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
This commit is contained in:
parent
b3d405bb84
commit
26556ba03e
7 changed files with 3801 additions and 1 deletions
|
|
@ -12,7 +12,7 @@
|
|||
- Estimate: 30min
|
||||
- Files: backend/routers/pipeline.py, backend/schemas.py, backend/main.py
|
||||
- Verify: curl -s http://localhost:8096/api/v1/admin/pipeline/videos | python3 -m json.tool && curl -s http://localhost:8096/api/v1/admin/pipeline/worker-status | python3 -m json.tool
|
||||
- [ ] **T03: Pipeline admin frontend page** — New AdminPipeline.tsx page at /admin/pipeline. Video list table with status badges, retrigger/pause buttons. Expandable row showing event log timeline with token usage and collapsible JSON response viewer. Worker status indicator. Wire into App.tsx and nav.
|
||||
- [x] **T03: Built AdminPipeline.tsx page at /admin/pipeline with video table, status badges, retrigger/revoke buttons, expandable event log with token usage, collapsible JSON responses, and live worker status indicator** — New AdminPipeline.tsx page at /admin/pipeline. Video list table with status badges, retrigger/pause buttons. Expandable row showing event log timeline with token usage and collapsible JSON response viewer. Worker status indicator. Wire into App.tsx and nav.
|
||||
- Estimate: 45min
|
||||
- Files: frontend/src/pages/AdminPipeline.tsx, frontend/src/api/public-client.ts, frontend/src/App.tsx, frontend/src/App.css
|
||||
- Verify: docker compose build chrysopedia-web 2>&1 | tail -5 (exit 0, zero TS errors)
|
||||
|
|
|
|||
9
.gsd/milestones/M005/slices/S01/tasks/T02-VERIFY.json
Normal file
9
.gsd/milestones/M005/slices/S01/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T02",
|
||||
"unitId": "M005/S01/T02",
|
||||
"timestamp": 1774859415126,
|
||||
"passed": true,
|
||||
"discoverySource": "none",
|
||||
"checks": []
|
||||
}
|
||||
83
.gsd/milestones/M005/slices/S01/tasks/T03-SUMMARY.md
Normal file
83
.gsd/milestones/M005/slices/S01/tasks/T03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
id: T03
|
||||
parent: S01
|
||||
milestone: M005
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["frontend/src/pages/AdminPipeline.tsx", "frontend/src/api/public-client.ts", "frontend/src/App.tsx", "frontend/src/App.css"]
|
||||
key_decisions: ["Used grid layout for video rows with info/meta/actions columns", "Worker status auto-refreshes every 15s via setInterval", "JsonViewer component for collapsible JSON payload display"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Docker compose build chrysopedia-web succeeded with zero TS errors (exit 0). Browser verification at http://ub01:8096/admin/pipeline confirmed: page renders with 3 videos, status badges, worker status indicator, expandable event log with 24 events, collapsible JSON viewer, and zero console errors."
|
||||
completed_at: 2026-03-30T08:35:03.406Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Built AdminPipeline.tsx page at /admin/pipeline with video table, status badges, retrigger/revoke buttons, expandable event log with token usage, collapsible JSON responses, and live worker status indicator
|
||||
|
||||
> Built AdminPipeline.tsx page at /admin/pipeline with video table, status badges, retrigger/revoke buttons, expandable event log with token usage, collapsible JSON responses, and live worker status indicator
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T03
|
||||
parent: S01
|
||||
milestone: M005
|
||||
key_files:
|
||||
- frontend/src/pages/AdminPipeline.tsx
|
||||
- frontend/src/api/public-client.ts
|
||||
- frontend/src/App.tsx
|
||||
- frontend/src/App.css
|
||||
key_decisions:
|
||||
- Used grid layout for video rows with info/meta/actions columns
|
||||
- Worker status auto-refreshes every 15s via setInterval
|
||||
- JsonViewer component for collapsible JSON payload display
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-30T08:35:03.407Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Built AdminPipeline.tsx page at /admin/pipeline with video table, status badges, retrigger/revoke buttons, expandable event log with token usage, collapsible JSON responses, and live worker status indicator
|
||||
|
||||
**Built AdminPipeline.tsx page at /admin/pipeline with video table, status badges, retrigger/revoke buttons, expandable event log with token usage, collapsible JSON responses, and live worker status indicator**
|
||||
|
||||
## What Happened
|
||||
|
||||
Created the pipeline admin frontend page with four file changes: AdminPipeline.tsx with video list, event log, JSON viewer, and worker status; API client functions in public-client.ts; route and nav in App.tsx; themed CSS in App.css. All components use real API data from the five backend endpoints verified in T02. Worker status auto-refreshes every 15s. Event log shows pagination, token counts, model names, and collapsible JSON payloads.
|
||||
|
||||
## Verification
|
||||
|
||||
Docker compose build chrysopedia-web succeeded with zero TS errors (exit 0). Browser verification at http://ub01:8096/admin/pipeline confirmed: page renders with 3 videos, status badges, worker status indicator, expandable event log with 24 events, collapsible JSON viewer, and zero console errors.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `docker compose build chrysopedia-web 2>&1 | tail -5` | 0 | ✅ pass | 4700ms |
|
||||
| 2 | `browser_assert: text_visible Pipeline Management + selector_visible .pipeline-video + selector_visible .worker-status + no_console_errors` | 0 | ✅ pass | 1000ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `frontend/src/pages/AdminPipeline.tsx`
|
||||
- `frontend/src/api/public-client.ts`
|
||||
- `frontend/src/App.tsx`
|
||||
- `frontend/src/App.css`
|
||||
|
||||
|
||||
## Deviations
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
2764
frontend/src/App.css
Normal file
2764
frontend/src/App.css
Normal file
File diff suppressed because it is too large
Load diff
58
frontend/src/App.tsx
Normal file
58
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Link, Navigate, Route, Routes } from "react-router-dom";
|
||||
import Home from "./pages/Home";
|
||||
import SearchResults from "./pages/SearchResults";
|
||||
import TechniquePage from "./pages/TechniquePage";
|
||||
import CreatorsBrowse from "./pages/CreatorsBrowse";
|
||||
import CreatorDetail from "./pages/CreatorDetail";
|
||||
import TopicsBrowse from "./pages/TopicsBrowse";
|
||||
import ReviewQueue from "./pages/ReviewQueue";
|
||||
import MomentDetail from "./pages/MomentDetail";
|
||||
import AdminReports from "./pages/AdminReports";
|
||||
import AdminPipeline from "./pages/AdminPipeline";
|
||||
import ModeToggle from "./components/ModeToggle";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<Link to="/" className="app-header__brand">
|
||||
<h1>Chrysopedia</h1>
|
||||
</Link>
|
||||
<div className="app-header__right">
|
||||
<nav className="app-nav">
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/topics">Topics</Link>
|
||||
<Link to="/creators">Creators</Link>
|
||||
<Link to="/admin/review">Review</Link>
|
||||
<Link to="/admin/reports">Reports</Link>
|
||||
<Link to="/admin/pipeline">Pipeline</Link>
|
||||
</nav>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/techniques/:slug" element={<TechniquePage />} />
|
||||
|
||||
{/* Browse routes */}
|
||||
<Route path="/creators" element={<CreatorsBrowse />} />
|
||||
<Route path="/creators/:slug" element={<CreatorDetail />} />
|
||||
<Route path="/topics" element={<TopicsBrowse />} />
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route path="/admin/review" element={<ReviewQueue />} />
|
||||
<Route path="/admin/review/:momentId" element={<MomentDetail />} />
|
||||
<Route path="/admin/reports" element={<AdminReports />} />
|
||||
<Route path="/admin/pipeline" element={<AdminPipeline />} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
469
frontend/src/api/public-client.ts
Normal file
469
frontend/src/api/public-client.ts
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
/**
|
||||
* Typed API client for Chrysopedia public endpoints.
|
||||
*
|
||||
* Mirrors backend schemas: SearchResponse, TechniquePageDetail, TopicCategory, CreatorBrowseItem.
|
||||
* Uses the same request<T> pattern as client.ts.
|
||||
*/
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SearchResultItem {
|
||||
title: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
score: number;
|
||||
summary: string;
|
||||
creator_name: string;
|
||||
creator_slug: string;
|
||||
topic_category: string;
|
||||
topic_tags: string[];
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
items: SearchResultItem[];
|
||||
total: number;
|
||||
query: string;
|
||||
fallback_used: boolean;
|
||||
}
|
||||
|
||||
export interface KeyMomentSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
content_type: string;
|
||||
plugins: string[] | null;
|
||||
video_filename: string;
|
||||
}
|
||||
|
||||
export interface CreatorInfo {
|
||||
name: string;
|
||||
slug: string;
|
||||
genres: string[] | null;
|
||||
}
|
||||
|
||||
export interface RelatedLinkItem {
|
||||
target_title: string;
|
||||
target_slug: string;
|
||||
relationship: string;
|
||||
}
|
||||
|
||||
export interface TechniquePageDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
topic_category: string;
|
||||
topic_tags: string[] | null;
|
||||
summary: string | null;
|
||||
body_sections: Record<string, unknown> | null;
|
||||
signal_chains: unknown[] | null;
|
||||
plugins: string[] | null;
|
||||
creator_id: string;
|
||||
source_quality: string | null;
|
||||
view_count: number;
|
||||
review_status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
key_moments: KeyMomentSummary[];
|
||||
creator_info: CreatorInfo | null;
|
||||
related_links: RelatedLinkItem[];
|
||||
version_count: number;
|
||||
}
|
||||
|
||||
export interface TechniquePageVersionSummary {
|
||||
version_number: number;
|
||||
created_at: string;
|
||||
pipeline_metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface TechniquePageVersionListResponse {
|
||||
items: TechniquePageVersionSummary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TechniquePageVersionDetail {
|
||||
version_number: number;
|
||||
content_snapshot: Record<string, unknown>;
|
||||
pipeline_metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TechniqueListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
topic_category: string;
|
||||
topic_tags: string[] | null;
|
||||
summary: string | null;
|
||||
creator_id: string;
|
||||
source_quality: string | null;
|
||||
view_count: number;
|
||||
review_status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TechniqueListResponse {
|
||||
items: TechniqueListItem[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface TopicSubTopic {
|
||||
name: string;
|
||||
technique_count: number;
|
||||
creator_count: number;
|
||||
}
|
||||
|
||||
export interface TopicCategory {
|
||||
name: string;
|
||||
description: string;
|
||||
sub_topics: TopicSubTopic[];
|
||||
}
|
||||
|
||||
export interface CreatorBrowseItem {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
genres: string[] | null;
|
||||
folder_name: string;
|
||||
view_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
technique_count: number;
|
||||
video_count: number;
|
||||
}
|
||||
|
||||
export interface CreatorBrowseResponse {
|
||||
items: CreatorBrowseItem[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface CreatorDetailResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
genres: string[] | null;
|
||||
folder_name: string;
|
||||
view_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
video_count: number;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE = "/api/v1";
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public detail: string,
|
||||
) {
|
||||
super(`API ${status}: ${detail}`);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
const body: unknown = await res.json();
|
||||
if (typeof body === "object" && body !== null && "detail" in body) {
|
||||
const d = (body as { detail: unknown }).detail;
|
||||
detail = typeof d === "string" ? d : Array.isArray(d) ? d.map((e: any) => e.msg || JSON.stringify(e)).join("; ") : JSON.stringify(d);
|
||||
}
|
||||
} catch {
|
||||
// body not JSON — keep statusText
|
||||
}
|
||||
throw new ApiError(res.status, detail);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── Search ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function searchApi(
|
||||
q: string,
|
||||
scope?: string,
|
||||
limit?: number,
|
||||
): Promise<SearchResponse> {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (scope) qs.set("scope", scope);
|
||||
if (limit !== undefined) qs.set("limit", String(limit));
|
||||
return request<SearchResponse>(`${BASE}/search?${qs.toString()}`);
|
||||
}
|
||||
|
||||
// ── Techniques ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TechniqueListParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
category?: string;
|
||||
creator_slug?: string;
|
||||
}
|
||||
|
||||
export async function fetchTechniques(
|
||||
params: TechniqueListParams = {},
|
||||
): Promise<TechniqueListResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.limit !== undefined) qs.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) qs.set("offset", String(params.offset));
|
||||
if (params.category) qs.set("category", params.category);
|
||||
if (params.creator_slug) qs.set("creator_slug", params.creator_slug);
|
||||
const query = qs.toString();
|
||||
return request<TechniqueListResponse>(
|
||||
`${BASE}/techniques${query ? `?${query}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchTechnique(
|
||||
slug: string,
|
||||
): Promise<TechniquePageDetail> {
|
||||
return request<TechniquePageDetail>(`${BASE}/techniques/${slug}`);
|
||||
}
|
||||
|
||||
export async function fetchTechniqueVersions(
|
||||
slug: string,
|
||||
): Promise<TechniquePageVersionListResponse> {
|
||||
return request<TechniquePageVersionListResponse>(
|
||||
`${BASE}/techniques/${slug}/versions`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchTechniqueVersion(
|
||||
slug: string,
|
||||
versionNumber: number,
|
||||
): Promise<TechniquePageVersionDetail> {
|
||||
return request<TechniquePageVersionDetail>(
|
||||
`${BASE}/techniques/${slug}/versions/${versionNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Topics ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchTopics(): Promise<TopicCategory[]> {
|
||||
return request<TopicCategory[]>(`${BASE}/topics`);
|
||||
}
|
||||
|
||||
// ── Creators ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CreatorListParams {
|
||||
sort?: string;
|
||||
genre?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function fetchCreators(
|
||||
params: CreatorListParams = {},
|
||||
): Promise<CreatorBrowseResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.sort) qs.set("sort", params.sort);
|
||||
if (params.genre) qs.set("genre", params.genre);
|
||||
if (params.limit !== undefined) qs.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) qs.set("offset", String(params.offset));
|
||||
const query = qs.toString();
|
||||
return request<CreatorBrowseResponse>(
|
||||
`${BASE}/creators${query ? `?${query}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCreator(
|
||||
slug: string,
|
||||
): Promise<CreatorDetailResponse> {
|
||||
return request<CreatorDetailResponse>(`${BASE}/creators/${slug}`);
|
||||
}
|
||||
|
||||
|
||||
// ── Content Reports ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ContentReportCreate {
|
||||
content_type: string;
|
||||
content_id?: string | null;
|
||||
content_title?: string | null;
|
||||
report_type: string;
|
||||
description: string;
|
||||
page_url?: string | null;
|
||||
}
|
||||
|
||||
export interface ContentReport {
|
||||
id: string;
|
||||
content_type: string;
|
||||
content_id: string | null;
|
||||
content_title: string | null;
|
||||
report_type: string;
|
||||
description: string;
|
||||
status: string;
|
||||
admin_notes: string | null;
|
||||
page_url: string | null;
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
}
|
||||
|
||||
export interface ContentReportListResponse {
|
||||
items: ContentReport[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export async function submitReport(
|
||||
body: ContentReportCreate,
|
||||
): Promise<ContentReport> {
|
||||
return request<ContentReport>(`${BASE}/reports`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchReports(params: {
|
||||
status?: string;
|
||||
content_type?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
} = {}): Promise<ContentReportListResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.status) qs.set("status", params.status);
|
||||
if (params.content_type) qs.set("content_type", params.content_type);
|
||||
if (params.offset !== undefined) qs.set("offset", String(params.offset));
|
||||
if (params.limit !== undefined) qs.set("limit", String(params.limit));
|
||||
const query = qs.toString();
|
||||
return request<ContentReportListResponse>(
|
||||
`${BASE}/admin/reports${query ? `?${query}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateReport(
|
||||
id: string,
|
||||
body: { status?: string; admin_notes?: string },
|
||||
): Promise<ContentReport> {
|
||||
return request<ContentReport>(`${BASE}/admin/reports/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Pipeline Admin ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface PipelineVideoItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
processing_status: string;
|
||||
creator_name: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
event_count: number;
|
||||
total_tokens_used: number;
|
||||
last_event_at: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineVideoListResponse {
|
||||
items: PipelineVideoItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PipelineEvent {
|
||||
id: string;
|
||||
video_id: string;
|
||||
stage: string;
|
||||
event_type: string;
|
||||
prompt_tokens: number | null;
|
||||
completion_tokens: number | null;
|
||||
total_tokens: number | null;
|
||||
model: string | null;
|
||||
duration_ms: number | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineEventListResponse {
|
||||
items: PipelineEvent[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface WorkerTask {
|
||||
id: string;
|
||||
name: string;
|
||||
args: unknown[];
|
||||
time_start: number | null;
|
||||
}
|
||||
|
||||
export interface WorkerInfo {
|
||||
name: string;
|
||||
active_tasks: WorkerTask[];
|
||||
reserved_tasks: number;
|
||||
total_completed: number;
|
||||
uptime: string | null;
|
||||
pool_size: number | null;
|
||||
}
|
||||
|
||||
export interface WorkerStatusResponse {
|
||||
online: boolean;
|
||||
workers: WorkerInfo[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TriggerResponse {
|
||||
status: string;
|
||||
video_id: string;
|
||||
current_processing_status?: string;
|
||||
}
|
||||
|
||||
export interface RevokeResponse {
|
||||
status: string;
|
||||
video_id: string;
|
||||
tasks_revoked: number;
|
||||
}
|
||||
|
||||
export async function fetchPipelineVideos(): Promise<PipelineVideoListResponse> {
|
||||
return request<PipelineVideoListResponse>(`${BASE}/admin/pipeline/videos`);
|
||||
}
|
||||
|
||||
export async function fetchPipelineEvents(
|
||||
videoId: string,
|
||||
params: { offset?: number; limit?: number; stage?: string; event_type?: string } = {},
|
||||
): Promise<PipelineEventListResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.offset !== undefined) qs.set("offset", String(params.offset));
|
||||
if (params.limit !== undefined) qs.set("limit", String(params.limit));
|
||||
if (params.stage) qs.set("stage", params.stage);
|
||||
if (params.event_type) qs.set("event_type", params.event_type);
|
||||
const query = qs.toString();
|
||||
return request<PipelineEventListResponse>(
|
||||
`${BASE}/admin/pipeline/events/${videoId}${query ? `?${query}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchWorkerStatus(): Promise<WorkerStatusResponse> {
|
||||
return request<WorkerStatusResponse>(`${BASE}/admin/pipeline/worker-status`);
|
||||
}
|
||||
|
||||
export async function triggerPipeline(videoId: string): Promise<TriggerResponse> {
|
||||
return request<TriggerResponse>(`${BASE}/admin/pipeline/trigger/${videoId}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokePipeline(videoId: string): Promise<RevokeResponse> {
|
||||
return request<RevokeResponse>(`${BASE}/admin/pipeline/revoke/${videoId}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
417
frontend/src/pages/AdminPipeline.tsx
Normal file
417
frontend/src/pages/AdminPipeline.tsx
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue