chrysopedia/frontend/src/pages/AdminPipeline.tsx

668 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Pipeline admin dashboard — video list with status, retrigger/revoke,
* expandable event log with token usage and collapsible JSON viewer.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
fetchPipelineVideos,
fetchPipelineEvents,
fetchWorkerStatus,
fetchDebugMode,
setDebugMode,
triggerPipeline,
revokePipeline,
type PipelineVideoItem,
type PipelineEvent,
type WorkerStatusResponse,
} from "../api/public-client";
// ── Helpers ──────────────────────────────────────────────────────────────────
function formatDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function formatTokens(n: number): string {
if (n === 0) return "0";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
function statusBadgeClass(status: string): string {
switch (status) {
case "completed":
case "indexed":
return "pipeline-badge--success";
case "processing":
case "extracted":
case "classified":
case "synthesized":
return "pipeline-badge--active";
case "failed":
case "error":
return "pipeline-badge--error";
case "pending":
case "queued":
return "pipeline-badge--pending";
default:
return "";
}
}
function eventTypeIcon(eventType: string): string {
switch (eventType) {
case "start":
return "▶";
case "complete":
return "✓";
case "error":
return "✗";
case "llm_call":
return "🤖";
default:
return "·";
}
}
// ── Collapsible JSON ─────────────────────────────────────────────────────────
function JsonViewer({ data }: { data: Record<string, unknown> | null }) {
const [open, setOpen] = useState(false);
if (!data || Object.keys(data).length === 0) return null;
return (
<div className="json-viewer">
<button
className="json-viewer__toggle"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
{open ? "▾ Hide payload" : "▸ Show payload"}
</button>
{open && (
<pre className="json-viewer__content">
{JSON.stringify(data, null, 2)}
</pre>
)}
</div>
);
}
// ── Debug Payload Viewer ─────────────────────────────────────────────────────
interface DebugSection {
label: string;
content: string;
}
function DebugPayloadViewer({ event }: { event: PipelineEvent }) {
const sections: DebugSection[] = [];
if (event.system_prompt_text) sections.push({ label: "System Prompt", content: event.system_prompt_text });
if (event.user_prompt_text) sections.push({ label: "User Prompt", content: event.user_prompt_text });
if (event.response_text) sections.push({ label: "Response", content: event.response_text });
const [openSections, setOpenSections] = useState<Record<string, boolean>>({});
const [copiedKey, setCopiedKey] = useState<string | null>(null);
if (sections.length === 0) return null;
const toggleSection = (label: string) => {
setOpenSections((prev) => ({ ...prev, [label]: !prev[label] }));
};
const copyToClipboard = async (label: string, text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedKey(label);
setTimeout(() => setCopiedKey(null), 1500);
} catch {
// Fallback for non-HTTPS contexts
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopiedKey(label);
setTimeout(() => setCopiedKey(null), 1500);
}
};
const exportAsJson = () => {
const data: Record<string, string | null> = {
event_id: event.id,
stage: event.stage,
event_type: event.event_type,
model: event.model,
system_prompt_text: event.system_prompt_text,
user_prompt_text: event.user_prompt_text,
response_text: event.response_text,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `debug-${event.stage}-${event.id.slice(0, 8)}.json`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="debug-viewer">
<div className="debug-viewer__header">
<span className="debug-viewer__label">LLM Debug</span>
<button className="debug-viewer__export" onClick={exportAsJson} title="Export debug payload as JSON">
JSON
</button>
</div>
{sections.map((sec) => {
const isOpen = !!openSections[sec.label];
return (
<div key={sec.label} className="debug-viewer__section">
<div className="debug-viewer__section-header">
<button
className="debug-viewer__section-toggle"
onClick={() => toggleSection(sec.label)}
aria-expanded={isOpen}
>
{isOpen ? "▾" : "▸"} {sec.label}
</button>
<button
className="debug-viewer__copy"
onClick={() => void copyToClipboard(sec.label, sec.content)}
title={`Copy ${sec.label}`}
>
{copiedKey === sec.label ? "✓ Copied" : "Copy"}
</button>
</div>
{isOpen && (
<pre className="debug-viewer__content">{sec.content}</pre>
)}
</div>
);
})}
</div>
);
}
// ── Event Log ────────────────────────────────────────────────────────────────
function EventLog({ videoId }: { videoId: string }) {
const [events, setEvents] = useState<PipelineEvent[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [offset, setOffset] = useState(0);
const [viewMode, setViewMode] = useState<"head" | "tail">("tail");
const limit = 50;
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetchPipelineEvents(videoId, {
offset,
limit,
order: viewMode === "head" ? "asc" : "desc",
});
setEvents(res.items);
setTotal(res.total);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load events");
} finally {
setLoading(false);
}
}, [videoId, offset, viewMode]);
useEffect(() => {
void load();
}, [load]);
if (loading) return <div className="loading">Loading events</div>;
if (error) return <div className="loading error-text">Error: {error}</div>;
if (events.length === 0) return <div className="pipeline-events__empty">No events recorded.</div>;
const hasNext = offset + limit < total;
const hasPrev = offset > 0;
return (
<div className="pipeline-events">
<div className="pipeline-events__header">
<span className="pipeline-events__count">{total} event{total !== 1 ? "s" : ""}</span>
<div className="pipeline-events__view-toggle">
<button
className={`pipeline-events__view-btn${viewMode === "head" ? " pipeline-events__view-btn--active" : ""}`}
onClick={() => { setViewMode("head"); setOffset(0); }}
>
Oldest first
</button>
<button
className={`pipeline-events__view-btn${viewMode === "tail" ? " pipeline-events__view-btn--active" : ""}`}
onClick={() => { setViewMode("tail"); setOffset(0); }}
>
Newest first
</button>
</div>
</div>
<div className="pipeline-events__list">
{events.map((evt) => (
<div key={evt.id} className={`pipeline-event pipeline-event--${evt.event_type}`}>
<div className="pipeline-event__row">
<span className="pipeline-event__icon">{eventTypeIcon(evt.event_type)}</span>
<span className="pipeline-event__stage">{evt.stage}</span>
<span className={`pipeline-badge pipeline-badge--event-${evt.event_type}`}>
{evt.event_type}
</span>
{evt.model && <span className="pipeline-event__model">{evt.model}</span>}
{evt.total_tokens != null && evt.total_tokens > 0 && (
<span className="pipeline-event__tokens" title={`prompt: ${evt.prompt_tokens ?? 0} / completion: ${evt.completion_tokens ?? 0}`}>
{formatTokens(evt.total_tokens)} tok
</span>
)}
{evt.duration_ms != null && (
<span className="pipeline-event__duration">{evt.duration_ms}ms</span>
)}
<span className="pipeline-event__time">{formatDate(evt.created_at)}</span>
</div>
<JsonViewer data={evt.payload} />
<DebugPayloadViewer event={evt} />
</div>
))}
</div>
{(hasPrev || hasNext) && (
<div className="pipeline-events__pager">
<button
className="btn btn--small btn--secondary"
disabled={!hasPrev}
onClick={() => setOffset((o) => Math.max(0, o - limit))}
>
Prev
</button>
<span className="pipeline-events__pager-info">
{offset + 1}{Math.min(offset + limit, total)} of {total}
</span>
<button
className="btn btn--small btn--secondary"
disabled={!hasNext}
onClick={() => setOffset((o) => o + limit)}
>
Next
</button>
</div>
)}
</div>
);
}
// ── Worker Status ────────────────────────────────────────────────────────────
function WorkerStatus() {
const [status, setStatus] = useState<WorkerStatusResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setError(null);
const res = await fetchWorkerStatus();
setStatus(res);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed");
}
}, []);
useEffect(() => {
void load();
const id = setInterval(() => void load(), 15_000);
return () => clearInterval(id);
}, [load]);
if (error) {
return (
<div className="worker-status worker-status--error">
<span className="worker-status__dot worker-status__dot--offline" />
Worker: error ({error})
</div>
);
}
if (!status) {
return (
<div className="worker-status">
<span className="worker-status__dot worker-status__dot--unknown" />
Worker: checking
</div>
);
}
return (
<div className={`worker-status ${status.online ? "worker-status--online" : "worker-status--offline"}`}>
<span className={`worker-status__dot ${status.online ? "worker-status__dot--online" : "worker-status__dot--offline"}`} />
<span className="worker-status__label">
{status.online ? `${status.workers.length} worker${status.workers.length !== 1 ? "s" : ""} online` : "Workers offline"}
</span>
{status.workers.map((w) => (
<span key={w.name} className="worker-status__detail" title={w.name}>
{w.active_tasks.length > 0
? `${w.active_tasks.length} active`
: "idle"}
{w.pool_size != null && ` · pool ${w.pool_size}`}
</span>
))}
</div>
);
}
// ── Debug Mode Toggle ────────────────────────────────────────────────────────
function DebugModeToggle({
debugMode,
onDebugModeChange,
}: {
debugMode: boolean | null;
onDebugModeChange: (mode: boolean) => void;
}) {
const [debugLoading, setDebugLoading] = useState(false);
async function handleToggle() {
if (debugMode === null || debugLoading) return;
setDebugLoading(true);
try {
const res = await setDebugMode(!debugMode);
onDebugModeChange(res.debug_mode);
} catch {
// swallow — leave previous state
} finally {
setDebugLoading(false);
}
}
if (debugMode === null) return null;
return (
<div className="debug-toggle" title="When enabled, pipeline runs capture full LLM prompts and responses for inspection in the event log">
<span className="debug-toggle__label">
Debug {debugMode ? "On" : "Off"}
</span>
<button
type="button"
className={`debug-toggle__switch ${debugMode ? "debug-toggle__switch--active" : ""}`}
onClick={handleToggle}
disabled={debugLoading}
aria-label={`Turn debug mode ${debugMode ? "off" : "on"}`}
/>
</div>
);
}
// ── Status Filter ────────────────────────────────────────────────────────────
function StatusFilter({
videos,
activeFilter,
onFilterChange,
}: {
videos: PipelineVideoItem[];
activeFilter: string | null;
onFilterChange: (filter: string | null) => void;
}) {
const statuses = Array.from(new Set(videos.map((v) => v.processing_status))).sort();
if (statuses.length <= 1) return null;
return (
<div className="filter-tabs">
<button
type="button"
className={`filter-tab ${activeFilter === null ? "filter-tab--active" : ""}`}
onClick={() => onFilterChange(null)}
>
All
</button>
{statuses.map((status) => (
<button
key={status}
type="button"
className={`filter-tab ${activeFilter === status ? "filter-tab--active" : ""}`}
onClick={() => onFilterChange(status)}
>
{status} ({videos.filter((v) => v.processing_status === status).length})
</button>
))}
</div>
);
}
// ── Main Page ────────────────────────────────────────────────────────────────
export default function AdminPipeline() {
const [searchParams] = useSearchParams();
const [videos, setVideos] = useState<PipelineVideoItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionMessage, setActionMessage] = useState<{ id: string; text: string; ok: boolean } | null>(null);
const [activeFilter, setActiveFilter] = useState<string | null>(null);
const [debugMode, setDebugModeState] = useState<boolean | null>(null);
const videoRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const deepLinked = useRef(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetchPipelineVideos();
setVideos(res.items);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load videos");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// Deep-link: auto-expand and scroll to ?video=<id> on first load
useEffect(() => {
if (deepLinked.current || loading || videos.length === 0) return;
const targetVideoId = searchParams.get("video");
if (!targetVideoId) return;
const match = videos.find((v) => v.id === targetVideoId);
if (!match) return;
deepLinked.current = true;
setExpandedId(targetVideoId);
// Scroll after the expanded detail renders
requestAnimationFrame(() => {
const el = videoRefs.current.get(targetVideoId);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
});
}, [loading, videos, searchParams]);
useEffect(() => {
let cancelled = false;
fetchDebugMode()
.then((res) => {
if (!cancelled) setDebugModeState(res.debug_mode);
})
.catch(() => {
// silently fail — toggle stays hidden
});
return () => { cancelled = true; };
}, []);
const handleTrigger = async (videoId: string) => {
setActionLoading(videoId);
setActionMessage(null);
try {
const res = await triggerPipeline(videoId);
setActionMessage({ id: videoId, text: `Triggered (${res.status})`, ok: true });
// Refresh after short delay to let status update
setTimeout(() => void load(), 2000);
} catch (err) {
setActionMessage({
id: videoId,
text: err instanceof Error ? err.message : "Trigger failed",
ok: false,
});
} finally {
setActionLoading(null);
}
};
const handleRevoke = async (videoId: string) => {
setActionLoading(videoId);
setActionMessage(null);
try {
const res = await revokePipeline(videoId);
setActionMessage({
id: videoId,
text: res.tasks_revoked > 0
? `Revoked ${res.tasks_revoked} task${res.tasks_revoked !== 1 ? "s" : ""}`
: "No active tasks",
ok: true,
});
setTimeout(() => void load(), 2000);
} catch (err) {
setActionMessage({
id: videoId,
text: err instanceof Error ? err.message : "Revoke failed",
ok: false,
});
} finally {
setActionLoading(null);
}
};
const toggleExpand = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
return (
<div className="admin-pipeline">
<div className="admin-pipeline__header">
<div>
<h2 className="admin-pipeline__title">Pipeline Management</h2>
<p className="admin-pipeline__subtitle">
{videos.length} video{videos.length !== 1 ? "s" : ""}
</p>
</div>
<div className="admin-pipeline__header-right">
<DebugModeToggle debugMode={debugMode} onDebugModeChange={setDebugModeState} />
<WorkerStatus />
<button className="btn btn--secondary" onClick={() => void load()} disabled={loading}>
Refresh
</button>
</div>
</div>
{loading ? (
<div className="loading">Loading videos</div>
) : error ? (
<div className="loading error-text">Error: {error}</div>
) : videos.length === 0 ? (
<div className="empty-state">No videos in pipeline.</div>
) : (
<>
<StatusFilter
videos={videos}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
/>
<div className="admin-pipeline__list">
{videos
.filter((v) => activeFilter === null || v.processing_status === activeFilter)
.map((video) => (
<div key={video.id} className="pipeline-video" ref={(el) => { if (el) videoRefs.current.set(video.id, el); }}>
<div
className="pipeline-video__header"
onClick={() => toggleExpand(video.id)}
>
<div className="pipeline-video__info">
<span className="pipeline-video__filename" title={video.filename}>
{video.filename}
</span>
<span className="pipeline-video__creator">{video.creator_name}</span>
</div>
<div className="pipeline-video__meta">
<span className={`pipeline-badge ${statusBadgeClass(video.processing_status)}`}>
{video.processing_status}
</span>
<span className="pipeline-video__stat" title="Events">
{video.event_count} events
</span>
<span className="pipeline-video__stat" title="Total tokens used">
{formatTokens(video.total_tokens_used)} tokens
</span>
<span className="pipeline-video__time">
{formatDate(video.last_event_at)}
</span>
<a
className="pipeline-video__review-link"
href="/admin/review"
title={`Review moments from ${video.creator_name}`}
onClick={(e) => e.stopPropagation()}
>
Moments
</a>
</div>
<div className="pipeline-video__actions" onClick={(e) => e.stopPropagation()}>
<button
className="btn btn--small btn--primary"
onClick={() => void handleTrigger(video.id)}
disabled={actionLoading === video.id}
title="Retrigger pipeline"
>
{actionLoading === video.id ? "…" : debugMode ? "▶ Trigger (debug)" : "▶ Trigger"}
</button>
<button
className="btn btn--small btn--danger"
onClick={() => void handleRevoke(video.id)}
disabled={actionLoading === video.id}
title="Revoke active tasks"
>
{actionLoading === video.id ? "…" : "■ Revoke"}
</button>
</div>
</div>
{actionMessage?.id === video.id && (
<div className={`pipeline-video__message ${actionMessage.ok ? "pipeline-video__message--ok" : "pipeline-video__message--err"}`}>
{actionMessage.text}
</div>
)}
{expandedId === video.id && (
<div className="pipeline-video__detail">
<div className="pipeline-video__detail-meta">
<span>Created: {formatDate(video.created_at)}</span>
<span>Updated: {formatDate(video.updated_at)}</span>
</div>
<EventLog videoId={video.id} />
</div>
)}
</div>
))}
</div>
</>
)}
</div>
);
}