- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
341 lines
10 KiB
TypeScript
341 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import {
|
|
ArrowLeft,
|
|
Loader2,
|
|
CircleCheck,
|
|
CircleAlert,
|
|
Clock,
|
|
UserPlus,
|
|
PlayCircle,
|
|
PauseCircle,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { format, formatDistanceToNow } from "date-fns";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* Run history viewer.
|
|
*
|
|
* Lists the 50 most recent runs for a flow, newest first. Each row
|
|
* collapses to a one-liner (contact + status + time); expanding shows
|
|
* the full `flow_run_events` timeline for that run — useful for
|
|
* debugging "why didn't my flow advance?" by surfacing the engine's
|
|
* own log.
|
|
*/
|
|
|
|
interface RunRow {
|
|
id: string;
|
|
status:
|
|
| "active"
|
|
| "completed"
|
|
| "handed_off"
|
|
| "timed_out"
|
|
| "paused_by_agent"
|
|
| "failed";
|
|
current_node_key: string | null;
|
|
started_at: string;
|
|
last_advanced_at: string;
|
|
ended_at: string | null;
|
|
end_reason: string | null;
|
|
vars: Record<string, unknown>;
|
|
reprompt_count: number;
|
|
contact: { id: string; name: string | null; phone: string } | null;
|
|
}
|
|
|
|
interface EventRow {
|
|
flow_run_id: string;
|
|
event_type: string;
|
|
node_key: string | null;
|
|
payload: Record<string, unknown>;
|
|
created_at: string;
|
|
}
|
|
|
|
const STATUS_META: Record<
|
|
RunRow["status"],
|
|
{ label: string; classes: string; icon: typeof Clock }
|
|
> = {
|
|
active: {
|
|
label: "Active",
|
|
classes: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
|
|
icon: PlayCircle,
|
|
},
|
|
completed: {
|
|
label: "Completed",
|
|
classes: "border-border bg-muted text-muted-foreground",
|
|
icon: CircleCheck,
|
|
},
|
|
handed_off: {
|
|
label: "Handed off",
|
|
classes: "border-amber-600/40 bg-amber-500/10 text-amber-300",
|
|
icon: UserPlus,
|
|
},
|
|
timed_out: {
|
|
label: "Timed out",
|
|
classes: "border-border bg-muted/60 text-muted-foreground",
|
|
icon: Clock,
|
|
},
|
|
paused_by_agent: {
|
|
label: "Paused by agent",
|
|
classes: "border-border bg-muted text-muted-foreground",
|
|
icon: PauseCircle,
|
|
},
|
|
failed: {
|
|
label: "Failed",
|
|
classes: "border-red-600/40 bg-red-500/10 text-red-300",
|
|
icon: CircleAlert,
|
|
},
|
|
};
|
|
|
|
export default function FlowRunsPage() {
|
|
const router = useRouter();
|
|
const params = useParams<{ id: string }>();
|
|
|
|
const [flow, setFlow] = useState<{ id: string; name: string } | null>(null);
|
|
const [runs, setRuns] = useState<RunRow[]>([]);
|
|
const [events, setEvents] = useState<EventRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
const [notFound, setNotFound] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!params.id) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const res = await fetch(`/api/flows/${params.id}/runs`);
|
|
if (res.status === 404) {
|
|
if (!cancelled) setNotFound(true);
|
|
return;
|
|
}
|
|
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
|
const json = (await res.json()) as {
|
|
flow: { id: string; name: string };
|
|
runs: RunRow[];
|
|
events: EventRow[];
|
|
};
|
|
if (!cancelled) {
|
|
setFlow(json.flow);
|
|
setRuns(json.runs ?? []);
|
|
setEvents(json.events ?? []);
|
|
}
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
console.error(err);
|
|
toast.error("Couldn't load runs.");
|
|
}
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [params.id]);
|
|
|
|
function toggle(runId: string) {
|
|
setExpanded((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(runId)) next.delete(runId);
|
|
else next.add(runId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
if (notFound || !flow) {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3">
|
|
<p className="text-sm text-muted-foreground">Flow not found.</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push("/flows")}
|
|
className="text-sm text-primary hover:opacity-80"
|
|
>
|
|
← Back to flows
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-4xl p-6">
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push(`/flows/${flow.id}`)}
|
|
className="mb-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="h-3 w-3" />
|
|
{flow.name}
|
|
</button>
|
|
<h1 className="text-xl font-semibold text-foreground">Runs</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
The 50 most recent times this flow ran. Expand a row to see the engine's
|
|
per-step log.
|
|
</p>
|
|
|
|
{runs.length === 0 ? (
|
|
<div className="mt-6 rounded-lg border border-dashed border-border bg-card/50 px-6 py-12 text-center text-sm text-muted-foreground">
|
|
No runs yet. Trigger the flow from a personal WhatsApp number to see
|
|
it appear here.
|
|
</div>
|
|
) : (
|
|
<div className="mt-6 flex flex-col gap-2">
|
|
{runs.map((run) => (
|
|
<RunCard
|
|
key={run.id}
|
|
run={run}
|
|
events={events.filter((e) => e.flow_run_id === run.id)}
|
|
expanded={expanded.has(run.id)}
|
|
onToggle={() => toggle(run.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RunCard({
|
|
run,
|
|
events,
|
|
expanded,
|
|
onToggle,
|
|
}: {
|
|
run: RunRow;
|
|
events: EventRow[];
|
|
expanded: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
const meta = STATUS_META[run.status];
|
|
const StatusIcon = meta.icon;
|
|
const contactLabel =
|
|
run.contact?.name?.trim() || run.contact?.phone || "Unknown contact";
|
|
const duration = run.ended_at
|
|
? formatDistanceToNow(new Date(run.ended_at), {
|
|
addSuffix: false,
|
|
})
|
|
: null;
|
|
return (
|
|
<div className="rounded-lg border border-border bg-card">
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className="flex w-full items-center gap-3 px-4 py-3 text-left"
|
|
>
|
|
{expanded ? (
|
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="truncate text-sm font-medium text-foreground">
|
|
{contactLabel}
|
|
</span>
|
|
<Badge variant="outline" className={cn("gap-1", meta.classes)}>
|
|
<StatusIcon className="h-3 w-3" />
|
|
{meta.label}
|
|
</Badge>
|
|
{run.status === "active" && run.current_node_key && (
|
|
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
|
at {run.current_node_key}
|
|
</code>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
|
<span>Started {format(new Date(run.started_at), "PP p")}</span>
|
|
{run.reprompt_count > 0 && (
|
|
<span>· {run.reprompt_count} re-prompts</span>
|
|
)}
|
|
{duration && <span>· ran for {duration}</span>}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{expanded && (
|
|
<div className="border-t border-border px-4 py-3">
|
|
{Object.keys(run.vars).length > 0 && (
|
|
<details className="mb-3">
|
|
<summary className="cursor-pointer text-xs text-muted-foreground">
|
|
Captured vars ({Object.keys(run.vars).length})
|
|
</summary>
|
|
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
|
|
{JSON.stringify(run.vars, null, 2)}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
<div className="flex flex-col gap-1">
|
|
{events.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
No events recorded for this run.
|
|
</p>
|
|
) : (
|
|
events.map((ev, ix) => <EventLine key={ix} ev={ev} />)
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const EVENT_COLOR: Record<string, string> = {
|
|
started: "text-emerald-300",
|
|
node_entered: "text-muted-foreground",
|
|
message_sent: "text-sky-300",
|
|
reply_received: "text-primary",
|
|
fallback_fired: "text-amber-300",
|
|
handoff: "text-amber-300",
|
|
timeout: "text-muted-foreground",
|
|
error: "text-red-300",
|
|
completed: "text-emerald-300",
|
|
};
|
|
|
|
function EventLine({ ev }: { ev: EventRow }) {
|
|
const cls = EVENT_COLOR[ev.event_type] ?? "text-muted-foreground";
|
|
return (
|
|
<div className="flex items-start gap-2 rounded-md px-2 py-1 text-xs">
|
|
<span className="w-32 shrink-0 text-[10px] text-muted-foreground">
|
|
{format(new Date(ev.created_at), "HH:mm:ss")}
|
|
</span>
|
|
<span className={cn("w-32 shrink-0 font-mono text-[10px]", cls)}>
|
|
{ev.event_type}
|
|
</span>
|
|
{ev.node_key && (
|
|
<code className="shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
|
|
{ev.node_key}
|
|
</code>
|
|
)}
|
|
{Object.keys(ev.payload).length > 0 && (
|
|
<span className="min-w-0 truncate text-[10px] text-muted-foreground">
|
|
{summarizePayload(ev.payload)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function summarizePayload(payload: Record<string, unknown>): string {
|
|
// Show the keys that matter most to a human debugger; full JSON is
|
|
// available via the "Captured vars" details panel for the run.
|
|
const keys = ["reply_id", "captured_key", "reason", "advancing_to"];
|
|
for (const k of keys) {
|
|
if (k in payload && payload[k] !== null && payload[k] !== undefined) {
|
|
return `${k}=${String(payload[k]).slice(0, 80)}`;
|
|
}
|
|
}
|
|
return "";
|
|
}
|