Big updates
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.14-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ async def correlate_call(
|
|||||||
await _update_incident(
|
await _update_incident(
|
||||||
matched_incident, call_id, talkgroup_id, system_id, tags,
|
matched_incident, call_id, talkgroup_id, system_id, tags,
|
||||||
location, location_coords, call_units, call_vehicles, call_embedding, now,
|
location, location_coords, call_units, call_vehicles, call_embedding, now,
|
||||||
|
talkgroup_name=talkgroup_name, incident_type=incident_type,
|
||||||
)
|
)
|
||||||
elif incident_type:
|
elif incident_type:
|
||||||
incident_id = await _create_incident(
|
incident_id = await _create_incident(
|
||||||
@@ -250,6 +251,8 @@ async def _update_incident(
|
|||||||
call_vehicles: list[str],
|
call_vehicles: list[str],
|
||||||
call_embedding: Optional[list],
|
call_embedding: Optional[list],
|
||||||
now: datetime,
|
now: datetime,
|
||||||
|
talkgroup_name: Optional[str] = None,
|
||||||
|
incident_type: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
incident_id = inc["incident_id"]
|
incident_id = inc["incident_id"]
|
||||||
|
|
||||||
@@ -295,6 +298,22 @@ async def _update_incident(
|
|||||||
if best_coords:
|
if best_coords:
|
||||||
updates["location_coords"] = best_coords
|
updates["location_coords"] = best_coords
|
||||||
|
|
||||||
|
# Re-evaluate title when a substantive call (classified incident_type) brings new tags.
|
||||||
|
# Routine status calls (type=None) do not clobber the title.
|
||||||
|
if incident_type:
|
||||||
|
content_tags = [t for t in tags if t != "auto-generated"]
|
||||||
|
primary_tag = content_tags[0].replace("-", " ").title() if content_tags else None
|
||||||
|
tg_label = (
|
||||||
|
talkgroup_name
|
||||||
|
or (f"TGID {talkgroup_id}" if talkgroup_id else inc.get("title", "").split(" — ")[-1])
|
||||||
|
)
|
||||||
|
if primary_tag and best_location:
|
||||||
|
updates["title"] = f"{primary_tag} at {best_location}"
|
||||||
|
elif primary_tag and tg_label:
|
||||||
|
updates["title"] = f"{primary_tag} — {tg_label}"
|
||||||
|
elif primary_tag:
|
||||||
|
updates["title"] = primary_tag
|
||||||
|
|
||||||
await fstore.doc_set("incidents", incident_id, updates)
|
await fstore.doc_set("incidents", incident_id, updates)
|
||||||
logger.info(f"Correlator: linked call {call_id} to incident {incident_id}")
|
logger.info(f"Correlator: linked call {call_id} to incident {incident_id}")
|
||||||
|
|
||||||
@@ -315,14 +334,24 @@ async def _create_incident(
|
|||||||
now: datetime,
|
now: datetime,
|
||||||
) -> str:
|
) -> str:
|
||||||
incident_id = str(uuid.uuid4())
|
incident_id = str(uuid.uuid4())
|
||||||
tg_label = (
|
tg_label = (
|
||||||
talkgroup_name
|
talkgroup_name
|
||||||
or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup")
|
or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Build a descriptive title from tags + location when available
|
||||||
|
content_tags = [t for t in tags if t != "auto-generated"]
|
||||||
|
primary_tag = content_tags[0].replace("-", " ").title() if content_tags else None
|
||||||
|
if primary_tag and location:
|
||||||
|
title = f"{primary_tag} at {location}"
|
||||||
|
elif primary_tag:
|
||||||
|
title = f"{primary_tag} — {tg_label}"
|
||||||
|
else:
|
||||||
|
title = f"{incident_type.title()} — {tg_label}"
|
||||||
|
|
||||||
doc = {
|
doc = {
|
||||||
"incident_id": incident_id,
|
"incident_id": incident_id,
|
||||||
"title": f"{incident_type.title()} — {tg_label}",
|
"title": title,
|
||||||
"type": incident_type,
|
"type": incident_type,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"location": location,
|
"location": location,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ async def extract_tags(
|
|||||||
system_id: Optional[str] = None,
|
system_id: Optional[str] = None,
|
||||||
segments: Optional[list[dict]] = None,
|
segments: Optional[list[dict]] = None,
|
||||||
node_id: Optional[str] = None,
|
node_id: Optional[str] = None,
|
||||||
|
preserve_transcript_correction: bool = False,
|
||||||
) -> tuple[list[str], Optional[str], Optional[str], Optional[dict], bool]:
|
) -> tuple[list[str], Optional[str], Optional[str], Optional[dict], bool]:
|
||||||
"""
|
"""
|
||||||
Extract incident tags, type, location, corrected transcript, and closure signal via GPT-4o mini.
|
Extract incident tags, type, location, corrected transcript, and closure signal via GPT-4o mini.
|
||||||
@@ -119,7 +120,7 @@ async def extract_tags(
|
|||||||
updates["units"] = units
|
updates["units"] = units
|
||||||
if embedding:
|
if embedding:
|
||||||
updates["embedding"] = embedding
|
updates["embedding"] = embedding
|
||||||
if transcript_corrected:
|
if transcript_corrected and not preserve_transcript_correction:
|
||||||
updates["transcript_corrected"] = transcript_corrected
|
updates["transcript_corrected"] = transcript_corrected
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -154,12 +154,23 @@ class MQTTHandler:
|
|||||||
else datetime.now(timezone.utc)
|
else datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Prefer the name from OP25 metadata; fall back to the system config
|
||||||
|
tgid_name = payload.get("tgid_name") or ""
|
||||||
|
if not tgid_name and system_id and payload.get("tgid"):
|
||||||
|
system_doc = await fstore.doc_get("systems", system_id)
|
||||||
|
if system_doc:
|
||||||
|
tgid_int = int(payload["tgid"])
|
||||||
|
for tg in system_doc.get("config", {}).get("talkgroups", []):
|
||||||
|
if int(tg.get("id", -1)) == tgid_int:
|
||||||
|
tgid_name = tg.get("name", "")
|
||||||
|
break
|
||||||
|
|
||||||
doc = {
|
doc = {
|
||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
"system_id": system_id,
|
"system_id": system_id,
|
||||||
"talkgroup_id": payload.get("tgid"),
|
"talkgroup_id": payload.get("tgid"),
|
||||||
"talkgroup_name": payload.get("tgid_name") or "",
|
"talkgroup_name": tgid_name,
|
||||||
"freq": payload.get("freq"),
|
"freq": payload.get("freq"),
|
||||||
"srcaddr": payload.get("srcaddr"),
|
"srcaddr": payload.get("srcaddr"),
|
||||||
"started_at": started_at,
|
"started_at": started_at,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ async def transcribe_call(
|
|||||||
return None, []
|
return None, []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transcript, segments = await asyncio.to_thread(_sync_transcribe, gcs_uri)
|
transcript, segments = await asyncio.to_thread(_sync_transcribe, gcs_uri, talkgroup_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Transcription failed for call {call_id}: {e}")
|
logger.warning(f"Transcription failed for call {call_id}: {e}")
|
||||||
return None, []
|
return None, []
|
||||||
@@ -61,7 +61,7 @@ async def transcribe_call(
|
|||||||
return transcript, segments
|
return transcript, segments
|
||||||
|
|
||||||
|
|
||||||
def _sync_transcribe(gcs_uri: str) -> tuple[Optional[str], list[dict]]:
|
def _sync_transcribe(gcs_uri: str, talkgroup_name: Optional[str] = None) -> tuple[Optional[str], list[dict]]:
|
||||||
"""Download audio from GCS and transcribe with OpenAI Whisper."""
|
"""Download audio from GCS and transcribe with OpenAI Whisper."""
|
||||||
from google.cloud import storage as gcs
|
from google.cloud import storage as gcs
|
||||||
from google.oauth2 import service_account
|
from google.oauth2 import service_account
|
||||||
@@ -94,13 +94,15 @@ def _sync_transcribe(gcs_uri: str) -> tuple[Optional[str], list[dict]]:
|
|||||||
try:
|
try:
|
||||||
blob.download_to_filename(tmp_path)
|
blob.download_to_filename(tmp_path)
|
||||||
|
|
||||||
|
prompt = (f"Talkgroup: {talkgroup_name}. " + _WHISPER_PROMPT) if talkgroup_name else _WHISPER_PROMPT
|
||||||
|
|
||||||
openai_client = OpenAI(api_key=settings.openai_api_key)
|
openai_client = OpenAI(api_key=settings.openai_api_key)
|
||||||
with open(tmp_path, "rb") as f:
|
with open(tmp_path, "rb") as f:
|
||||||
response = openai_client.audio.transcriptions.create(
|
response = openai_client.audio.transcriptions.create(
|
||||||
model="whisper-1",
|
model="whisper-1",
|
||||||
file=f,
|
file=f,
|
||||||
language="en",
|
language="en",
|
||||||
prompt=_WHISPER_PROMPT,
|
prompt=prompt,
|
||||||
response_format="verbose_json",
|
response_format="verbose_json",
|
||||||
)
|
)
|
||||||
text = response.text.strip() or None
|
text = response.text.strip() or None
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ async def patch_transcript(
|
|||||||
if not call:
|
if not call:
|
||||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||||
|
|
||||||
# Save new transcript, clear stale intelligence fields
|
# Save user correction as transcript_corrected; leave original transcript intact.
|
||||||
|
# Clear stale intelligence fields so re-extraction runs fresh.
|
||||||
await fstore.doc_set("calls", call_id, {
|
await fstore.doc_set("calls", call_id, {
|
||||||
"transcript": body.transcript,
|
"transcript_corrected": body.transcript,
|
||||||
"transcript_corrected": None,
|
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"severity": "unknown",
|
"severity": "unknown",
|
||||||
"location": None,
|
"location": None,
|
||||||
@@ -93,5 +93,6 @@ async def patch_transcript(
|
|||||||
talkgroup_name=call.get("talkgroup_name"),
|
talkgroup_name=call.get("talkgroup_name"),
|
||||||
transcript=body.transcript,
|
transcript=body.transcript,
|
||||||
segments=call.get("segments"),
|
segments=call.get("segments"),
|
||||||
|
preserve_transcript_correction=True,
|
||||||
)
|
)
|
||||||
return {"ok": True, "call_id": call_id}
|
return {"ok": True, "call_id": call_id}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ async def _run_extraction_pipeline(
|
|||||||
talkgroup_name: Optional[str],
|
talkgroup_name: Optional[str],
|
||||||
transcript: str,
|
transcript: str,
|
||||||
segments: Optional[list] = None,
|
segments: Optional[list] = None,
|
||||||
|
preserve_transcript_correction: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run steps 2-4 of the intelligence pipeline using an existing transcript."""
|
"""Run steps 2-4 of the intelligence pipeline using an existing transcript."""
|
||||||
from app.internal import intelligence, incident_correlator, alerter
|
from app.internal import intelligence, incident_correlator, alerter
|
||||||
@@ -99,6 +100,7 @@ async def _run_extraction_pipeline(
|
|||||||
call_id, transcript, talkgroup_name,
|
call_id, transcript, talkgroup_name,
|
||||||
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
|
preserve_transcript_correction=preserve_transcript_correction,
|
||||||
)
|
)
|
||||||
|
|
||||||
incident_id = await incident_correlator.correlate_call(
|
incident_id = await incident_correlator.correlate_call(
|
||||||
|
|||||||
@@ -88,16 +88,18 @@ export default function IncidentDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex flex-col gap-1.5">
|
||||||
<TypeBadge type={incident.type} />
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="text-xl font-bold text-white font-mono">
|
<TypeBadge type={incident.type} />
|
||||||
|
<StatusBadge status={incident.status} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug">
|
||||||
{incident.title ?? "Incident"}
|
{incident.title ?? "Incident"}
|
||||||
</h1>
|
</h1>
|
||||||
<StatusBadge status={incident.status} />
|
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-2 shrink-0 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={handleSummarize}
|
onClick={handleSummarize}
|
||||||
disabled={summarizing}
|
disabled={summarizing}
|
||||||
@@ -261,8 +263,8 @@ export default function IncidentDetailPage() {
|
|||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||||
<th className="px-4 py-2 text-left">Time</th>
|
<th className="px-4 py-2 text-left">Time</th>
|
||||||
<th className="px-4 py-2 text-left">Talkgroup</th>
|
<th className="px-4 py-2 text-left">Talkgroup</th>
|
||||||
<th className="px-4 py-2 text-left">System</th>
|
<th className="px-4 py-2 text-left hidden sm:table-cell">System</th>
|
||||||
<th className="px-4 py-2 text-left">Node</th>
|
<th className="px-4 py-2 text-left hidden sm:table-cell">Node</th>
|
||||||
<th className="px-4 py-2 text-left">Duration</th>
|
<th className="px-4 py-2 text-left">Duration</th>
|
||||||
<th className="px-4 py-2 text-left">Audio</th>
|
<th className="px-4 py-2 text-left">Audio</th>
|
||||||
<th className="px-4 py-2"></th>
|
<th className="px-4 py-2"></th>
|
||||||
|
|||||||
@@ -136,37 +136,85 @@ function CreateModal({ onClose, onCreate }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IncidentCards({ incidents, isAdmin, onResolve }: {
|
||||||
|
incidents: IncidentRecord[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
onResolve: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{incidents.map((inc) => (
|
||||||
|
<div
|
||||||
|
key={inc.incident_id}
|
||||||
|
className="bg-gray-900 border border-gray-800 rounded-xl p-4 cursor-pointer active:bg-gray-800"
|
||||||
|
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{typeBadge(inc.type)}
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
inc.status === "active" ? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
|
||||||
|
}`}>{inc.status}</span>
|
||||||
|
</div>
|
||||||
|
{isAdmin && inc.status === "active" && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}
|
||||||
|
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-1 font-mono">
|
||||||
|
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function IncidentTable({ incidents, isAdmin, onResolve }: {
|
function IncidentTable({ incidents, isAdmin, onResolve }: {
|
||||||
incidents: IncidentRecord[];
|
incidents: IncidentRecord[];
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onResolve: (id: string) => void;
|
onResolve: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
<>
|
||||||
<table className="w-full text-left">
|
{/* Mobile card view */}
|
||||||
<thead>
|
<div className="sm:hidden">
|
||||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
|
||||||
<th className="px-4 py-3">Type</th>
|
</div>
|
||||||
<th className="px-4 py-3">Title</th>
|
|
||||||
<th className="px-4 py-3">Status</th>
|
{/* Desktop table view */}
|
||||||
<th className="px-4 py-3">Calls</th>
|
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
<th className="px-4 py-3">Started</th>
|
<table className="w-full text-left">
|
||||||
<th className="px-4 py-3">Updated</th>
|
<thead>
|
||||||
<th className="px-4 py-3"></th>
|
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||||
</tr>
|
<th className="px-4 py-3">Type</th>
|
||||||
</thead>
|
<th className="px-4 py-3">Title</th>
|
||||||
<tbody>
|
<th className="px-4 py-3">Status</th>
|
||||||
{incidents.map((inc) => (
|
<th className="px-4 py-3">Calls</th>
|
||||||
<IncidentRow
|
<th className="px-4 py-3">Started</th>
|
||||||
key={inc.incident_id}
|
<th className="px-4 py-3">Updated</th>
|
||||||
incident={inc}
|
<th className="px-4 py-3"></th>
|
||||||
isAdmin={isAdmin}
|
</tr>
|
||||||
onResolve={onResolve}
|
</thead>
|
||||||
/>
|
<tbody>
|
||||||
))}
|
{incidents.map((inc) => (
|
||||||
</tbody>
|
<IncidentRow
|
||||||
</table>
|
key={inc.incident_id}
|
||||||
</div>
|
incident={inc}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onResolve={onResolve}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,17 @@ export default function NodeDetailPage() {
|
|||||||
>
|
>
|
||||||
Restart OP25
|
Restart OP25
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={sending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Restart the node container to apply any pulled image updates?")) {
|
||||||
|
sendCommand("node_update");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Update Node
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDiscordJoin(true)}
|
onClick={() => setShowDiscordJoin(true)}
|
||||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors"
|
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run before pushing — catches TypeScript errors without a full build.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
echo "→ TypeScript check…"
|
||||||
|
npm run typecheck
|
||||||
|
echo "✓ No type errors."
|
||||||
@@ -30,6 +30,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editText, setEditText] = useState("");
|
const [editText, setEditText] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const isActive = call.status === "active";
|
const isActive = call.status === "active";
|
||||||
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || call.incident_id;
|
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || call.incident_id;
|
||||||
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
|
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
|
||||||
@@ -44,11 +45,12 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
async function saveEdit(e: React.MouseEvent) {
|
async function saveEdit(e: React.MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
await c2api.patchTranscript(call.call_id, editText);
|
await c2api.patchTranscript(call.call_id, editText);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
setSaveError(err instanceof Error ? err.message : "Save failed");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -71,8 +73,8 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-400">{systemName ?? call.system_id ?? "—"}</td>
|
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
|
||||||
<td className="px-4 py-2 text-gray-400">{call.node_id}</td>
|
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<span className="text-orange-400 animate-pulse">● live</span>
|
<span className="text-orange-400 animate-pulse">● live</span>
|
||||||
@@ -136,7 +138,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
rows={4}
|
rows={4}
|
||||||
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-xs text-gray-200 font-mono focus:outline-none focus:border-indigo-500 resize-none"
|
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-xs text-gray-200 font-mono focus:outline-none focus:border-indigo-500 resize-none"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={saveEdit}
|
onClick={saveEdit}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -150,6 +152,9 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
{saveError && (
|
||||||
|
<span className="text-xs text-red-400 font-mono">{saveError}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : hasSegments ? (
|
) : hasSegments ? (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.14-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user