From 961cc6f36e5efb1ce287f7021262607a06a52eb3 Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 21 Jun 2026 23:45:28 -0400 Subject: [PATCH] add button to clear stale 'active' calls --- drb-c2-core/app/routers/calls.py | 42 +++++++++++++++ drb-frontend/app/admin/page.tsx | 89 ++++++++++++++++++++++++++++++-- drb-frontend/lib/c2api.ts | 2 + 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/drb-c2-core/app/routers/calls.py b/drb-c2-core/app/routers/calls.py index 1fb3c74..76527d6 100644 --- a/drb-c2-core/app/routers/calls.py +++ b/drb-c2-core/app/routers/calls.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone, timedelta from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends from pydantic import BaseModel from typing import Optional @@ -59,6 +60,47 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks): return {"ok": True, "call_id": call_id} +@router.post("/close-stale") +async def close_stale_calls( + older_than_minutes: int = Query(30, ge=1, le=1440, description="Close active calls started more than this many minutes ago."), + dry_run: bool = Query(False, description="If true, return what would be closed without writing."), + _: dict = Depends(require_admin_token), +): + """ + Find and close calls stuck in 'active' status — e.g. because a node rebooted + before sending an end-call event. Returns the list of affected call IDs. + """ + cutoff = datetime.now(timezone.utc) - timedelta(minutes=older_than_minutes) + active_calls = await fstore.collection_list("calls", status="active") + + stale = [] + for call in active_calls: + started_raw = call.get("started_at") + if not started_raw: + continue + try: + started = datetime.fromisoformat(started_raw.replace("Z", "+00:00")) + except Exception: + continue + if started < cutoff: + stale.append(call) + + if not dry_run: + now_iso = datetime.now(timezone.utc).isoformat() + for call in stale: + await fstore.doc_set("calls", call["call_id"], { + "status": "ended", + "ended_at": now_iso, + }) + + return { + "dry_run": dry_run, + "older_than_minutes": older_than_minutes, + "count": len(stale), + "call_ids": [c["call_id"] for c in stale], + } + + @router.patch("/{call_id}/transcript") async def patch_transcript( call_id: str, diff --git a/drb-frontend/app/admin/page.tsx b/drb-frontend/app/admin/page.tsx index 9d10e1c..0c9c8c9 100644 --- a/drb-frontend/app/admin/page.tsx +++ b/drb-frontend/app/admin/page.tsx @@ -245,10 +245,92 @@ function CorrelationDebugTab() { ); } +function StaleCallsTab() { + const [minutes, setMinutes] = useState(30); + const [result, setResult] = useState<{ dry_run: boolean; count: number; call_ids: string[] } | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function run(dryRun: boolean) { + setLoading(true); + setError(null); + setResult(null); + try { + const res = await c2api.closeStallCalls(minutes, dryRun); + setResult(res); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + } + + return ( +
+

+ Finds calls stuck in active status because a node rebooted before sending an end-call event. + Preview first, then close. +

+ +
+
+ + setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))} + className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" + /> +
+ + +
+ + {error && ( +
+

{error}

+
+ )} + + {result && ( +
+

+ {result.dry_run ? "Preview: " : "Closed: "} + 0 ? "text-amber-400" : "text-green-400"}> + {result.count} stale call{result.count !== 1 ? "s" : ""} + + {result.count === 0 && — nothing to clear} +

+ {result.call_ids.length > 0 && ( +
+ {result.call_ids.map((id) => ( +

{id}

+ ))} +
+ )} +
+ )} +
+ ); +} + export default function AdminPage() { const { isAdmin } = useAuth(); const router = useRouter(); - const [tab, setTab] = useState<"features" | "correlation">("features"); + const [tab, setTab] = useState<"features" | "correlation" | "calls">("features"); const [flags, setFlags] = useState(null); const [loading, setLoading] = useState(true); @@ -287,7 +369,7 @@ export default function AdminPage() {

Admin

- {(["features", "correlation"] as const).map((t) => ( + {(["features", "correlation", "calls"] as const).map((t) => ( ))}
@@ -330,6 +412,7 @@ export default function AdminPage() { )} {tab === "correlation" && } + {tab === "calls" && } ); } diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index 8ae4312..fa759e6 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -63,6 +63,8 @@ export const c2api = { }, patchTranscript: (callId: string, transcript: string) => request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }), + closeStallCalls: (olderThanMinutes: number, dryRun: boolean) => + request<{ dry_run: boolean; older_than_minutes: number; count: number; call_ids: string[] }>(`/calls/close-stale?older_than_minutes=${olderThanMinutes}&dry_run=${dryRun}`, { method: "POST" }), // Incidents getIncidents: (params?: { status?: string; type?: string }) => {