mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
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:
parent
abfb678b4d
commit
d4b6afb020
77 changed files with 1001 additions and 245 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue