Author SHA1 Message Date
Logan CusanoandClaude Sonnet 5 968134f8ee frontend: fix map stacking + honest infra error states
From a live review of drb.cusano.net.

MapView.tsx / globals.css:
- The Leaflet map painted above the sticky Nav (z-40) and modal overlays, so
  on Live the account dropdown opened *behind* the map. Pin .leaflet-container
  to its own stacking context (position:relative; z-index:0) — keeps Leaflet's
  internal pane order, drops the whole map below app chrome. The map's own
  overlay UI (legend, rail, clock, fit-all) is outside .leaflet-container and
  unaffected. Chosen over raising Nav's z-index, which would float the sticky
  header over modal backdrops on ~7 pages.
- Basemap: the "Dark" tile URL is already CARTO's keyless dark raster (so a
  prod "API KEY REQUIRED" watermark is a stale build or CARTO rate-limiting
  the origin, not this code). Add NEXT_PUBLIC_MAP_TILE_URL as a build-time
  override so a keyed style drops in without a code change; add the OSM
  attribution the keyless CARTO tiles require.

incidents/page.tsx, alerts/page.tsx:
- Both dumped raw Firestore "requires an index / PERMISSION_DENIED" strings
  (with a console.firebase URL) straight into the UI when the composite
  indexes aren't deployed (server-26 #13/#51). Collapse those known infra
  failures to a plain sentence; any other error passes through verbatim so a
  real bug still shows. alerts also now surfaces the events-query error at
  all — it was swallowed, showing a false "No alerts triggered yet." on a
  public-safety screen.

onboarding/page.tsx: stale comment (/dashboard -> "/").

