add button to clear stale 'active' calls
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user