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
+6
View File
@@ -332,12 +332,18 @@ docker compose run --rm api
Useful API endpoints for generated files:
- `POST /webdav/scan`
- `GET /runs`
- `GET /videos/{video_id}/runs`
- `GET /videos/{video_id}/artifacts`
- `GET /clips/{clip_id}/artifacts`
- `GET /artifacts/{artifact_id}`
- `GET /system/logs`
- `GET /system/logs/{service}?tail=300`
Thumbnail outputs are visible through the artifact endpoints. Upload command payloads include `thumbnail` when a `thumbnail_final` artifact exists.
The frontend operations view uses the run, artifact, and log endpoints to show per-video pipeline history and recent Compose container logs. The API service mounts `/var/run/docker.sock` read-only so it can read logs for `api`, `frontend`, `scheduler`, `worker-ai`, `worker-media`, and `redis`; keep that endpoint LAN-only or behind trusted access.
## Custom LLM Prompt
Edit `[llm_prompt]` in your private `config.toml` to tune clip selection without rebuilding containers:
+1
View File
@@ -14,6 +14,7 @@ services:
volumes:
- ./config.toml:/etc/evanescere/config.toml:ro
- ./storage:/data/evanescere
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
- redis
+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;
}
+63 -2
View File
@@ -1,9 +1,8 @@
import logging
import time
from fastapi import Depends, FastAPI, HTTPException
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -16,6 +15,8 @@ from evanescere.models import Artifact, ClipSuggestion, PipelineRun, TranscriptS
from evanescere.schemas import (
ArtifactRead,
ClipSuggestionRead,
ContainerLogRead,
ContainerLogServiceRead,
RunRead,
SettingsPatch,
SettingsRead,
@@ -23,6 +24,11 @@ from evanescere.schemas import (
VideoRead,
WebDavScanRead,
)
from evanescere.services.docker_logs import (
DockerLogsError,
list_log_services,
read_container_logs,
)
from evanescere.services.webdav import WebDavClient, safe_scan_once
from evanescere.settings_store import get_pipeline_settings, patch_pipeline_settings
@@ -111,6 +117,22 @@ def video_clips(video_id: int, db: Session = Depends(get_db)) -> list[ClipSugges
)
@app.get("/videos/{video_id}/runs", response_model=list[RunRead])
def video_runs(
video_id: int,
limit: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
) -> list[PipelineRun]:
return list(
db.scalars(
select(PipelineRun)
.where(PipelineRun.video_id == video_id)
.order_by(PipelineRun.updated_at.desc())
.limit(limit)
).all()
)
@app.get("/videos/{video_id}/artifacts", response_model=list[ArtifactRead])
def video_artifacts(video_id: int, db: Session = Depends(get_db)) -> list[Artifact]:
return list(
@@ -162,6 +184,23 @@ def upload_clip(clip_id: int, db: Session = Depends(get_db)) -> ClipSuggestion:
return clip
@app.get("/runs", response_model=list[RunRead])
def list_runs(
video_id: int | None = None,
limit: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
) -> list[PipelineRun]:
query = select(PipelineRun).order_by(PipelineRun.updated_at.desc()).limit(limit)
if video_id is not None:
query = (
select(PipelineRun)
.where(PipelineRun.video_id == video_id)
.order_by(PipelineRun.updated_at.desc())
.limit(limit)
)
return list(db.scalars(query).all())
@app.get("/runs/{run_id}", response_model=RunRead)
def get_run(run_id: int, db: Session = Depends(get_db)) -> PipelineRun:
run = db.get(PipelineRun, run_id)
@@ -178,6 +217,28 @@ def get_artifact(artifact_id: int, db: Session = Depends(get_db)) -> Artifact:
return artifact
@app.get("/system/logs", response_model=list[ContainerLogServiceRead])
def system_log_services() -> list[ContainerLogServiceRead]:
return [
ContainerLogServiceRead(service=entry.service, container=entry.container)
for entry in list_log_services()
]
@app.get("/system/logs/{service}", response_model=ContainerLogRead)
def system_logs(
service: str,
tail: int = Query(default=200, ge=1, le=2000),
) -> ContainerLogRead:
try:
result = read_container_logs(service, tail=tail)
except KeyError as exc:
raise HTTPException(status_code=404, detail=f"Unknown log service: {service}") from exc
except DockerLogsError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return ContainerLogRead(service=result.service, container=result.container, logs=result.logs)
@app.get("/settings", response_model=SettingsRead)
def read_settings(db: Session = Depends(get_db)) -> SettingsRead:
return get_pipeline_settings(db)
+11
View File
@@ -103,6 +103,17 @@ class WebDavScanRead(BaseModel):
baseline_marked_existing: int = 0
class ContainerLogServiceRead(BaseModel):
service: str
container: str
class ContainerLogRead(BaseModel):
service: str
container: str
logs: str
class ClipCandidate(BaseModel):
start_sec: float = Field(ge=0)
end_sec: float = Field(gt=0)
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import os
from dataclasses import dataclass
import httpx
DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock"
SERVICE_CONTAINERS: dict[str, str] = {
"api": "evanescere-api-1",
"frontend": "evanescere-frontend-1",
"scheduler": "evanescere-scheduler-1",
"worker-ai": "evanescere-worker-ai-1",
"worker-media": "evanescere-worker-media-1",
"redis": "evanescere-redis-1",
}
class DockerLogsError(RuntimeError):
pass
@dataclass(frozen=True)
class LogService:
service: str
container: str
@dataclass(frozen=True)
class ContainerLogs:
service: str
container: str
logs: str
def list_log_services() -> list[LogService]:
return [
LogService(service=service, container=container)
for service, container in SERVICE_CONTAINERS.items()
]
def read_container_logs(
service: str,
*,
tail: int = 200,
socket_path: str = DEFAULT_DOCKER_SOCKET,
) -> ContainerLogs:
container = SERVICE_CONTAINERS.get(service)
if container is None:
raise KeyError(service)
if not os.path.exists(socket_path):
raise DockerLogsError(
f"Docker socket {socket_path} is not mounted into the API container."
)
transport = httpx.HTTPTransport(uds=socket_path)
params = {
"stdout": "1",
"stderr": "1",
"timestamps": "1",
"tail": str(tail),
}
with httpx.Client(transport=transport, base_url="http://docker", timeout=10.0) as client:
response = client.get(f"/containers/{container}/logs", params=params)
if response.status_code == 404:
raise DockerLogsError(f"Container {container} was not found.")
if response.status_code >= 400:
raise DockerLogsError(
f"Docker logs request failed with HTTP {response.status_code}: {response.text}"
)
return ContainerLogs(
service=service,
container=container,
logs=_decode_docker_logs(response.content),
)
def _decode_docker_logs(content: bytes) -> str:
frames: list[bytes] = []
index = 0
while index + 8 <= len(content):
frame_size = int.from_bytes(content[index + 4 : index + 8], "big")
frame_start = index + 8
frame_end = frame_start + frame_size
if frame_size <= 0 or frame_end > len(content):
break
frames.append(content[frame_start:frame_end])
index = frame_end
if frames and index == len(content):
return b"".join(frames).decode("utf-8", errors="replace")
return content.decode("utf-8", errors="replace")