chrysopedia/frontend/src/pages/AdminPipeline.tsx
jlightner 171775452e feat: Built AdminPipeline.tsx page at /admin/pipeline with video table,…
- "frontend/src/pages/AdminPipeline.tsx"
- "frontend/src/api/public-client.ts"
- "frontend/src/App.tsx"
- "frontend/src/App.css"

GSD-Task: S01/T03
2026-03-30 08:35:11 +00:00

417 lines
14 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, useState } from "react";
import {
fetchPipelineVideos,
fetchPipelineEvents,
fetchWorkerStatus,
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>
);
}
// ── 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 limit = 50;
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetchPipelineEvents(videoId, { offset, limit });
setEvents(res.items);
setTotal(res.total);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load events");
} finally {
setLoading(false);
}
}, [videoId, offset]);
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>
<button className="btn btn--small btn--secondary" onClick={() => void load()}> Refresh</button>
</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} />
</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>
);
}
// ── Main Page ────────────────────────────────────────────────────────────────
export default function AdminPipeline() {
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 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]);
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">
<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>
) : (
<div className="admin-pipeline__list">
{videos.map((video) => (
<div key={video.id} className="pipeline-video">
<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>
</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 ? "…" : "▶ 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>ID: {video.id.slice(0, 8)}</span>
<span>Created: {formatDate(video.created_at)}</span>
<span>Updated: {formatDate(video.updated_at)}</span>
</div>
<EventLog videoId={video.id} />
</div>
)}
</div>
))}
</div>
)}
</div>
);
}