668 lines
23 KiB
TypeScript
668 lines
23 KiB
TypeScript
/**
|
||
* Pipeline admin dashboard — video list with status, retrigger/revoke,
|
||
* expandable event log with token usage and collapsible JSON viewer.
|
||
*/
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useSearchParams } from "react-router-dom";
|
||
import {
|
||
fetchPipelineVideos,
|
||
fetchPipelineEvents,
|
||
fetchWorkerStatus,
|
||
fetchDebugMode,
|
||
setDebugMode,
|
||
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>
|
||
);
|
||
}
|
||
|
||
// ── Debug Payload Viewer ─────────────────────────────────────────────────────
|
||
|
||
interface DebugSection {
|
||
label: string;
|
||
content: string;
|
||
}
|
||
|
||
function DebugPayloadViewer({ event }: { event: PipelineEvent }) {
|
||
const sections: DebugSection[] = [];
|
||
if (event.system_prompt_text) sections.push({ label: "System Prompt", content: event.system_prompt_text });
|
||
if (event.user_prompt_text) sections.push({ label: "User Prompt", content: event.user_prompt_text });
|
||
if (event.response_text) sections.push({ label: "Response", content: event.response_text });
|
||
|
||
const [openSections, setOpenSections] = useState<Record<string, boolean>>({});
|
||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||
|
||
if (sections.length === 0) return null;
|
||
|
||
const toggleSection = (label: string) => {
|
||
setOpenSections((prev) => ({ ...prev, [label]: !prev[label] }));
|
||
};
|
||
|
||
const copyToClipboard = async (label: string, text: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
setCopiedKey(label);
|
||
setTimeout(() => setCopiedKey(null), 1500);
|
||
} catch {
|
||
// Fallback for non-HTTPS contexts
|
||
const ta = document.createElement("textarea");
|
||
ta.value = text;
|
||
ta.style.position = "fixed";
|
||
ta.style.opacity = "0";
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand("copy");
|
||
document.body.removeChild(ta);
|
||
setCopiedKey(label);
|
||
setTimeout(() => setCopiedKey(null), 1500);
|
||
}
|
||
};
|
||
|
||
const exportAsJson = () => {
|
||
const data: Record<string, string | null> = {
|
||
event_id: event.id,
|
||
stage: event.stage,
|
||
event_type: event.event_type,
|
||
model: event.model,
|
||
system_prompt_text: event.system_prompt_text,
|
||
user_prompt_text: event.user_prompt_text,
|
||
response_text: event.response_text,
|
||
};
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `debug-${event.stage}-${event.id.slice(0, 8)}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
return (
|
||
<div className="debug-viewer">
|
||
<div className="debug-viewer__header">
|
||
<span className="debug-viewer__label">LLM Debug</span>
|
||
<button className="debug-viewer__export" onClick={exportAsJson} title="Export debug payload as JSON">
|
||
↓ JSON
|
||
</button>
|
||
</div>
|
||
{sections.map((sec) => {
|
||
const isOpen = !!openSections[sec.label];
|
||
return (
|
||
<div key={sec.label} className="debug-viewer__section">
|
||
<div className="debug-viewer__section-header">
|
||
<button
|
||
className="debug-viewer__section-toggle"
|
||
onClick={() => toggleSection(sec.label)}
|
||
aria-expanded={isOpen}
|
||
>
|
||
{isOpen ? "▾" : "▸"} {sec.label}
|
||
</button>
|
||
<button
|
||
className="debug-viewer__copy"
|
||
onClick={() => void copyToClipboard(sec.label, sec.content)}
|
||
title={`Copy ${sec.label}`}
|
||
>
|
||
{copiedKey === sec.label ? "✓ Copied" : "Copy"}
|
||
</button>
|
||
</div>
|
||
{isOpen && (
|
||
<pre className="debug-viewer__content">{sec.content}</pre>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</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 [viewMode, setViewMode] = useState<"head" | "tail">("tail");
|
||
const limit = 50;
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetchPipelineEvents(videoId, {
|
||
offset,
|
||
limit,
|
||
order: viewMode === "head" ? "asc" : "desc",
|
||
});
|
||
setEvents(res.items);
|
||
setTotal(res.total);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Failed to load events");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [videoId, offset, viewMode]);
|
||
|
||
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>
|
||
<div className="pipeline-events__view-toggle">
|
||
<button
|
||
className={`pipeline-events__view-btn${viewMode === "head" ? " pipeline-events__view-btn--active" : ""}`}
|
||
onClick={() => { setViewMode("head"); setOffset(0); }}
|
||
>
|
||
Oldest first
|
||
</button>
|
||
<button
|
||
className={`pipeline-events__view-btn${viewMode === "tail" ? " pipeline-events__view-btn--active" : ""}`}
|
||
onClick={() => { setViewMode("tail"); setOffset(0); }}
|
||
>
|
||
Newest first
|
||
</button>
|
||
</div>
|
||
</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} />
|
||
<DebugPayloadViewer event={evt} />
|
||
</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>
|
||
);
|
||
}
|
||
|
||
// ── Debug Mode Toggle ────────────────────────────────────────────────────────
|
||
|
||
function DebugModeToggle({
|
||
debugMode,
|
||
onDebugModeChange,
|
||
}: {
|
||
debugMode: boolean | null;
|
||
onDebugModeChange: (mode: boolean) => void;
|
||
}) {
|
||
const [debugLoading, setDebugLoading] = useState(false);
|
||
|
||
async function handleToggle() {
|
||
if (debugMode === null || debugLoading) return;
|
||
setDebugLoading(true);
|
||
try {
|
||
const res = await setDebugMode(!debugMode);
|
||
onDebugModeChange(res.debug_mode);
|
||
} catch {
|
||
// swallow — leave previous state
|
||
} finally {
|
||
setDebugLoading(false);
|
||
}
|
||
}
|
||
|
||
if (debugMode === null) return null;
|
||
|
||
return (
|
||
<div className="debug-toggle" title="When enabled, pipeline runs capture full LLM prompts and responses for inspection in the event log">
|
||
<span className="debug-toggle__label">
|
||
Debug {debugMode ? "On" : "Off"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className={`debug-toggle__switch ${debugMode ? "debug-toggle__switch--active" : ""}`}
|
||
onClick={handleToggle}
|
||
disabled={debugLoading}
|
||
aria-label={`Turn debug mode ${debugMode ? "off" : "on"}`}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Status Filter ────────────────────────────────────────────────────────────
|
||
|
||
function StatusFilter({
|
||
videos,
|
||
activeFilter,
|
||
onFilterChange,
|
||
}: {
|
||
videos: PipelineVideoItem[];
|
||
activeFilter: string | null;
|
||
onFilterChange: (filter: string | null) => void;
|
||
}) {
|
||
const statuses = Array.from(new Set(videos.map((v) => v.processing_status))).sort();
|
||
|
||
if (statuses.length <= 1) return null;
|
||
|
||
return (
|
||
<div className="filter-tabs">
|
||
<button
|
||
type="button"
|
||
className={`filter-tab ${activeFilter === null ? "filter-tab--active" : ""}`}
|
||
onClick={() => onFilterChange(null)}
|
||
>
|
||
All
|
||
</button>
|
||
{statuses.map((status) => (
|
||
<button
|
||
key={status}
|
||
type="button"
|
||
className={`filter-tab ${activeFilter === status ? "filter-tab--active" : ""}`}
|
||
onClick={() => onFilterChange(status)}
|
||
>
|
||
{status} ({videos.filter((v) => v.processing_status === status).length})
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Main Page ────────────────────────────────────────────────────────────────
|
||
|
||
export default function AdminPipeline() {
|
||
const [searchParams] = useSearchParams();
|
||
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 [activeFilter, setActiveFilter] = useState<string | null>(null);
|
||
const [debugMode, setDebugModeState] = useState<boolean | null>(null);
|
||
const videoRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||
const deepLinked = useRef(false);
|
||
|
||
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]);
|
||
|
||
// Deep-link: auto-expand and scroll to ?video=<id> on first load
|
||
useEffect(() => {
|
||
if (deepLinked.current || loading || videos.length === 0) return;
|
||
const targetVideoId = searchParams.get("video");
|
||
if (!targetVideoId) return;
|
||
const match = videos.find((v) => v.id === targetVideoId);
|
||
if (!match) return;
|
||
deepLinked.current = true;
|
||
setExpandedId(targetVideoId);
|
||
// Scroll after the expanded detail renders
|
||
requestAnimationFrame(() => {
|
||
const el = videoRefs.current.get(targetVideoId);
|
||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
}, [loading, videos, searchParams]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
fetchDebugMode()
|
||
.then((res) => {
|
||
if (!cancelled) setDebugModeState(res.debug_mode);
|
||
})
|
||
.catch(() => {
|
||
// silently fail — toggle stays hidden
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
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">
|
||
<DebugModeToggle debugMode={debugMode} onDebugModeChange={setDebugModeState} />
|
||
<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>
|
||
) : (
|
||
<>
|
||
<StatusFilter
|
||
videos={videos}
|
||
activeFilter={activeFilter}
|
||
onFilterChange={setActiveFilter}
|
||
/>
|
||
<div className="admin-pipeline__list">
|
||
{videos
|
||
.filter((v) => activeFilter === null || v.processing_status === activeFilter)
|
||
.map((video) => (
|
||
<div key={video.id} className="pipeline-video" ref={(el) => { if (el) videoRefs.current.set(video.id, el); }}>
|
||
<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>
|
||
<a
|
||
className="pipeline-video__review-link"
|
||
href="/admin/review"
|
||
title={`Review moments from ${video.creator_name}`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
→ Moments
|
||
</a>
|
||
</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 ? "…" : debugMode ? "▶ Trigger (debug)" : "▶ 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>Created: {formatDate(video.created_at)}</span>
|
||
<span>Updated: {formatDate(video.updated_at)}</span>
|
||
</div>
|
||
<EventLog videoId={video.id} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|