Staging environment + FleetOps split #17

Open
kianiadee wants to merge 23 commits from feat/staging-fleetops-architecture into main
Showing only changes of commit 03c66fbd17 - Show all commits

View file

@ -27,6 +27,12 @@ is the base URL (the `N8N_BASE` constant in each dashboard SPA):
trips payload (reporting.fn_trips_for_map)
GET /webhook/tickets?service_type=&status=&open_only=
{ summary, geojson } (reporting.fn_tickets_for_map) INC/CRQ map layer
GET /webhook/{inc,crq}-dashboard?cluster=&status=&window=&from=&to=
{ window, open, closed, metrics, freshness } (reporting.fn_{inc,crq}_dashboard)
GET /webhook/{inc,crq}-search?ticket_id=&owner=&cluster=&status=&state=&from=&to=
{ count, truncated, limit, state, rows } (reporting.fn_{inc,crq}_search)
GET /webhook/{inc,crq}-filter-options
{ owners, clusters, open_ticket_ids } (reporting.fn_{inc,crq}_filter_options)
GET /health
"""
@ -439,6 +445,124 @@ def inc_filter_options():
return JSONResponse({"owners": [], "clusters": [], "open_ticket_ids": []})
# ── CRQ operations dashboard (new-installation tickets) ───────────────────────
# CRQ mirrors INC: identical payload shape, served by reporting.fn_crq_dashboard /
# fn_crq_search / fn_crq_filter_options over tickets.crq (fleettickets migration 16).
# Powers the FleetOps Tickets tab's CRQ sub-tab. The INC routes above are unchanged;
# these are parallel so the two datasets stay independently observable.
@app.get("/webhook/crq-dashboard")
def crq_dashboard(
cluster: str | None = None, # exact tickets.crq.cluster (UPPERCASE), blank = all
status: str | None = None, # exact normalized_status, blank = all
window: str = "today", # today | week | month | custom (calendar EAT)
from_: str | None = Query(None, alias="from"), # custom start (inclusive), ISO-8601
to: str | None = None, # custom end (exclusive), ISO-8601
):
if window not in _INC_WINDOWS:
return _bad_request("window must be one of today|week|month|custom")
f, t = _clean(from_), _clean(to)
if window == "custom" and not f and not t:
return _bad_request("custom window requires from and/or to")
def _parse(v):
if not v:
return None
try:
return datetime.fromisoformat(v)
except ValueError:
return False
pf, pt = _parse(f), _parse(t)
if pf is False or pt is False:
return _bad_request("from/to must be ISO-8601 timestamps with an offset/Z")
if pf and pt and pf >= pt:
return _bad_request("from must be earlier than to")
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT reporting.fn_crq_dashboard(%s, %s, %s, %s, %s)",
(_clean(cluster), _clean(status), window, f, t),
)
payload = cur.fetchone()[0] or {}
return JSONResponse(payload) # jsonb body returned unchanged
except Exception:
log.exception("crq-dashboard failed")
return JSONResponse(
{
"error": {
"type": "unknown",
"message": "CRQ dashboard is unavailable. Try again in a few seconds.",
}
}
)
@app.get("/webhook/crq-search")
def crq_search(
ticket_id: str | None = None, # substring match on ticket_id
owner: str | None = None, # engineer — case-insensitive substring on owner
cluster: str | None = None, # exact tickets.crq.cluster
status: str | None = None, # exact normalized_status
state: str = "closed", # closed | open | all
from_: str | None = Query(None, alias="from"), # closed-at range start (ISO-8601)
to: str | None = None, # closed-at range end (exclusive, ISO-8601)
):
state = (state or "closed").lower()
if state not in _INC_STATES:
return _bad_request("state must be one of open|closed|all")
f, t = _clean(from_), _clean(to)
def _parse(v):
if not v:
return None
try:
return datetime.fromisoformat(v)
except ValueError:
return False
pf, pt = _parse(f), _parse(t)
if pf is False or pt is False:
return _bad_request("from/to must be ISO-8601 timestamps with an offset/Z")
if pf and pt and pf >= pt:
return _bad_request("from must be earlier than to")
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT reporting.fn_crq_search(%s, %s, %s, %s, %s, %s, %s)",
(_clean(ticket_id), _clean(owner), _clean(cluster), _clean(status), state, f, t),
)
payload = cur.fetchone()[0] or {}
return JSONResponse(payload) # jsonb body returned unchanged
except Exception:
log.exception("crq-search failed")
return JSONResponse(
{
"error": {
"type": "unknown",
"message": "Ticket search is unavailable. Try again in a few seconds.",
}
}
)
@app.get("/webhook/crq-filter-options")
def crq_filter_options():
# Dropdown options for the CRQ ticket explorer (engineers, clusters, open ticket ids).
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT reporting.fn_crq_filter_options()")
payload = cur.fetchone()[0] or {}
return JSONResponse(payload)
except Exception:
log.exception("crq-filter-options failed")
return JSONResponse({"owners": [], "clusters": [], "open_ticket_ids": []})
# ── Fleet trips (#002) ───────────────────────────────────────────────────────
_FILTER_OPTIONS_SQL = """