Author SHA1 Message Date
logan bc3251e8df Merge pull request 'frontend: #109 punch-list P2 — RulesTab effect, node recent-calls window' (#123) from fix/109-punchlist-p2 into main
Build & Deploy / Build & push images (push) Successful in 5m45s
Build & Deploy / Deploy to VM (push) Successful in 2m23s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-07 19:06:34 -04:00
logan 7a5bd5dbbb Merge pull request 'map: remove stray clock, unstack incident rail, OSM tile fallback (#118)' (#122) from fix/118-map-overlays into main
Build & Deploy / Deploy to VM (push) Canceled after 0s
Build & Deploy / Report a failed deploy (push) Canceled after 0s
Build & Deploy / Build & push images (push) Canceled after 1m31s
2026-09-07 19:06:30 -04:00
logan 629bd1c340 Merge pull request 'firestore: declare the alert_events composite index (#51)' (#121) from fix/51-alert-events-index into main
Build & Deploy / Deploy to VM (push) Canceled after 0s
Build & Deploy / Report a failed deploy (push) Canceled after 0s
Build & Deploy / Build & push images (push) Canceled after 1m28s
2026-09-07 19:06:28 -04:00
logan cea094d66b Merge pull request 'c2-core: fix CORS so the browser can call the REST API (#110)' (#120) from fix/110-c2-core-cors into main
Build & Deploy / Deploy to VM (push) Canceled after 0s
Build & Deploy / Report a failed deploy (push) Canceled after 0s
Build & Deploy / Build & push images (push) Canceled after 1m31s
2026-09-07 19:06:25 -04:00
logan 01c146e21e Merge pull request 'ci: bake NEXT_PUBLIC_MAP_TILE_URL into the frontend build (#117)' (#119) from fix/117-map-tile-build-arg into main
Build & Deploy / Build & push images (push) Failing after 14s
Build & Deploy / Deploy to VM (push) Skipped
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-07 19:06:21 -04:00
Logan CusanoandClaude Sonnet 5 8a0412b529 firestore: add the alert_events composite index (#51)
collectionGroup alert_events (acknowledged, org_id, triggered_at desc) — the index the /watch Triggered Alerts tab and the site-wide useUnacknowledgedAlerts hook require. Still needs a manual deploy; no automation exists (#51).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:54:10 -04:00
Logan CusanoandClaude Sonnet 5 52edbf105c map: remove stray clock, unstack the incident card from the zoom controls, OSM tile fallback (#118)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:53:14 -04:00
Logan CusanoandClaude Sonnet 5 77f1d2f93f frontend: #109 punch-list P2 — effect-guard RulesTab, pending node card modal, org save, node recent-calls filter
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:52:39 -04:00
Logan CusanoandClaude Sonnet 5 d60fef67ad c2-core: add CORS middleware so the browser can call the REST API (#110)
The Archive page's GET /calls/search failed its CORS preflight (OPTIONS -> 405, no Access-Control-* headers). Allow the app origin(s) explicitly for the standard methods and the authorization/content-type headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:52:38 -04:00
Logan CusanoandClaude Sonnet 5 fe643924c7 ci: bake NEXT_PUBLIC_MAP_TILE_URL into the frontend build (#117)
The map override var was added to MapView.tsx but never passed as a build-arg, so prod still shipped the dead Carto tile URL. Point it at OSM raster tiles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:48:42 -04:00
logan bccb3e0316 correlator: give the LLM tier what it needs to link, stop it defaulting to "new" (#116)
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy to VM (push) Successful in 2m25s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-07 16:59:46 -04:00
10 changed files with 151 additions and 66 deletions
+1
View File
@@ -63,6 +63,7 @@ jobs:
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }} NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }} NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }}
NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }} NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }}
NEXT_PUBLIC_MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
deploy: deploy:
name: Deploy to VM name: Deploy to VM
+7
View File
@@ -33,6 +33,13 @@ SUMMARY_INTERVAL_MINUTES=15
CORRELATION_WINDOW_HOURS=4 CORRELATION_WINDOW_HOURS=4
EMBEDDING_SIMILARITY_THRESHOLD=0.82 EMBEDDING_SIMILARITY_THRESHOLD=0.82
# Browser origins allowed to call this API cross-origin (JSON list). The only
# browser caller is the frontend's Archive page (GET /calls/search). Set this
# to the exact origin the frontend is served from — scheme + host, no path.
# Defaults to https://drb.cusano.net. A "*" entry works for local dev but is
# logged as a probable misconfiguration and never gets a credentialed response.
CORS_ORIGINS=["https://drb.cusano.net"]
# Fleet-wide token edge nodes present as X-Enrollment-Token on first boot # Fleet-wide token edge nodes present as X-Enrollment-Token on first boot
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret. # (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
+11 -9
View File
@@ -180,16 +180,18 @@ class Settings(BaseSettings):
# between genuinely separate transmissions on a busy dispatch channel. # between genuinely separate transmissions on a busy dispatch channel.
duplicate_window_seconds: int = 10 duplicate_window_seconds: int = 10
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"] # Browser origins allowed to call this API cross-origin. The only browser
# Defaults to "*" for local development only. # caller is the frontend's Archive page (GET /calls/search) — every other
# page reads Firestore directly. The frontend is served on the BARE domain
# (see infra Caddyfile.j2 — only drb. and api. have DNS records), so the
# default is that origin, not app.<domain>. Override via CORS_ORIGINS (JSON
# list) if the frontend ever moves; keep infra/.../c2-core.env.j2 in sync.
# #
# Leaving this as "*" is not merely permissive: main.py turns OFF # A "*" entry here still works for local dev but is refused a credentialed
# allow_credentials when it sees a wildcard, because Starlette would # response: main.py never enables allow_credentials (auth is a Bearer
# otherwise reflect each caller's origin back WITH # header, not a cookie), and it logs a loud ERROR when it sees a wildcard
# Access-Control-Allow-Credentials. So a production deployment that # in a deployment so a forgotten override is visible.
# forgets to set this gets a loud ERROR at startup and loses credentialed cors_origins: list[str] = ["https://drb.cusano.net"]
# cross-origin requests, rather than silently accepting every origin.
cors_origins: list[str] = ["*"]
# Discord webhook URL that app/internal/ai_health.py posts to when an AI # Discord webhook URL that app/internal/ai_health.py posts to when an AI
# tier (transcription/correlation) transitions into or out of degraded # tier (transcription/correlation) transitions into or out of degraded
+24 -17
View File
@@ -78,33 +78,40 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="DRB C2 Core", lifespan=lifespan) app = FastAPI(title="DRB C2 Core", lifespan=lifespan)
# "*" plus allow_credentials=True is not the permissive-but-harmless setting it # The browser needs CORS to reach this API at all: the frontend's Archive page
# looks like. Starlette does not refuse the combination -- it reflects the # calls GET /calls/search with Authorization + Content-Type headers, which
# caller's Origin back and still sends Access-Control-Allow-Credentials: true, # forces a preflight. Without this middleware the OPTIONS gets a bare 405 and
# so the effective policy becomes "any origin, with credentials", the opposite # the fetch fails (#110). allow_origins is an explicit list -- never "*" in a
# of what a wildcard normally means. Rather than trust every deployment to # deployment -- so name every host the frontend is served from in CORS_ORIGINS.
# remember to override CORS_ORIGINS, make the dangerous pair unrepresentable. #
# allow_credentials stays False on purpose: auth here is a Bearer header, not a
# cookie, so credentialed CORS is never needed, and keeping it False is what
# lets an explicit-origin allowlist work without Starlette's "*"-only
# restriction. "*" + credentials is the dangerous pair (Starlette reflects the
# caller's Origin back WITH Access-Control-Allow-Credentials: true); this code
# cannot produce it because credentials are hard-off.
def cors_allows_credentials(origins: list[str]) -> bool: def cors_allows_credentials(origins: list[str]) -> bool:
"""False when any entry is a wildcard. Extracted so it can be tested """Always False -- credentialed CORS is never enabled here (Bearer auth,
without re-importing this module, which drags in every router.""" not cookies). Kept as a named predicate so a future edit that wants to
return "*" not in origins turn credentials on has to go through here and confront the "*" case.
A wildcard entry would additionally be refused a credentialed response."""
return False
_cors_is_wildcard = not cors_allows_credentials(settings.cors_origins) _cors_is_wildcard = "*" in settings.cors_origins
if _cors_is_wildcard: if _cors_is_wildcard:
logger.error( logger.error(
"CORS_ORIGINS is '*', so credentialed cross-origin requests are being " "CORS_ORIGINS contains '*'. That is fine for local dev but is almost "
"DISABLED to avoid reflecting every caller's origin back with " "certainly a misconfigured deployment -- set CORS_ORIGINS to your "
"Access-Control-Allow-Credentials. Set CORS_ORIGINS to your frontend " "frontend origin(s), e.g. [\"https://drb.cusano.net\"]."
"origin(s) in production, e.g. [\"https://app.example.com\"]."
) )
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["authorization", "content-type"],
allow_credentials=not _cors_is_wildcard, allow_credentials=False,
) )
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
+66
View File
@@ -0,0 +1,66 @@
"""
End-to-end CORS wiring for the one browser-facing REST surface.
The frontend's Archive page calls GET /calls/search with Authorization +
Content-Type headers, which forces the browser to send a CORS preflight
first. Before #110 that OPTIONS got a bare 405 with no Access-Control-*
headers and the fetch failed with "TypeError: Failed to fetch". These
tests drive the real app through TestClient so a regression in the
middleware wiring (not just the helper) is caught.
TestClient is NOT used as a context manager on purpose: that would run the
lifespan (mqtt_handler.connect(), the sweeper loops, dynsec bootstrap),
none of which is needed here -- CORSMiddleware answers a preflight before
routing or dependencies run.
"""
from fastapi.testclient import TestClient
from app.config import settings
from app.main import app
client = TestClient(app)
ALLOWED_ORIGIN = "https://drb.cusano.net"
DISALLOWED_ORIGIN = "https://evil.example.com"
def test_default_allowed_origin_matches_the_deployed_frontend():
# The frontend is served on the bare domain (infra Caddyfile.j2), so the
# default must allow exactly that origin without any env override.
assert ALLOWED_ORIGIN in settings.cors_origins
def test_preflight_for_calls_search_is_allowed():
resp = client.options(
"/calls/search",
headers={
"Origin": ALLOWED_ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization,content-type",
},
)
assert resp.status_code == 200
assert resp.headers.get("access-control-allow-origin") == ALLOWED_ORIGIN
allow_methods = resp.headers.get("access-control-allow-methods", "").upper()
assert "GET" in allow_methods
# Bearer auth, not cookies -- credentials must never be advertised.
assert "access-control-allow-credentials" not in resp.headers
def test_preflight_from_disallowed_origin_gets_no_allow_origin():
resp = client.options(
"/calls/search",
headers={
"Origin": DISALLOWED_ORIGIN,
"Access-Control-Request-Method": "GET",
},
)
assert resp.headers.get("access-control-allow-origin") is None
def test_simple_get_from_allowed_origin_is_annotated():
# Even a non-preflight GET must carry Access-Control-Allow-Origin or the
# browser hides the response body from the page.
resp = client.get("/health", headers={"Origin": ALLOWED_ORIGIN})
assert resp.status_code == 200
assert resp.headers.get("access-control-allow-origin") == ALLOWED_ORIGIN
+9 -7
View File
@@ -5,8 +5,9 @@ Starlette does not reject `allow_origins=["*"]` combined with
`allow_credentials=True`. It reflects the caller's Origin back in `allow_credentials=True`. It reflects the caller's Origin back in
Access-Control-Allow-Origin and still sends Access-Control-Allow-Origin and still sends
Access-Control-Allow-Credentials: true, so the effective policy is the Access-Control-Allow-Credentials: true, so the effective policy is the
opposite of what a wildcard usually means. main.py defuses that by turning opposite of what a wildcard usually means. main.py never enables
credentials off whenever it sees a wildcard; these tests hold it to that. credentials at all (auth is a Bearer header, not a cookie), which makes
that pair unrepresentable; these tests hold it to that.
The policy lives in a pure function so it can be exercised directly -- The policy lives in a pure function so it can be exercised directly --
reloading app.main to vary settings drags every router back through import reloading app.main to vary settings drags every router back through import
@@ -28,11 +29,11 @@ def test_wildcard_among_real_origins_still_disables_credentials():
assert cors_allows_credentials(["https://app.example.com", "*"]) is False assert cors_allows_credentials(["https://app.example.com", "*"]) is False
def test_named_origins_keep_credentials(): def test_credentials_never_enabled_even_for_named_origins():
# Naming your origins is how you ask for credentialed requests, so a # Auth here is a Bearer header, not a cookie, so credentialed CORS is
# correctly configured deployment must not be penalised. # never needed. The predicate is hard-off regardless of the origin list.
assert cors_allows_credentials(["https://app.example.com"]) is True assert cors_allows_credentials(["https://app.example.com"]) is False
assert cors_allows_credentials([]) is True assert cors_allows_credentials([]) is False
def test_the_app_actually_mounted_that_policy(): def test_the_app_actually_mounted_that_policy():
@@ -42,6 +43,7 @@ def test_the_app_actually_mounted_that_policy():
(mw.kwargs for mw in app.user_middleware if mw.cls is CORSMiddleware), None (mw.kwargs for mw in app.user_middleware if mw.cls is CORSMiddleware), None
) )
assert opts is not None, "CORSMiddleware is not mounted at all" assert opts is not None, "CORSMiddleware is not mounted at all"
assert opts["allow_credentials"] is False
assert opts["allow_credentials"] is cors_allows_credentials(settings.cors_origins) assert opts["allow_credentials"] is cors_allows_credentials(settings.cors_origins)
+3 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useAlerts } from "@/lib/useAlerts"; import { useAlerts } from "@/lib/useAlerts";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
@@ -32,8 +32,8 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
} }
} }
// Load on first render of this tab // Load once when this tab mounts (load() self-guards on `loaded`).
if (!loaded) { load(); } useEffect(() => { load(); }, []);
async function handleCreate(e: React.FormEvent) { async function handleCreate(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
+4 -1
View File
@@ -120,7 +120,10 @@ export default function NodeDetailPage() {
const [approving, setApproving] = useState(false); const [approving, setApproving] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const { systems } = useSystems(); const { systems } = useSystems();
const { calls } = useCalls(20); // TODO(server-26#109 item5): server-side node_id filter. A where("node_id","==",id)
// alongside the existing org_id equality + started_at orderBy needs a brand-new
// composite index, so for now pull a wider window and filter client-side.
const { calls } = useCalls(200);
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
+16 -29
View File
@@ -25,15 +25,15 @@ L.Icon.Default.mergeOptions({
}); });
// ── Basemap tiles ───────────────────────────────────────────────────────────── // ── Basemap tiles ─────────────────────────────────────────────────────────────
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI. // Prod sets NEXT_PUBLIC_MAP_TILE_URL to a keyed style (a CARTO account style,
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account // MapTiler, Mapbox, …). The in-code fallback is plain OpenStreetMap so the map
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change. // still renders if that var is missing — CARTO's keyless CDN has proven flaky.
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme. // Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme;
// the {z}/{x}/{y} tokens below are substituted by Leaflet at runtime.
const MAP_TILE_URL = const MAP_TILE_URL =
process.env.NEXT_PUBLIC_MAP_TILE_URL || process.env.NEXT_PUBLIC_MAP_TILE_URL ||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"; "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const MAP_TILE_ATTRIBUTION = const MAP_TILE_ATTRIBUTION = "&copy; OpenStreetMap contributors";
'&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
@@ -459,9 +459,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [agoClock, setAgoClock] = useState(0); const [agoClock, setAgoClock] = useState(0);
const [radarEpoch, setRadarEpoch] = useState(() => Date.now()); const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
const [clockStr, setClockStr] = useState(() =>
new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
);
useEffect(() => { useEffect(() => {
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000); const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
@@ -474,15 +471,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
// Live clock for TOC situational awareness
useEffect(() => {
const id = setInterval(() =>
setClockStr(new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })),
1000
);
return () => clearInterval(id);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
@@ -623,13 +611,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
)} )}
</div> </div>
{/* ── Clock — bottom-left for TOC situational awareness ───────────────── */}
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2 pointer-events-none">
<span className="text-ink text-sm font-mono tabular-nums">{clockStr}</span>
</div>
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */} {/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2"> <div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2 max-h-[calc(100%-4rem)] overflow-y-auto">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p> <p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => ( {(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
@@ -673,15 +656,19 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* ── Incident overlay panel ───────────────────────────────────────────── */} {/* ── Incident overlay panel ───────────────────────────────────────────── */}
{incidents.length > 0 && ( {incidents.length > 0 && (
<> <>
{/* Desktop: left sidebar — starts below zoom controls + fit-all button */} {/* Desktop: left sidebar — offset below the zoom stack + fit-all button
<div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5"> so it never overlaps the Leaflet +/- controls (#118). Height is
capped and the list scrolls on its own, so the rail never reaches
the bottom-right legend. pointer-events are off on the wrapper and
back on for the cards, so the map still pans in the gaps. */}
<div className="absolute top-[9.5rem] left-3 z-[1001] hidden md:flex flex-col w-56 gap-1.5 max-h-[calc(100%-12rem)] pointer-events-none">
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and {/* Gate A / A2 (server-26#46) — the rail's titles, locations and
unit counts are pipeline output. Pinned above the scroll area unit counts are pipeline output. Pinned above the scroll area
so it cannot be scrolled off the screen it qualifies. */} so it cannot be scrolled off the screen it qualifies. */}
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0"> <div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto">
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" /> <MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
</div> </div>
<div className="flex flex-col gap-1.5 overflow-y-auto"> <div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
{incidents.map((inc) => { {incidents.map((inc) => {
const color = severityColor(inc.severity); const color = severityColor(inc.severity);
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null; const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
+10
View File
@@ -75,6 +75,16 @@
{ "fieldPath": "acknowledged", "order": "ASCENDING" }, { "fieldPath": "acknowledged", "order": "ASCENDING" },
{ "fieldPath": "triggered_at", "order": "ASCENDING" } { "fieldPath": "triggered_at", "order": "ASCENDING" }
] ]
},
{
"//": "drb-frontend lib/useAlerts.ts useUnacknowledgedAlerts (nav badge) and the /watch \"Triggered Alerts\" tab — where(org_id ==) where(acknowledged == false) orderBy(triggered_at desc). Threw \"the query requires an index\" on every page until this was declared (server-26#51). Field tuple and triggered_at DESCENDING copy the console create_composite link in that issue verbatim, so a gcloud/console create and a firebase deploy converge on one index rather than the ASC-vs-DESC pair that caused server-26#33. Distinct from the (org_id, triggered_at) alert-feed index above (no acknowledged filter) and supersedes the pre-tenancy live alert_events(acknowledged, triggered_at) index #33 says to delete.",
"collectionGroup": "alert_events",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "acknowledged", "order": "ASCENDING" },
{ "fieldPath": "org_id", "order": "ASCENDING" },
{ "fieldPath": "triggered_at", "order": "DESCENDING" }
]
} }
], ],
"fieldOverrides": [] "fieldOverrides": []