add button to clear stale 'active' calls

This commit is contained in:
Logan
2026-06-21 23:45:28 -04:00
parent d290b89736
commit 961cc6f36e
3 changed files with 130 additions and 3 deletions
+42
View File
@@ -1,3 +1,4 @@
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional 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} 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") @router.patch("/{call_id}/transcript")
async def patch_transcript( async def patch_transcript(
call_id: str, call_id: str,
+86 -3
View File
@@ -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<string | null>(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 (
<div className="space-y-5">
<p className="text-xs text-gray-500 font-mono">
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
Preview first, then close.
</p>
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label>
<input
type="number"
min={1} max={1440}
value={minutes}
onChange={(e) => 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"
/>
</div>
<button
onClick={() => run(true)}
disabled={loading}
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
{loading ? "Working…" : "Preview"}
</button>
<button
onClick={() => run(false)}
disabled={loading || result === null}
className="bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
Close {result && !result.dry_run ? "Done" : result?.count ? `${result.count} calls` : "calls"}
</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{result && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
<p className="text-sm font-mono text-white">
{result.dry_run ? "Preview: " : "Closed: "}
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
{result.count} stale call{result.count !== 1 ? "s" : ""}
</span>
{result.count === 0 && <span className="text-gray-500"> nothing to clear</span>}
</p>
{result.call_ids.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-0.5">
{result.call_ids.map((id) => (
<p key={id} className="text-xs font-mono text-gray-400">{id}</p>
))}
</div>
)}
</div>
)}
</div>
);
}
export default function AdminPage() { export default function AdminPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<"features" | "correlation">("features"); const [tab, setTab] = useState<"features" | "correlation" | "calls">("features");
const [flags, setFlags] = useState<FeatureFlags | null>(null); const [flags, setFlags] = useState<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -287,7 +369,7 @@ export default function AdminPage() {
<h1 className="text-white text-xl font-bold font-mono">Admin</h1> <h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit"> <div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{(["features", "correlation"] as const).map((t) => ( {(["features", "correlation", "calls"] as const).map((t) => (
<button <button
key={t} key={t}
onClick={() => setTab(t)} onClick={() => setTab(t)}
@@ -295,7 +377,7 @@ export default function AdminPage() {
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300" tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`} }`}
> >
{t === "features" ? "AI Features" : "Correlation Debug"} {t === "features" ? "AI Features" : t === "correlation" ? "Correlation Debug" : "Calls"}
</button> </button>
))} ))}
</div> </div>
@@ -330,6 +412,7 @@ export default function AdminPage() {
)} )}
{tab === "correlation" && <CorrelationDebugTab />} {tab === "correlation" && <CorrelationDebugTab />}
{tab === "calls" && <StaleCallsTab />}
</div> </div>
); );
} }
+2
View File
@@ -63,6 +63,8 @@ export const c2api = {
}, },
patchTranscript: (callId: string, transcript: string) => patchTranscript: (callId: string, transcript: string) =>
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }), 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 // Incidents
getIncidents: (params?: { status?: string; type?: string }) => { getIncidents: (params?: { status?: string; type?: string }) => {