Untypechecked (no node/npm locally); presentational only — one string
helper, one added error branch, a CSS rule, two tile-URL constants, a
comment. next build in deploy.yml gates it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:43:22 -04:00
logan b430cf32f2 Merge pull request 'frontend: install command uses the node id from the mint form (node-26#4)' (#107) from feat/mint-panel-nodeid into main
Build & Deploy / Build & push images (push) Successful in 5m51s
Build & Deploy / Deploy to VM (push) Successful in 1m42s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #107
2026-09-06 20:15:10 -04:00
Logan CusanoandClaude Sonnet 5 93fa3a6054 frontend: install command uses the node id from the mint form (node-26#4)
The mint panel's copy command hard-coded --node-id node-XXX. Now the label
just entered (the operator types the node id there — placeholder relabeled
"Node ID, e.g. node-003") is captured on mint and interpolated into the
command: spaces → dashes, non [A-Za-z0-9_-] stripped (install.sh's rule),
falling back to node-XXX only if that yields nothing. The "edit node-XXX"
hint now only shows in the fallback case.

Not typechecked (no node/npm here); one useState<string|null>, one derived
string, a JSX conditional. `next build` in deploy.yml gates it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:13:23 -04:00
6 changed files with 67 additions and 14 deletions
+7 -1
View File
@@ -186,7 +186,7 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
export default function AlertsPage() { export default function AlertsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { alerts, loading } = useAlerts(); const { alerts, loading, error } = useAlerts();
const [tab, setTab] = useState<"events" | "rules">("events"); const [tab, setTab] = useState<"events" | "rules">("events");
async function handleAcknowledge(id: string) { async function handleAcknowledge(id: string) {
@@ -226,6 +226,12 @@ export default function AlertsPage() {
{tab === "events" && ( {tab === "events" && (
loading ? ( loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-gray-500 text-sm font-mono">Loading…</p>
) : error ? (
<p className="text-red-400 text-sm font-mono">
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
? "Couldn't load alerts — a database index or security rule isn't deployed on the server yet (server-26 #13 / #51)."
: `Couldn't load alerts: ${error}`}
</p>
) : alerts.length === 0 ? ( ) : alerts.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p> <p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
) : ( ) : (
+14
View File
@@ -163,6 +163,20 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
animation: pulse-ring 1.8s ease-out infinite; animation: pulse-ring 1.8s ease-out infinite;
} }
/* ── Leaflet stacking fix ─────────────────────────────────────────────────────
* Leaflet's internal panes (z-index 200–700) and its zoom / layers controls
* (z-index 1000) otherwise paint above the sticky app Nav (z-40) and any modal
* overlay — on Live this put the account dropdown *behind* the map. Pinning the
* map container to its own low stacking context keeps Leaflet's internal layer
* order intact while dropping the whole map (tiles + controls) below the app
* chrome. The map's own overlay UI (legend, incident rail, clock, fit-all) sits
* outside .leaflet-container, so it is unaffected and still renders on top.
*/
.leaflet-container {
position: relative;
z-index: 0;
}
/* ── Form inputs ─────────────────────────────────────────────────────────── */ /* ── Form inputs ─────────────────────────────────────────────────────────── */
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,
+12 -1
View File
@@ -28,6 +28,17 @@ const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, mo
type SortMode = "recent" | "severity"; type SortMode = "recent" | "severity";
// The Firestore client surfaces a missing composite index or an undeployed
// ruleset as a raw multi-line string with a console URL in it — not something
// to put in front of an operator. Collapse the known infra failures to a plain
// line; pass anything else straight through so a real bug still shows.
function friendlyIncidentsError(raw: string): string {
if (/requires an index|PERMISSION_DENIED|Missing or insufficient permissions|failed-precondition/i.test(raw)) {
return "Couldn't load incidents — the incidents database index isn't deployed on the server yet. This is a one-time backend deploy step (server-26 #13 / #51), not a problem with your data.";
}
return `Couldn't load incidents: ${raw}`;
}
function fmtTime(iso: string) { function fmtTime(iso: string) {
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; } try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
} }
@@ -286,7 +297,7 @@ export default function IncidentsPage() {
recorded yet" over the top of it told the operator the radio was recorded yet" over the top of it told the operator the radio was
quiet when the page had simply failed to load — server-26#13. */} quiet when the page had simply failed to load — server-26#13. */}
{filtered.length === 0 && error && ( {filtered.length === 0 && error && (
<ErrorBanner message={`Couldn't load incidents: ${error}`} /> <ErrorBanner message={friendlyIncidentsError(error)} />
)} )}
{filtered.length === 0 && !error && ( {filtered.length === 0 && !error && (
+1 -1
View File
@@ -43,7 +43,7 @@ export default function OnboardingPage() {
// Firebase custom claims only show up in a *freshly fetched* ID token — // Firebase custom claims only show up in a *freshly fetched* ID token —
// getIdTokenResult(true) inside refreshClaims forces that fetch, then // getIdTokenResult(true) inside refreshClaims forces that fetch, then
// AuthProvider's own state (orgId) updates and the effect above // AuthProvider's own state (orgId) updates and the effect above
// redirects to /dashboard. // redirects to "/" (Live).
await refreshClaims(); await refreshClaims();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again."); setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
+19 -8
View File
@@ -41,19 +41,26 @@ function EnrollmentTokensPanel() {
const [justMinted, setJustMinted] = useState<string | null>(null); const [justMinted, setJustMinted] = useState<string | null>(null);
const [cmdCopied, setCmdCopied] = useState(false); const [cmdCopied, setCmdCopied] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false); const [tokenCopied, setTokenCopied] = useState(false);
// The label the operator typed for the token that was just minted — used as
// the node id in the install command below. Captured on mint because `label`
// itself is cleared afterward.
const [mintedLabel, setMintedLabel] = useState<string | null>(null);
// The paste-ready one-shot install command for a fresh Pi. node id and the // The paste-ready one-shot install command for a fresh Pi. The node id comes
// MQTT broker host aren't known here — left as placeholders the operator // from the label just entered (spaces → dashes; install.sh requires
// edits. C2 URL comes from this deployment's own config; the broker is the // [A-Za-z0-9_-]); if that yields nothing it falls back to a node-XXX
// documented mqtt.<domain> sibling of the api host (install.sh header). // placeholder. The MQTT broker host is the documented mqtt.<domain> sibling
// of the api host (install.sh header) — a DNS assumption the operator checks.
const c2Url = (process.env.NEXT_PUBLIC_C2_URL ?? "https://api.example.net").replace(/\/$/, ""); const c2Url = (process.env.NEXT_PUBLIC_C2_URL ?? "https://api.example.net").replace(/\/$/, "");
const mqttBroker = (() => { const mqttBroker = (() => {
try { return `mqtt.${new URL(c2Url).hostname.replace(/^api\./, "")}`; } try { return `mqtt.${new URL(c2Url).hostname.replace(/^api\./, "")}`; }
catch { return "mqtt.example.net"; } catch { return "mqtt.example.net"; }
})(); })();
const nodeIdForCmd =
(mintedLabel ?? "").trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_-]/g, "") || "node-XXX";
const installCmd = justMinted const installCmd = justMinted
? `curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \\ ? `curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \\
| sudo bash -s -- --token ${justMinted} --node-id node-XXX \\ | sudo bash -s -- --token ${justMinted} --node-id ${nodeIdForCmd} \\
--c2-url ${c2Url} --mqtt-broker ${mqttBroker}` --c2-url ${c2Url} --mqtt-broker ${mqttBroker}`
: ""; : "";
@@ -74,6 +81,7 @@ function EnrollmentTokensPanel() {
try { try {
const result = await c2api.mintEnrollmentToken(label.trim()); const result = await c2api.mintEnrollmentToken(label.trim());
setJustMinted(result.token); setJustMinted(result.token);
setMintedLabel(label.trim());
setLabel(""); setLabel("");
load(); load();
} catch (err) { } catch (err) {
@@ -119,7 +127,10 @@ function EnrollmentTokensPanel() {
</div> </div>
<p className="text-xs text-indigo-200 font-mono mt-3 mb-1"> <p className="text-xs text-indigo-200 font-mono mt-3 mb-1">
…or run this on a fresh Pi (edit <span className="text-indigo-100">node-XXX</span> and the broker host): …or run this on a fresh Pi{" "}
{nodeIdForCmd === "node-XXX"
? <>(edit <span className="text-indigo-100">node-XXX</span> and check the broker host)</>
: <>(check the broker host)</>}:
</p> </p>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre> <pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre>
@@ -136,7 +147,7 @@ function EnrollmentTokensPanel() {
<button <button
type="button" type="button"
onClick={() => setJustMinted(null)} onClick={() => { setJustMinted(null); setMintedLabel(null); }}
className="text-xs text-indigo-300 hover:text-indigo-200 mt-3 transition-colors" className="text-xs text-indigo-300 hover:text-indigo-200 mt-3 transition-colors"
> >
Dismiss Dismiss
@@ -151,7 +162,7 @@ function EnrollmentTokensPanel() {
<input <input
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="Label, e.g. 'node-003 field kit'" placeholder="Node ID, e.g. node-003"
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
<Button type="submit" size="sm" disabled={minting || !label.trim()}> <Button type="submit" size="sm" disabled={minting || !label.trim()}>
+14 -3
View File
@@ -24,6 +24,17 @@ L.Icon.Default.mergeOptions({
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
}); });
// ── Basemap tiles ─────────────────────────────────────────────────────────────
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI.
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change.
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme.
const MAP_TILE_URL =
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const MAP_TILE_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/">CARTO</a>';
// ── Colour ──────────────────────────────────────────────────────────────────── // ── Colour ────────────────────────────────────────────────────────────────────
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident // Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
// type is carried by the glyph knocked out of the pin, never by colour, and // type is carried by the glyph knocked out of the pin, never by colour, and
@@ -540,14 +551,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* Base layers */} {/* Base layers */}
<LayersControl.BaseLayer checked name="Dark"> <LayersControl.BaseLayer checked name="Dark">
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" url={MAP_TILE_URL}
attribution='&copy; <a href="https://carto.com/">CARTO</a>' attribution={MAP_TILE_ATTRIBUTION}
/> />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Light"> <LayersControl.BaseLayer name="Light">
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CARTO</a>' attribution={MAP_TILE_ATTRIBUTION}
/> />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Streets"> <LayersControl.BaseLayer name="Streets">