Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- 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/
This commit is contained in:
340
wacrm/src/app/(dashboard)/flows/[id]/runs/page.tsx
Normal file
340
wacrm/src/app/(dashboard)/flows/[id]/runs/page.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
"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 "";
|
||||
}
|
||||
Reference in New Issue
Block a user