Frontend: fixes from the 10-25 Chrome sweep #294
@@ -1169,8 +1169,10 @@ function SttEvalTab() {
|
|||||||
// scanning", not "done", so this fires again on its own.
|
// scanning", not "done", so this fires again on its own.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queue.length === 0 && !exhausted) loadBatch();
|
if (queue.length === 0 && !exhausted) loadBatch();
|
||||||
|
// `cursor` is a dep so an empty page that advanced the cursor scans the
|
||||||
|
// next one; without it the tab sat on "Loading…" forever.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [queue.length, exhausted]);
|
}, [queue.length, exhausted, cursor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(current ? (current.transcript_corrected || current.transcript || "") : "");
|
setDraft(current ? (current.transcript_corrected || current.transcript || "") : "");
|
||||||
@@ -1309,7 +1311,10 @@ export default function AdminPage() {
|
|||||||
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages";
|
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
// The width toggle wraps the tab body only. It used to wrap the tab bar
|
||||||
|
// too, so the bar reflowed on every switch and left the cursor over
|
||||||
|
// whatever landed under it (e.g. Calls -> "Close calls").
|
||||||
|
<div className="space-y-6">
|
||||||
<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 flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
||||||
@@ -1326,6 +1331,7 @@ export default function AdminPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
||||||
{tab === "features" && <FeaturesTab />}
|
{tab === "features" && <FeaturesTab />}
|
||||||
{tab === "cameras" && <CameraProvidersTab />}
|
{tab === "cameras" && <CameraProvidersTab />}
|
||||||
{tab === "outages" && <OutageProvidersTab />}
|
{tab === "outages" && <OutageProvidersTab />}
|
||||||
@@ -1335,6 +1341,7 @@ export default function AdminPage() {
|
|||||||
{tab === "eval" && <SttEvalTab />}
|
{tab === "eval" && <SttEvalTab />}
|
||||||
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
||||||
{tab === "audit" && <AuditLogTab />}
|
{tab === "audit" && <AuditLogTab />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,14 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||||
|
/* Placeholders must read as hints, not values: Tailwind's default #9ca3af on
|
||||||
|
* the dark surface looked like filled-in text (e.g. /systems Local Area showed
|
||||||
|
* "Ossining / Westchester" next to "no area set"). Dimmer + italic in dark. */
|
||||||
|
.dark input::placeholder,
|
||||||
|
.dark textarea::placeholder {
|
||||||
|
color: #5F6B80;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||||
html:not(.dark) select,
|
html:not(.dark) select,
|
||||||
html:not(.dark) textarea {
|
html:not(.dark) textarea {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
import { useIncident } from "@/lib/useIncidents";
|
import { useIncident } from "@/lib/useIncidents";
|
||||||
import { useCallsByIncident } from "@/lib/useCalls";
|
import { useCallsByIncident } from "@/lib/useCalls";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
@@ -21,9 +22,9 @@ const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
|||||||
|
|
||||||
const EARLIER_PAGE_SIZE = 8;
|
const EARLIER_PAGE_SIZE = 8;
|
||||||
|
|
||||||
function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string {
|
function elapsedLabel(startedAt: string, active: boolean, endedAt: string): string {
|
||||||
const start = new Date(startedAt).getTime();
|
const start = new Date(startedAt).getTime();
|
||||||
const end = active ? Date.now() : new Date(updatedAt).getTime();
|
const end = active ? Date.now() : new Date(endedAt).getTime();
|
||||||
const mins = Math.max(0, Math.round((end - start) / 60000));
|
const mins = Math.max(0, Math.round((end - start) / 60000));
|
||||||
if (mins < 60) return `${mins}m`;
|
if (mins < 60) return `${mins}m`;
|
||||||
const hrs = Math.floor(mins / 60);
|
const hrs = Math.floor(mins / 60);
|
||||||
@@ -33,7 +34,6 @@ function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): st
|
|||||||
export default function IncidentDetailPage() {
|
export default function IncidentDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { incident, loading } = useIncident(id);
|
const { incident, loading } = useIncident(id);
|
||||||
const { calls, loading: callsLoading } = useCallsByIncident(id);
|
const { calls, loading: callsLoading } = useCallsByIncident(id);
|
||||||
@@ -82,18 +82,23 @@ export default function IncidentDetailPage() {
|
|||||||
const unitsCleared = incident.units_cleared ?? [];
|
const unitsCleared = incident.units_cleared ?? [];
|
||||||
const vehicles = incident.vehicles ?? [];
|
const vehicles = incident.vehicles ?? [];
|
||||||
const active = incident.status === "active";
|
const active = incident.status === "active";
|
||||||
|
// A manual resolve doesn't set resolved_at and updated_at can predate the
|
||||||
|
// last call, which showed "0m elapsed" on an 11-call incident — so a
|
||||||
|
// resolved incident runs to the latest of those and its newest call.
|
||||||
|
const resolvedEnd = [incident.resolved_at, incident.updated_at, newestFirst[0]?.ended_at, newestFirst[0]?.started_at]
|
||||||
|
.filter((t): t is string => !!t)
|
||||||
|
.reduce((a, b) => (b > a ? b : a), incident.started_at);
|
||||||
|
|
||||||
const visible = newestFirst.slice(0, earlierShown);
|
const visible = newestFirst.slice(0, earlierShown);
|
||||||
const remaining = newestFirst.length - visible.length;
|
const remaining = newestFirst.length - visible.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
{/* A real link, not router.back(): arriving from Live or a shared URL,
|
||||||
onClick={() => router.back()}
|
"back" went to the map or out of the app. */}
|
||||||
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
|
<Link href="/incidents" className="text-xs text-ink-muted hover:text-ink-2 transition-colors">
|
||||||
>
|
|
||||||
← Incidents
|
← Incidents
|
||||||
</button>
|
</Link>
|
||||||
|
|
||||||
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
|
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||||
@@ -109,7 +114,7 @@ export default function IncidentDetailPage() {
|
|||||||
{incident.title ?? "Incident"}
|
{incident.title ?? "Incident"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-ink-muted font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed
|
{elapsedLabel(incident.started_at, active, resolvedEnd)} elapsed
|
||||||
{path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path</>}
|
{path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path</>}
|
||||||
{path?.mayBeMerged && (
|
{path?.mayBeMerged && (
|
||||||
<span title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
|
<span title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
|
||||||
|
|||||||
@@ -484,15 +484,17 @@ function DayTimeline({
|
|||||||
style={{ top, height, left: 60, right: 0 }}
|
style={{ top, height, left: 60, right: 0 }}
|
||||||
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
|
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
|
||||||
isConflict
|
isConflict
|
||||||
? "bg-red-950/70 border border-red-700/70"
|
// Theme tokens: the old bg-indigo-950/60 had no light-mode
|
||||||
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
|
// override, so light mode got dark text on a dark block.
|
||||||
|
? "bg-sev-major/15 border border-sev-major/50"
|
||||||
|
: "bg-accent/15 border border-accent/40 hover:border-accent/70"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 h-full">
|
<div className="flex items-start justify-between gap-2 h-full">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
|
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
|
||||||
{height >= 44 && (
|
{height >= 44 && (
|
||||||
<p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-red-400" : "text-indigo-400"}`}>
|
<p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-sev-major" : "text-accent"}`}>
|
||||||
{fmtTime(e.start_time)}
|
{fmtTime(e.start_time)}
|
||||||
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
|
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
|
||||||
{isConflict ? " ⚠ conflict" : ""}
|
{isConflict ? " ⚠ conflict" : ""}
|
||||||
|
|||||||
@@ -54,8 +54,12 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
<div className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
<div className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
||||||
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
||||||
<p className="text-ink-2 text-sm mb-5">
|
<p className="text-ink-2 text-sm mb-5">
|
||||||
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
{node.configured ? (
|
||||||
Assign it a radio system to begin monitoring.
|
<><span className="text-indigo-400">{node.node_id}</span>: choose the radio system it should monitor.</>
|
||||||
|
) : (
|
||||||
|
<><span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
||||||
|
Assign it a radio system to begin monitoring.</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|||||||
@@ -487,6 +487,8 @@ export interface IncidentRecord {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
type: string | null;
|
type: string | null;
|
||||||
status: "active" | "resolved";
|
status: "active" | "resolved";
|
||||||
|
/** Set by the idle sweep; a manual resolve (PUT /incidents) doesn't set it. */
|
||||||
|
resolved_at?: string | null;
|
||||||
location: string | null;
|
location: string | null;
|
||||||
location_coords: GeoPoint | null;
|
location_coords: GeoPoint | null;
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user