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
|
- Estimate: 30min
|
||||||
- Files: backend/routers/pipeline.py, backend/schemas.py, backend/main.py
|
- 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
|
- 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
|
- Estimate: 45min
|
||||||
- Files: frontend/src/pages/AdminPipeline.tsx, frontend/src/api/public-client.ts, frontend/src/App.tsx, frontend/src/App.css
|
- 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)
|
- 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