Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a5bd5dbbb | ||
|
|
629bd1c340 | ||
|
|
cea094d66b | ||
|
|
01c146e21e | ||
|
|
8a0412b529 | ||
|
|
52edbf105c | ||
|
|
d60fef67ad | ||
|
|
fe643924c7 | ||
|
|
bccb3e0316 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "© OpenStreetMap contributors";
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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;
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
|||||||
Reference in New Issue
Block a user