feat: add logs in campaigns for failure or pausing (#265)

* feat: add logs in campaigns on failure

* chore: bump pipecat

* chore: update format.sh

* chore: fix github workflow

* fix: fix formatting errors
This commit is contained in:
Abhishek 2026-05-05 19:23:50 +05:30 committed by GitHub
parent abfb678b4d
commit d4b6afb020
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 1001 additions and 245 deletions

View file

@ -1,7 +1,7 @@
"use client";
import { format } from 'date-fns';
import { ArrowLeft, CalendarIcon, Check, Clock, Download, Pause, Pencil, Phone, Play, RefreshCw, X } from 'lucide-react';
import { AlertCircle, AlertTriangle, ArrowLeft, CalendarIcon, Check, Clock, Download, Info, Pause, Pencil, Phone, Play, RefreshCw, X } from 'lucide-react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
@ -381,6 +381,38 @@ export default function CampaignDetailPage() {
const canEdit = campaign && ['created', 'running', 'paused'].includes(campaign.state);
// Newest entries first. The backend appends chronologically; the UI is more
// useful when the most recent failure / pause is at the top.
const sortedLogs = (campaign?.logs ?? []).slice().reverse();
const getLogIcon = (level: string) => {
switch (level) {
case 'error':
return <AlertCircle className="h-4 w-4 text-destructive" />;
case 'warning':
return <AlertTriangle className="h-4 w-4 text-amber-500" />;
default:
return <Info className="h-4 w-4 text-blue-500" />;
}
};
const getLogBadgeVariant = (level: string): 'destructive' | 'secondary' | 'outline' => {
switch (level) {
case 'error':
return 'destructive';
case 'warning':
return 'outline';
default:
return 'secondary';
}
};
const formatLogTimestamp = (ts: string) => {
const d = new Date(ts);
if (isNaN(d.getTime())) return ts;
return d.toLocaleString();
};
// Render action button based on state
const renderActionButton = () => {
if (!campaign || isExecutingAction) return null;
@ -796,6 +828,56 @@ export default function CampaignDetailPage() {
</CardContent>
</Card>
{/* Activity Log */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Activity Log</CardTitle>
<CardDescription>
Recent state transitions and failures. Newest first.
</CardDescription>
</CardHeader>
<CardContent>
{sortedLogs.length === 0 ? (
<p className="text-sm text-muted-foreground">No events recorded yet.</p>
) : (
<ul className="space-y-3">
{sortedLogs.map((entry, idx) => (
<li
key={`${entry.ts}-${idx}`}
className="flex gap-3 border-b last:border-b-0 pb-3 last:pb-0"
>
<div className="mt-0.5">{getLogIcon(entry.level)}</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={getLogBadgeVariant(entry.level)} className="text-xs">
{entry.level}
</Badge>
<code className="text-xs text-muted-foreground">
{entry.event}
</code>
<span className="text-xs text-muted-foreground">
{formatLogTimestamp(entry.ts)}
</span>
</div>
<p className="text-sm mt-1 break-words">{entry.message}</p>
{entry.details && Object.keys(entry.details).length > 0 && (
<details className="mt-1.5">
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
Details
</summary>
<pre className="mt-1.5 text-xs bg-muted rounded p-2 overflow-x-auto whitespace-pre-wrap break-words">
{JSON.stringify(entry.details, null, 2)}
</pre>
</details>
)}
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* Workflow Runs */}
<CampaignRuns
campaignId={campaignId}

View file

@ -8,9 +8,10 @@ import { useEffect, useState } from "react";
import { PhoneInput } from 'react-international-phone';
import {
getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet,
initiateCallApiV1TelephonyInitiateCallPost
initiateCallApiV1TelephonyInitiateCallPost,
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
} from '@/client/sdk.gen';
import type { TelephonyConfigurationListItem } from '@/client/types.gen';
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -22,6 +23,14 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUserConfig } from "@/context/UserConfigContext";
interface PhoneCallDialogProps {
@ -47,6 +56,8 @@ export const PhoneCallDialog = ({
const [checkingConfig, setCheckingConfig] = useState(false);
const [needsConfiguration, setNeedsConfiguration] = useState<boolean | null>(null);
const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || ""));
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
const [selectedConfigId, setSelectedConfigId] = useState<string>("");
// Check telephony configuration when dialog opens
useEffect(() => {
@ -55,16 +66,25 @@ export const PhoneCallDialog = ({
setCheckingConfig(true);
try {
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({});
const configResponse = await listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet({});
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz && !configResponse.data?.cloudonix && !configResponse.data?.ari && !configResponse.data?.telnyx && !configResponse.data?.plivo)) {
const configurations = configResponse.data?.configurations ?? [];
if (configResponse.error || configurations.length === 0) {
setNeedsConfiguration(true);
setTelephonyConfigs([]);
setSelectedConfigId("");
} else {
setNeedsConfiguration(false);
setTelephonyConfigs(configurations);
const defaultConfig =
configurations.find((c) => c.is_default_outbound) ?? configurations[0];
setSelectedConfigId(String(defaultConfig.id));
}
} catch (err) {
console.error("Failed to check telephony config:", err);
setNeedsConfiguration(false);
setTelephonyConfigs([]);
setSelectedConfigId("");
} finally {
setCheckingConfig(false);
}
@ -80,6 +100,8 @@ export const PhoneCallDialog = ({
setCallSuccessMsg(null);
setCallLoading(false);
setNeedsConfiguration(null);
setTelephonyConfigs([]);
setSelectedConfigId("");
}
}, [open]);
@ -124,7 +146,8 @@ export const PhoneCallDialog = ({
const response = await initiateCallApiV1TelephonyInitiateCallPost({
body: {
workflow_id: workflowId,
phone_number: phoneNumber
phone_number: phoneNumber,
telephony_configuration_id: selectedConfigId ? Number(selectedConfigId) : null,
},
});
@ -189,6 +212,24 @@ export const PhoneCallDialog = ({
Enter the phone number or SIP endpoint to call. The number will be saved automatically.
</DialogDescription>
</DialogHeader>
{telephonyConfigs.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="telephony-config">Telephony configuration</Label>
<Select value={selectedConfigId} onValueChange={setSelectedConfigId}>
<SelectTrigger id="telephony-config" className="w-full">
<SelectValue placeholder="Select a configuration" />
</SelectTrigger>
<SelectContent>
{telephonyConfigs.map((config) => (
<SelectItem key={config.id} value={String(config.id)}>
{config.name} ({config.provider})
{config.is_default_outbound ? " — default" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{sipMode ? (
<Input
value={phoneNumber}

File diff suppressed because one or more lines are too long

View file

@ -346,6 +346,39 @@ export type CampaignDefaultsResponse = {
last_campaign_settings?: LastCampaignSettingsResponse | null;
};
/**
* CampaignLogEntryResponse
*
* A single timestamped entry from the campaign's append-only log.
*
* Surfaced in the UI so operators can see why a campaign moved to
* paused / failed without digging through server logs.
*/
export type CampaignLogEntryResponse = {
/**
* Ts
*/
ts: string;
/**
* Level
*/
level: string;
/**
* Event
*/
event: string;
/**
* Message
*/
message: string;
/**
* Details
*/
details?: {
[key: string]: unknown;
} | null;
};
/**
* CampaignProgressResponse
*/
@ -481,6 +514,10 @@ export type CampaignResponse = {
* Telephony Configuration Name
*/
telephony_configuration_name?: string | null;
/**
* Logs
*/
logs?: Array<CampaignLogEntryResponse>;
};
/**