update frontend
This commit is contained in:
+206
-3
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user