update frontend

This commit is contained in:
2026-06-02 23:38:29 -07:00
parent b2c0c6ccdb
commit d26342efd6
9 changed files with 613 additions and 7 deletions
+206 -3
View File
@@ -1,21 +1,29 @@
import {
Activity,
Check,
Clapperboard,
FileArchive,
FolderSync,
Play,
RefreshCw,
Save,
Send,
Settings2,
Terminal,
Upload,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
apiBaseUrl,
approveClip,
getContainerLog,
getClips,
getLogServices,
getRuns,
getSettings,
getTranscript,
getVideoArtifacts,
getVideoRuns,
getVideos,
patchSettings,
renderClip,
@@ -23,7 +31,16 @@ import {
scanWebDav,
uploadClip,
} from "./api";
import type { ClipSuggestion, PipelineSettings, TranscriptSegment, Video } from "./types";
import type {
Artifact,
ClipSuggestion,
ContainerLog,
LogService,
PipelineRun,
PipelineSettings,
TranscriptSegment,
Video,
} from "./types";
const settingLabels: Record<keyof PipelineSettings, string> = {
suggest_enabled: "Suggest",
@@ -48,6 +65,10 @@ function formatTimeRange(start: number, end: number) {
return `${formatDuration(start)}-${formatDuration(end)}`;
}
function formatDateTime(value: string) {
return new Date(value).toLocaleString();
}
function statusTone(value: string) {
if (["done", "stable", "existing_done", "approved", "auto_approved"].includes(value)) return "good";
if (["failed", "error"].includes(value)) return "bad";
@@ -61,6 +82,13 @@ export function App() {
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
const [transcript, setTranscript] = useState<TranscriptSegment[]>([]);
const [clips, setClips] = useState<ClipSuggestion[]>([]);
const [runs, setRuns] = useState<PipelineRun[]>([]);
const [latestRuns, setLatestRuns] = useState<PipelineRun[]>([]);
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
const [logServices, setLogServices] = useState<LogService[]>([]);
const [selectedLogService, setSelectedLogService] = useState("worker-media");
const [containerLog, setContainerLog] = useState<ContainerLog | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
@@ -71,18 +99,45 @@ export function App() {
);
const loadSelected = useCallback(async (videoId: number) => {
const [nextTranscript, nextClips] = await Promise.all([getTranscript(videoId), getClips(videoId)]);
const [nextTranscript, nextClips, nextRuns, nextArtifacts] = await Promise.all([
getTranscript(videoId),
getClips(videoId),
getVideoRuns(videoId),
getVideoArtifacts(videoId),
]);
setTranscript(nextTranscript);
setClips(nextClips);
setRuns(nextRuns);
setArtifacts(nextArtifacts);
}, []);
const loadLogs = useCallback(async (service = selectedLogService) => {
setLogsLoading(true);
setError(null);
try {
const nextLog = await getContainerLog(service, 300);
setContainerLog(nextLog);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Unknown error");
} finally {
setLogsLoading(false);
}
}, [selectedLogService]);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextSettings, nextVideos] = await Promise.all([getSettings(), getVideos()]);
const [nextSettings, nextVideos, nextLogServices, nextLatestRuns] = await Promise.all([
getSettings(),
getVideos(),
getLogServices(),
getRuns(20),
]);
setSettings(nextSettings);
setVideos(nextVideos);
setLogServices(nextLogServices);
setLatestRuns(nextLatestRuns);
const targetVideoId = selectedVideoId ?? nextVideos[0]?.id ?? null;
setSelectedVideoId(targetVideoId);
if (targetVideoId !== null) {
@@ -90,6 +145,8 @@ export function App() {
} else {
setTranscript([]);
setClips([]);
setRuns([]);
setArtifacts([]);
}
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Unknown error");
@@ -102,6 +159,13 @@ export function App() {
void refresh();
}, [refresh]);
useEffect(() => {
if (logServices.length === 0) return;
if (!logServices.some((entry) => entry.service === selectedLogService)) {
setSelectedLogService(logServices[0].service);
}
}, [logServices, selectedLogService]);
async function handleSelect(videoId: number) {
setSelectedVideoId(videoId);
setLoading(true);
@@ -152,6 +216,11 @@ export function App() {
await withRefresh(() => patchSettings(settings));
}
function chooseLogService(service: string) {
setSelectedLogService(service);
void loadLogs(service);
}
return (
<main className="app-shell">
<header className="topbar">
@@ -198,6 +267,28 @@ export function App() {
</div>
</section>
<section className="band jobs-band">
<div className="section-title">
<Activity size={18} />
<h2>Recent Jobs</h2>
</div>
<div className="job-strip">
{latestRuns.slice(0, 8).map((run) => (
<button
type="button"
className="job-chip"
key={run.id}
onClick={() => void handleSelect(run.video_id)}
>
<span>#{run.id}</span>
<strong>{run.stage}</strong>
<StatusPill value={run.status} />
</button>
))}
{latestRuns.length === 0 && <span className="muted">No jobs recorded yet.</span>}
</div>
</section>
<section className="workbench">
<aside className="video-list">
<div className="list-header">
@@ -260,9 +351,87 @@ export function App() {
<div className="metric-row">
<Metric label="Transcript" value={transcript.length.toString()} />
<Metric label="Clips" value={clips.length.toString()} />
<Metric label="Runs" value={runs.length.toString()} />
<Metric label="Artifacts" value={artifacts.length.toString()} />
</div>
</div>
<div className="ops-grid">
<section className="ops-panel">
<div className="panel-heading">
<div className="section-title">
<Activity size={17} />
<h3>Runs</h3>
</div>
<button
type="button"
className="icon-button"
onClick={() => selectedVideoId !== null && void loadSelected(selectedVideoId)}
disabled={loading || selectedVideoId === null}
>
<RefreshCw size={15} />
Refresh
</button>
</div>
<div className="compact-table">
<table>
<thead>
<tr>
<th>ID</th>
<th>Stage</th>
<th>Status</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id}>
<td>{run.id}</td>
<td>
<div>{run.stage}</div>
<div className="muted">{run.trigger}</div>
</td>
<td>
<StatusPill value={run.status} />
{run.error && <div className="run-error">{run.error}</div>}
</td>
<td>{formatDateTime(run.updated_at)}</td>
</tr>
))}
{runs.length === 0 && (
<tr>
<td colSpan={4} className="empty-cell">
No runs recorded for this video.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<section className="ops-panel">
<div className="panel-heading">
<div className="section-title">
<FileArchive size={17} />
<h3>Artifacts</h3>
</div>
</div>
<div className="artifact-list">
{artifacts.slice(0, 12).map((artifact) => (
<div className="artifact-row" key={artifact.id}>
<div>
<strong>{artifact.artifact_type}</strong>
<p>{artifact.local_path}</p>
</div>
<span>{formatDateTime(artifact.created_at)}</span>
</div>
))}
{artifacts.length === 0 && <div className="empty-block">No artifacts yet.</div>}
</div>
</section>
</div>
<div className="split">
<div className="transcript-pane">
<h3>Transcript</h3>
@@ -313,6 +482,40 @@ export function App() {
</div>
</div>
</div>
<section className="logs-panel">
<div className="panel-heading">
<div className="section-title">
<Terminal size={17} />
<h3>Container Logs</h3>
</div>
<button
type="button"
className="icon-button"
onClick={() => void loadLogs()}
disabled={logsLoading}
>
<RefreshCw size={15} />
Refresh
</button>
</div>
<div className="log-service-row">
{logServices.map((entry) => (
<button
type="button"
className={`service-tab ${entry.service === selectedLogService ? "active" : ""}`}
key={entry.service}
onClick={() => chooseLogService(entry.service)}
>
{entry.service}
</button>
))}
</div>
<div className="muted">
{containerLog ? `${containerLog.container}, latest 300 lines` : "Pick a service to load logs."}
</div>
<pre className="log-output">{containerLog?.logs || ""}</pre>
</section>
</section>
</section>
</main>
+23
View File
@@ -1,6 +1,9 @@
import type {
Artifact,
ClipSuggestion,
ContainerLog,
LogService,
PipelineRun,
PipelineSettings,
TranscriptSegment,
Video,
@@ -60,6 +63,18 @@ export function getClips(videoId: number): Promise<ClipSuggestion[]> {
return request<ClipSuggestion[]>(`/videos/${videoId}/clips`);
}
export function getVideoRuns(videoId: number): Promise<PipelineRun[]> {
return request<PipelineRun[]>(`/videos/${videoId}/runs`);
}
export function getRuns(limit = 50): Promise<PipelineRun[]> {
return request<PipelineRun[]>(`/runs?limit=${limit}`);
}
export function getVideoArtifacts(videoId: number): Promise<Artifact[]> {
return request<Artifact[]>(`/videos/${videoId}/artifacts`);
}
export function getClipArtifacts(clipId: number): Promise<Artifact[]> {
return request<Artifact[]>(`/clips/${clipId}/artifacts`);
}
@@ -75,3 +90,11 @@ export function renderClip(clipId: number): Promise<ClipSuggestion> {
export function uploadClip(clipId: number): Promise<ClipSuggestion> {
return request<ClipSuggestion>(`/clips/${clipId}/upload`, { method: "POST" });
}
export function getLogServices(): Promise<LogService[]> {
return request<LogService[]>("/system/logs");
}
export function getContainerLog(service: string, tail = 200): Promise<ContainerLog> {
return request<ContainerLog>(`/system/logs/${service}?tail=${tail}`);
}
+183 -2
View File
@@ -152,7 +152,8 @@ button {
.section-title h2,
.detail-header h2,
.transcript-pane h3,
.clips-pane h3 {
.clips-pane h3,
.section-title h3 {
margin: 0;
font-size: 16px;
font-weight: 690;
@@ -183,6 +184,50 @@ button {
accent-color: #21756b;
}
.jobs-band {
display: grid;
gap: 12px;
}
.job-strip {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.job-chip {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 34px;
max-width: 280px;
border: 1px solid #ccd3db;
border-radius: 6px;
background: #ffffff;
color: #2f3b46;
padding: 0 9px;
cursor: pointer;
}
.job-chip:hover {
border-color: #21756b;
background: #e9f3f1;
}
.job-chip span {
color: #66737f;
font-size: 12px;
}
.job-chip strong {
overflow: hidden;
font-size: 13px;
font-weight: 680;
text-overflow: ellipsis;
white-space: nowrap;
}
.workbench {
display: grid;
grid-template-columns: minmax(340px, 0.36fr) minmax(0, 1fr);
@@ -267,6 +312,7 @@ tr.selected td {
.metric-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.metric {
@@ -290,6 +336,100 @@ tr.selected td {
font-weight: 720;
}
.ops-grid {
display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(280px, 0.8fr);
gap: 18px;
margin-bottom: 18px;
}
.ops-panel,
.logs-panel {
min-width: 0;
border: 1px solid #d8dee6;
border-radius: 8px;
background: #ffffff;
padding: 12px;
}
.logs-panel {
margin-top: 18px;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.compact-table {
overflow: auto;
}
.compact-table table {
min-width: 520px;
}
.run-error {
max-width: 420px;
margin-top: 6px;
color: #a02717;
font-size: 12px;
overflow-wrap: anywhere;
}
.empty-cell,
.empty-block {
color: #66737f;
font-size: 13px;
}
.empty-block {
border: 1px dashed #ccd3db;
border-radius: 6px;
padding: 14px;
}
.artifact-list {
display: grid;
gap: 8px;
max-height: 252px;
overflow: auto;
}
.artifact-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
border-top: 1px solid #edf0f3;
padding-top: 8px;
}
.artifact-row:first-child {
border-top: 0;
padding-top: 0;
}
.artifact-row strong {
color: #18222b;
font-size: 13px;
}
.artifact-row p {
margin: 3px 0 0;
color: #4f5c68;
font-size: 12px;
overflow-wrap: anywhere;
}
.artifact-row span {
color: #66737f;
font-size: 12px;
white-space: nowrap;
}
.split {
display: grid;
grid-template-columns: minmax(280px, 0.95fr) minmax(320px, 1.05fr);
@@ -412,9 +552,50 @@ tr.selected td {
color: #6f4d00;
}
.log-service-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.service-tab {
min-height: 32px;
border: 1px solid #ccd3db;
border-radius: 6px;
background: #fbfcfd;
color: #2f3b46;
padding: 0 10px;
cursor: pointer;
}
.service-tab.active {
border-color: #21756b;
background: #e9f3f1;
color: #155c54;
}
.log-output {
min-height: 240px;
max-height: 480px;
margin: 10px 0 0;
overflow: auto;
border: 1px solid #263340;
border-radius: 8px;
background: #101820;
color: #d9e7ee;
padding: 12px;
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 1100px) {
.workbench,
.split {
.split,
.ops-grid {
grid-template-columns: 1fr;
}
+22
View File
@@ -48,6 +48,17 @@ export interface PipelineSettings {
bake_subtitles: boolean;
}
export interface PipelineRun {
id: number;
video_id: number;
trigger: string;
stage: string;
status: string;
error: string | null;
created_at: string;
updated_at: string;
}
export interface Artifact {
id: number;
video_id: number | null;
@@ -67,3 +78,14 @@ export interface WebDavScanResult {
baseline_inserted: number;
baseline_marked_existing: number;
}
export interface LogService {
service: string;
container: string;
}
export interface ContainerLog {
service: string;
container: string;
logs: string;
}