feat: Added DebugModeToggle component and StatusFilter pill bar to Admi…

- "frontend/src/pages/AdminPipeline.tsx"
- "frontend/src/api/public-client.ts"
- "frontend/src/App.css"

GSD-Task: S04/T01
This commit is contained in:
jlightner 2026-03-30 19:34:11 +00:00
parent dcadcbb5e2
commit 4186c6e208
3 changed files with 170 additions and 2 deletions

View file

@ -541,6 +541,57 @@ a.app-footer__repo:hover {
cursor: not-allowed;
}
/* ── Debug Mode Toggle ────────────────────────────────────────────────────── */
.debug-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.debug-toggle__label {
color: var(--color-text-on-header-label);
white-space: nowrap;
}
.debug-toggle__switch {
position: relative;
width: 2.5rem;
height: 1.25rem;
background: var(--color-toggle-track);
border: none;
border-radius: 9999px;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.debug-toggle__switch--active {
background: var(--color-toggle-review);
}
.debug-toggle__switch::after {
content: "";
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1rem;
height: 1rem;
background: var(--color-toggle-thumb);
border-radius: 50%;
transition: transform 0.2s;
}
.debug-toggle__switch--active::after {
transform: translateX(1.25rem);
}
.debug-toggle__switch:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Pagination ───────────────────────────────────────────────────────────── */
.pagination {

View file

@ -471,3 +471,20 @@ export async function revokePipeline(videoId: string): Promise<RevokeResponse> {
method: "POST",
});
}
// ── Debug Mode ──────────────────────────────────────────────────────────────
export interface DebugModeResponse {
debug_mode: boolean;
}
export async function fetchDebugMode(): Promise<DebugModeResponse> {
return request<DebugModeResponse>(`${BASE}/admin/pipeline/debug-mode`);
}
export async function setDebugMode(enabled: boolean): Promise<DebugModeResponse> {
return request<DebugModeResponse>(`${BASE}/admin/pipeline/debug-mode`, {
method: "PUT",
body: JSON.stringify({ debug_mode: enabled }),
});
}

View file

@ -8,6 +8,8 @@ import {
fetchPipelineVideos,
fetchPipelineEvents,
fetchWorkerStatus,
fetchDebugMode,
setDebugMode,
triggerPipeline,
revokePipeline,
type PipelineVideoItem,
@ -364,6 +366,93 @@ function WorkerStatus() {
);
}
// ── Debug Mode Toggle ────────────────────────────────────────────────────────
function DebugModeToggle() {
const [debugMode, setDebugModeState] = useState<boolean | null>(null);
const [debugLoading, setDebugLoading] = useState(false);
useEffect(() => {
let cancelled = false;
fetchDebugMode()
.then((res) => {
if (!cancelled) setDebugModeState(res.debug_mode);
})
.catch(() => {
// silently fail — toggle stays hidden
});
return () => { cancelled = true; };
}, []);
async function handleToggle() {
if (debugMode === null || debugLoading) return;
setDebugLoading(true);
try {
const res = await setDebugMode(!debugMode);
setDebugModeState(res.debug_mode);
} catch {
// swallow — leave previous state
} finally {
setDebugLoading(false);
}
}
if (debugMode === null) return null;
return (
<div className="debug-toggle">
<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() {
@ -373,6 +462,7 @@ export default function AdminPipeline() {
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 load = useCallback(async () => {
setLoading(true);
@ -448,6 +538,7 @@ export default function AdminPipeline() {
</p>
</div>
<div className="admin-pipeline__header-right">
<DebugModeToggle />
<WorkerStatus />
<button className="btn btn--secondary" onClick={() => void load()} disabled={loading}>
Refresh
@ -462,8 +553,16 @@ export default function AdminPipeline() {
) : videos.length === 0 ? (
<div className="empty-state">No videos in pipeline.</div>
) : (
<div className="admin-pipeline__list">
{videos.map((video) => (
<>
<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">
<div
className="pipeline-video__header"
@ -530,6 +629,7 @@ export default function AdminPipeline() {
</div>
))}
</div>
</>
)}
</div>
);