Split into a FastAPI backend and a React frontend, add case state and SSO
Some checks failed
build-and-deploy / test (push) Has been cancelled
build-and-deploy / image (push) Has been cancelled
build-and-deploy / deploy (push) Has been cancelled

The single-file stdlib server became the limit: no way to track what had been
done about an alert, no accounts, and a UI that had to be hand-rolled in
template strings. This restructures it into something deployable.

Backend (FastAPI)
- app/ holds config, database, auth, delivery and the routers; triagelib keeps
  the triage engine unchanged, so the validated screening and runbook logic is
  untouched.
- Cases persist per alert fingerprint with a status workflow (investigating,
  customer contacted, escalated to Infra, waiting, remediated, resolved, won't
  fix, false positive), an assignee, notes and an append-only history. An alert
  that stops and re-fires lands back on the same case and counts as a reopen.
- Suppression rules move from a JSON file into the database.

Auth
- Signed session cookies over PBKDF2 local accounts, plus an OIDC flow ready for
  Authentik: users are created on first login and admin follows a group claim.
  Local login can be switched off entirely once SSO is live.

Zendesk and Jira
- Delivery is now implemented, behind three gates: the integration must be
  configured, its feature flag on, and CX_FEATURE_SEND_ENABLED on. A demo
  instance leaves the last off and cannot mail anyone. Both search before
  creating, so re-diagnosing an alert updates one ticket rather than opening
  several, and a rolling daily cap stops a loop mailing everybody.

Deployment
- Multi-stage Dockerfile builds the bundle and serves it from the API origin.
- docker-compose for local and single-host use; Gitea Actions runs the tests,
  builds the image and renders deploy/k8s with envsubst.

Two fixes found while testing: assigning a case returned a null assignee, and
add_event could leave an already-loaded history collection stale.

Known gap: the engine reaches OpenStack via `docker exec <region>-osc`, which
does not work in a pod without the CX-Tools containers alongside it.
docs/DEPLOYMENT.md sets out the three ways to close that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 07:11:28 +01:00
parent a039e0b5fd
commit 1262690276
68 changed files with 3839 additions and 2223 deletions

2
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

10
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
# Builds the static bundle. The backend image copies the result out of here.
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund
COPY . .
RUN npm run build
FROM busybox:1.36
COPY --from=build /app/dist /dist

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CX Triage</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "cx-triage-frontend",
"private": true,
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}

61
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { useCallback, useEffect, useState } from "react";
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
import { api, ApiError } from "./lib/api";
import type { AppConfig, User } from "./types";
import Login from "./pages/Login";
import Queue from "./pages/Queue";
import Linkage from "./pages/Linkage";
import SettingsPage from "./pages/Settings";
export default function App() {
const [user, setUser] = useState<User | null>(null);
const [config, setConfig] = useState<AppConfig | null>(null);
const [ready, setReady] = useState(false);
const refresh = useCallback(async () => {
try {
const me = await api.get<{ user: User | null; config: AppConfig }>("/api/auth/me");
setUser(me.user);
setConfig(me.config);
} catch (err) {
if (!(err instanceof ApiError)) console.error(err);
setUser(null);
} finally {
setReady(true);
}
}, []);
useEffect(() => { void refresh(); }, [refresh]);
if (!ready) return <div className="empty"><span className="spin" /></div>;
if (!user) return <Login config={config} onSignedIn={refresh} />;
const logout = async () => { await api.post("/api/auth/logout"); await refresh(); };
return (
<>
<header className="top">
<div className="brand">
{config?.app_name ?? "CX Triage"} <em>&mdash; alert triage</em>
</div>
<NavLink to="/" end className={({ isActive }) => `navlink${isActive ? " on" : ""}`}>Queue</NavLink>
{config?.linkage_scan && (
<NavLink to="/linkage" className={({ isActive }) => `navlink${isActive ? " on" : ""}`}>Linkage</NavLink>
)}
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? " on" : ""}`}>Settings</NavLink>
<span className="spacer" />
<span className="who">
{user.name || user.email}
{!config?.send_enabled && <span className="badge" style={{ marginLeft: 8 }}>sending off</span>}
</span>
<button className="sm" onClick={logout}>Sign out</button>
</header>
<Routes>
<Route path="/" element={<Queue config={config!} />} />
<Route path="/linkage" element={<Linkage />} />
<Route path="/settings" element={<SettingsPage user={user} config={config!} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
);
}

View File

@@ -0,0 +1,139 @@
import { useState } from "react";
import { api } from "../lib/api";
import type { Diagnosis, IntegrationAction } from "../types";
interface Props {
kind: "zendesk" | "jira";
action: IntegrationAction;
diagnosis: Diagnosis;
sendEnabled: boolean;
onClose: () => void;
onDone: () => void;
}
/**
* Compose-and-confirm. The Send button stays disabled unless the integration is
* configured *and* sending is enabled for this instance, and the confirm step
* names the recipient so nobody mails the wrong customer by muscle memory.
*/
export default function ActionDrawer({ kind, action, diagnosis, sendEnabled, onClose, onDone }: Props) {
const zendesk = kind === "zendesk";
const ticket = action.payload?.ticket ?? {};
const fields = action.payload?.fields ?? {};
const [to, setTo] = useState<string>(ticket?.requester?.email ?? "");
const [subject, setSubject] = useState<string>(ticket?.subject ?? "");
const [body, setBody] = useState<string>(ticket?.comment?.body ?? "");
const [priority, setPriority] = useState<string>(ticket?.priority ?? "normal");
const [tags, setTags] = useState<string>((ticket?.tags ?? []).join(", "));
const [summary, setSummary] = useState<string>(fields?.summary ?? "");
const [description, setDescription] = useState<string>(fields?.description ?? "");
const [project, setProject] = useState<string>(fields?.project?.key ?? "");
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<{ ok: boolean; text: string; url?: string } | null>(null);
const blocked = !action.enabled || !sendEnabled;
const blockedWhy = !sendEnabled
? "Sending is switched off on this instance (CX_FEATURE_SEND_ENABLED). Nothing will be sent."
: action.blocked_reason;
const send = async () => {
setBusy(true);
try {
const fingerprint = diagnosis.alert.id;
const res = zendesk
? await api.post<{ ticket_id: string; url: string; action: string }>("/api/actions/zendesk", {
fingerprint, to, subject, body, priority,
tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
})
: await api.post<{ key: string; url: string; action: string }>("/api/actions/jira", {
fingerprint, summary, description, project,
labels: fields?.labels ?? [],
});
const label = zendesk ? `Ticket ${(res as any).ticket_id}` : `Issue ${(res as any).key}`;
setResult({ ok: true, text: `${label} ${(res as any).action}`, url: (res as any).url });
onDone();
} catch (err) {
setResult({ ok: false, text: err instanceof Error ? err.message : "Send failed" });
} finally {
setBusy(false);
}
};
return (
<>
<div className="scrim" onClick={onClose} />
<aside className="drawer">
<div className="dh">
<h2>{zendesk ? "Contact customer via Zendesk" : "Escalate to Infrastructure"}</h2>
<button className="sm" onClick={onClose}>Close</button>
</div>
<div className="db">
{blocked && <div className="warnbox"><b>Preview only.</b> {blockedWhy}</div>}
{zendesk && action.payload?._when && (
<div className="warnbox">When to send: {action.payload._when}</div>
)}
{zendesk ? (
<>
<div className="fld"><label>To</label>
<input value={to} onChange={(e) => setTo(e.target.value)} /></div>
<div className="fld"><label>Subject</label>
<input value={subject} onChange={(e) => setSubject(e.target.value)} /></div>
<div className="fld"><label>Message (approved wording edit if needed)</label>
<textarea value={body} onChange={(e) => setBody(e.target.value)} /></div>
<div className="fld"><label>Priority</label>
<select value={priority} onChange={(e) => setPriority(e.target.value)}>
{["low", "normal", "high", "urgent"].map((p) => <option key={p}>{p}</option>)}
</select></div>
<div className="fld"><label>Tags</label>
<input value={tags} onChange={(e) => setTags(e.target.value)} /></div>
</>
) : (
<>
<div className="fld"><label>Project</label>
<input value={project} onChange={(e) => setProject(e.target.value)} /></div>
<div className="fld"><label>Summary</label>
<input value={summary} onChange={(e) => setSummary(e.target.value)} /></div>
<div className="fld"><label>Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} /></div>
</>
)}
</div>
<div className="df">
{result ? (
<>
<span className={result.ok ? "t-ok" : "t-bad"}>{result.text}</span>
{result.url && <a className="navlink" href={result.url} target="_blank" rel="noreferrer">Open</a>}
<button onClick={onClose}>Close</button>
</>
) : confirming ? (
<>
<span style={{ fontSize: 13 }}>
{zendesk ? <>Send a reply to <b>{to}</b>?</> : <>Create an issue in <b>{project}</b>?</>}
</span>
<button className="pri" disabled={busy} onClick={send}>
{busy ? "Sending…" : "Yes, send"}
</button>
<button onClick={() => setConfirming(false)}>Back</button>
</>
) : (
<>
<button className="pri" disabled={blocked} onClick={() => setConfirming(true)}>
{zendesk ? "Send to customer" : "Create issue"}
</button>
<button onClick={onClose}>Cancel</button>
<span className="hint" style={{ margin: 0 }}>
{blocked ? blockedWhy : "You will be asked to confirm."}
</span>
</>
)}
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,83 @@
import { useState } from "react";
import { api } from "../lib/api";
import type { CaseRef } from "../types";
const STATUS_LABELS: Record<string, string> = {
new: "New",
investigating: "Investigating",
customer_contacted: "Customer contacted",
escalated_infra: "Escalated to Infra",
waiting_customer: "Waiting on customer",
waiting_infra: "Waiting on Infra",
remediated: "Remediated",
resolved: "Resolved",
wont_fix: "Won't fix",
false_positive: "False positive",
};
/** Status, ownership, notes and the audit trail for one tracked alert. */
export default function CasePanel({ kase, onChange }: {
kase: CaseRef; onChange: (next: CaseRef) => void;
}) {
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const call = async (fn: () => Promise<CaseRef>) => {
setBusy(true);
try { onChange(await fn()); } finally { setBusy(false); }
};
return (
<div className="card">
<h3 className="sec">Case</h3>
<div className="row">
<select value={kase.status} disabled={busy}
onChange={(e) => call(() => api.post<CaseRef>(
`/api/cases/${kase.fingerprint}/status`, { status: e.target.value }))}>
{Object.entries(STATUS_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
<button className="sm" disabled={busy}
onClick={() => call(() => api.post<CaseRef>(`/api/cases/${kase.fingerprint}/assign`))}>
{kase.assignee ? `Assigned to ${kase.assignee.name}` : "Assign to me"}
</button>
<button className="sm" disabled={busy}
onClick={() => call(() => api.post<CaseRef>(
`/api/cases/${kase.fingerprint}/snooze`, { hours: 24 }))}>Snooze 24h</button>
{kase.reopen_count > 0 && <span className="badge">reopened {kase.reopen_count}×</span>}
{kase.zendesk_ticket_url && (
<a className="navlink" href={kase.zendesk_ticket_url} target="_blank" rel="noreferrer">
Zendesk #{kase.zendesk_ticket_id}</a>
)}
{kase.jira_issue_url && (
<a className="navlink" href={kase.jira_issue_url} target="_blank" rel="noreferrer">
{kase.jira_issue_key}</a>
)}
</div>
<div className="row mt">
<input placeholder="Add a note…" value={note} onChange={(e) => setNote(e.target.value)}
style={{ flex: 1 }}
onKeyDown={(e) => {
if (e.key === "Enter" && note.trim()) {
call(() => api.post<CaseRef>(`/api/cases/${kase.fingerprint}/note`, { note }))
.then(() => setNote(""));
}
}} />
</div>
{(kase.events?.length ?? 0) > 0 && (
<details className="fold" style={{ marginTop: 12, border: "none" }}>
<summary>History ({kase.events!.length})</summary>
<ul className="timeline">
{kase.events!.map((e) => (
<li key={e.id}>
<b>{e.action.replace(/_/g, " ")}</b> {e.detail}
<div className="ts">{new Date(e.created_at).toLocaleString()} · {e.actor}</div>
</li>
))}
</ul>
</details>
)}
</div>
);
}

View File

@@ -0,0 +1,135 @@
import type { Visual } from "../types";
/** Infrahub vs OpenStack, side by side. */
function States({ v }: { v: Visual }) {
const bad = !v.match;
return (
<>
<div className="states">
<div className={`sbox ${bad ? "bad" : "ok"}`}>
<div className="lbl">Infrahub says</div><div className="val">{v.infrahub}</div>
</div>
<div className={`eqlink ${bad ? "bad" : ""}`}>{bad ? "≠" : "="}</div>
<div className={`sbox ${bad ? "bad" : "ok"}`}>
<div className="lbl">OpenStack says</div><div className="val">{v.openstack}</div>
</div>
</div>
<div className="legend">
{v.task && v.task !== "None" && <span>task state <b>{v.task}</b></span>}
<span>host <b>{v.never_built ? "never placed" : v.host}</b></span>
{v.flavor && <span>flavor <b>{v.flavor}</b></span>}
{v.fault && v.fault !== "None" && <span className="t-bad">fault present</span>}
</div>
</>
);
}
/**
* One tile per physical GPU. Drawing only the used ones drops the free sockets
* and leaves a total that does not match the host's GPU count.
*/
function Sockets({ v }: { v: Visual }) {
const slots = v.slots ?? [];
const named = slots.filter((s) => s.kind === "vm").length;
const unclaimed = slots.filter((s) => s.kind === "unaccounted").length;
const free = slots.filter((s) => s.kind === "free").length;
return (
<>
<div className="slots">
{slots.map((s, i) => {
if (s.kind === "vm") {
const cls = s.linked ? (s.match ? "" : "bad") : "unlinked";
return (
<div key={i} className={`slot vm ${cls}`}
title={`${s.name} — Infrahub ${s.ih_status} / OpenStack ${s.os_status}`}>
<span className="idx">GPU {i + 1}</span>
<span className="sn">{s.name}</span>
<span className="ss">
{s.linked ? (s.match ? "in sync" : "state mismatch") : "not in Infrahub"}
</span>
</div>
);
}
if (s.kind === "unaccounted") {
return (
<div key={i} className="slot unaccounted"
title="The host reports this GPU in use, but no instance claims it">
<span className="sn">unaccounted</span>
</div>
);
}
return (
<div key={i} className="slot free" title="Physically present, nothing using it">
<span className="sn">free</span>
</div>
);
})}
</div>
<div className="legend">
<span>{v.physical != null ? `${v.physical} GPU sockets on this host` : "GPU count unknown"}</span>
<span><i style={{ background: "color-mix(in srgb,var(--ok) 60%,transparent)" }} />
{named} held by {v.instances} VM(s)</span>
{unclaimed > 0 && (
<span className="t-bad">
<i style={{ background: "color-mix(in srgb,var(--bad) 55%,transparent)" }} />
{unclaimed} in use but unclaimed
</span>
)}
{free > 0 && <span><i style={{ border: "1px dashed var(--line2)" }} />{free} free</span>}
{v.in_use_metric != null && <span>host reports {v.in_use_metric} in use</span>}
</div>
</>
);
}
/** Every VM on the host, and whether Infrahub has a counterpart for it. */
export function Roster({ v }: { v: Visual }) {
const rows = v.roster ?? [];
if (!rows.length) return null;
const unlinked = rows.filter((r) => !r.linked).length;
const mismatched = rows.filter((r) => r.linked && !r.match).length;
return (
<>
<div className="roster">
<div className="rrow">
<span>VM on this host</span><span>Infrahub</span><span /><span>OpenStack</span><span className="rg">GPU</span>
</div>
{rows.map((r) => (
<div key={r.openstack_id || r.name}
className={`rrow ${r.linked ? (r.match ? "" : "bad") : "unlinked"}`}>
<span className="rn">{r.name}{r.tempest && <span className="badge"> tempest</span>}</span>
<span className="rs">{r.ih_status}</span>
<span className="eq">{r.linked ? (r.match ? "=" : "≠") : "✗"}</span>
<span className="rs">{r.os_status}</span>
<span className="rg">{r.gpus}</span>
</div>
))}
</div>
<div className="legend">
<span>{rows.length} VM(s) on host</span>
<span className={unlinked ? "t-bad" : ""}>{unlinked} with no Infrahub record</span>
<span className={mismatched ? "t-bad" : ""}>{mismatched} state mismatch(es)</span>
</div>
</>
);
}
export default function Visuals({ v }: { v: Visual }) {
if (!v || !v.type) return v?.roster ? <Roster v={v} /> : null;
if (v.type === "states") return <States v={v} />;
if (v.type === "gpu") return <><Sockets v={v} /><Roster v={v} /></>;
if (v.type === "claimants") {
return (
<div style={{ marginTop: 14, display: "flex", flexDirection: "column", gap: 7 }}>
{(v.items ?? []).map((c, i) => (
<div key={i} className="sbox" style={{ display: "flex", gap: 10, alignItems: "center" }}>
<b style={{ flex: 1 }}>{c.name}</b>
<span className="badge">{c.ih_status} / {c.os_status}</span>
<span style={{ color: "var(--dim)", fontSize: 12 }}>{c.verdict}</span>
</div>
))}
</div>
);
}
return null;
}

37
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,37 @@
const json = { "Content-Type": "application/json" };
async function handle<T>(res: Response): Promise<T> {
if (res.status === 401) throw new ApiError("Not signed in", 401);
if (!res.ok) {
let detail = res.statusText;
try { detail = (await res.json()).detail ?? detail; } catch { /* keep statusText */ }
throw new ApiError(String(detail), res.status);
}
return res.json() as Promise<T>;
}
export class ApiError extends Error {
constructor(message: string, public status: number) { super(message); }
}
export const api = {
get: <T>(path: string) => fetch(path, { credentials: "same-origin" }).then(handle<T>),
post: <T>(path: string, body?: unknown) =>
fetch(path, {
method: "POST", headers: json, credentials: "same-origin",
body: body === undefined ? undefined : JSON.stringify(body),
}).then(handle<T>),
del: <T>(path: string) =>
fetch(path, { method: "DELETE", credentials: "same-origin" }).then(handle<T>),
};
/** Poll a background triage job until it finishes. */
export async function pollJob<T>(jobId: string, signal: AbortSignal): Promise<T> {
for (;;) {
if (signal.aborted) throw new DOMException("aborted", "AbortError");
const job = await api.get<{ state: string; result: T; error: string }>(`/api/jobs/${jobId}`);
if (job.state === "done") return job.result;
if (job.state === "error") throw new Error(job.error);
await new Promise((r) => setTimeout(r, 900));
}
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);

View File

@@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../lib/api";
interface Candidate { id: string; name: string; status: string; region: string }
interface BrokenLink {
instance_name: string; infrahub_status: string; organization: string; region: string;
reason: string; candidate: Candidate | null; candidate_claimed_by_other: boolean; confidence: string;
}
interface Orphan { id: string; name: string; status: string; region: string; host: string; name_known_to_infrahub: boolean }
interface Result {
scanned_regions: Record<string, number>; region_failures: Record<string, string>;
openstack_servers: number; infrahub_records: number; broken_links: BrokenLink[];
likely_linkage_failures: number; orphans: Orphan[]; orphan_total: number;
skipped_unscanned_regions?: number;
}
interface State { state: string; error: string; progress: string; age_seconds: number | null; result: Result }
export default function LinkagePage() {
const [scan, setScan] = useState<State | null>(null);
const [detail, setDetail] = useState<Record<number, string>>({});
const timer = useRef<number | null>(null);
const tick = useCallback(async () => {
const s = await api.get<State>("/api/linkage");
setScan(s);
if (s.state !== "running" && timer.current) { clearInterval(timer.current); timer.current = null; }
}, []);
useEffect(() => { void tick(); return () => { if (timer.current) clearInterval(timer.current); }; }, [tick]);
const start = async () => {
await api.post("/api/linkage/scan");
if (timer.current) clearInterval(timer.current);
timer.current = window.setInterval(() => void tick(), 2500);
void tick();
};
const r = scan?.result;
return (
<main style={{ padding: "22px 26px 80px", maxWidth: 1500 }}>
<div className="row">
<h3 className="sec" style={{ flex: 1 }}>Linkage scan</h3>
<span className="hint" style={{ margin: 0 }}>
{scan?.state === "running" ? `scanning… ${scan.progress}`
: scan?.age_seconds != null ? `last scan ${Math.round(scan.age_seconds / 60)}m ago` : ""}
</span>
<button className="pri" disabled={scan?.state === "running"} onClick={start}>Run scan</button>
</div>
<p style={{ color: "var(--dim)", maxWidth: "90ch" }}>
A VM in ERROR is not always a failed build. If the server was created but the link back to Infrahub was
never written, Infrahub shows ERROR or CREATING with no usable <code>openstack_id</code> while a perfectly
good server of the same name is running. This scans both sides in bulk and pairs them up by name and
finds the reverse too: OpenStack servers that no Infrahub record claims.
</p>
{scan?.state === "running" && (
<div className="empty"><span className="spin" /><br /><br />{scan.progress}<br />
<span className="hint">Listing every server across all regions a few minutes.</span></div>
)}
{scan?.state === "error" && <div className="warnbox t-bad">{scan.error}</div>}
{scan?.state === "done" && r && (
<>
<div className="row mt">
{[["likely linkage failures", r.likely_linkage_failures],
["records with a broken link", r.broken_links.length],
["OpenStack servers no record claims", r.orphan_total],
["servers scanned", r.openstack_servers],
["Infrahub records", r.infrahub_records]].map(([label, n]) => (
<div className="card" key={label as string} style={{ minWidth: 160, marginBottom: 0 }}>
<div style={{ fontSize: 23, fontWeight: 680, fontFamily: "var(--mono)" }}>{n as number}</div>
<div className="hint" style={{ margin: 0 }}>{label as string}</div>
</div>
))}
</div>
{Object.keys(r.region_failures).length > 0 && (
<div className="warnbox mt">
Some regions could not be listed:{" "}
{Object.entries(r.region_failures).map(([k, v]) => <span key={k}><b>{k}</b> ({v}) </span>)}.
Results exclude those regions entirely ({r.skipped_unscanned_regions ?? 0} record(s) skipped),
so nothing here is a false positive from a failed listing but the scan is not complete.
</div>
)}
<h3 className="sec" style={{ marginTop: 24 }}>Infrahub records whose OpenStack server is missing</h3>
<div className="card" style={{ padding: 0 }}>
{r.broken_links.length === 0 ? <div className="empty">None every record resolves.</div>
: r.broken_links.slice(0, 300).map((l, i) => (
<div key={i} style={{ padding: "9px 14px", borderTop: i ? "1px solid var(--line)" : undefined }}>
<div className="row">
<b style={{ flex: 1 }}>{l.instance_name}</b>
<span className="t-bad" style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{l.infrahub_status}</span>
<span className={`badge ${l.confidence === "high" ? "overdue" : ""}`}>{l.confidence}</span>
{l.candidate && (
<button className="sm" onClick={async () => {
const d = await api.post<any>("/api/linkage/enrich",
{ region: l.candidate!.region, openstack_id: l.candidate!.id });
setDetail({ ...detail, [i]: d.ok
? `created ${d.created} · status ${d.status} · host ${d.host || "—"} · fault: ${d.fault}`
: d.error });
}}>Details</button>
)}
</div>
<div className="hint" style={{ margin: 0 }}>
{l.organization} · {l.region} · {l.reason}
{l.candidate && <> OpenStack <code>{l.candidate.id}</code> {l.candidate.name} {l.candidate.status}
{l.candidate_claimed_by_other && <span className="t-warn"> · claimed by another record</span>}</>}
</div>
{detail[i] && <div className="hint" style={{ fontFamily: "var(--mono)" }}>{detail[i]}</div>}
</div>
))}
</div>
<h3 className="sec" style={{ marginTop: 24 }}>OpenStack servers with no Infrahub record</h3>
<div className="card" style={{ padding: 0 }}>
{r.orphans.length === 0 ? <div className="empty">None.</div>
: r.orphans.slice(0, 200).map((o, i) => (
<div key={o.id} className="row" style={{ padding: "7px 14px", borderTop: i ? "1px solid var(--line)" : undefined }}>
<b style={{ flex: 1 }}>{o.name || "(unnamed)"}</b>
<code style={{ fontSize: 11.5 }}>{o.id}</code>
<span style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{o.status}</span>
<span className="hint" style={{ margin: 0 }}>{o.host || "—"}</span>
<span className={o.name_known_to_infrahub ? "t-warn" : "t-bad"}>
{o.name_known_to_infrahub ? "name exists" : "unknown"}</span>
</div>
))}
</div>
</>
)}
</main>
);
}

View File

@@ -0,0 +1,57 @@
import { useState } from "react";
import { api } from "../lib/api";
import type { AppConfig } from "../types";
export default function Login({ config, onSignedIn }: {
config: AppConfig | null; onSignedIn: () => void;
}) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true); setError("");
try {
await api.post("/api/auth/login", { email, password });
onSignedIn();
} catch (err) {
setError(err instanceof Error ? err.message : "Sign-in failed");
} finally {
setBusy(false);
}
};
return (
<div className="card login">
<h2 style={{ marginTop: 0 }}>{config?.app_name ?? "CX Triage"}</h2>
{config?.oidc_enabled && (
<>
<a className="navlink" style={{ display: "block", textAlign: "center", padding: 10 }}
href="/api/auth/oidc/start">Sign in with SSO</a>
{config.local_login && <div className="hint" style={{ textAlign: "center" }}>or use a local account</div>}
</>
)}
{config?.local_login !== false && (
<form onSubmit={submit} style={{ marginTop: 14 }}>
<div className="fld">
<label>Email</label>
<input value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="username" />
</div>
<div className="fld">
<label>Password</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" />
</div>
{error && <div className="warnbox t-bad">{error}</div>}
<button className="pri" style={{ width: "100%" }} disabled={busy}>
{busy ? "Signing in…" : "Sign in"}
</button>
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,286 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, pollJob } from "../lib/api";
import type { AppConfig, CaseRef, Diagnosis, Queue as QueueData } from "../types";
import Visuals from "../components/Visuals";
import ActionDrawer from "../components/ActionDrawer";
import CasePanel from "../components/CasePanel";
const VERDICT_ORDER = ["overdue", "real", "unverified", "chronic", "low_impact",
"pending", "resolved", "rule_defect", "suppressed"];
const VERDICT_HELP: Record<string, string> = {
overdue: "Still true, and past the point where the runbook says to contact the customer.",
real: "Still true right now — re-checked against live Infrahub/OpenStack state.",
unverified: "Could not be re-checked, so it stays in the queue rather than being hidden on a guess.",
chronic: "Still true, but it has been for days — known rather than new work.",
low_impact: "Still true, but internally owned.",
pending: "Prometheus has not committed to this alert yet; it may clear on its own.",
resolved: "The condition no longer holds.",
rule_defect: "The alert rule itself is wrong, so the alert is not evidence of a problem.",
suppressed: "Hidden by a rule you configured in Settings.",
};
// Alerts Prometheus has not committed to are never shown unless asked for by name.
const HIDDEN_STATES = ["pending"];
function subjectOf(a: { kind: string; floating_ip: string; host: string; instance_name: string; openstack_id: string }) {
if (a.kind === "duplicate_ip") return a.floating_ip;
if (["rogue_vm", "total_gpus", "orphan_vm"].includes(a.kind)) return a.host;
return a.instance_name || a.openstack_id || "unknown";
}
export default function QueuePage({ config }: { config: AppConfig }) {
const [data, setData] = useState<QueueData | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [diagnosis, setDiagnosis] = useState<Diagnosis | null>(null);
const [kase, setKase] = useState<CaseRef | null>(null);
const [loadingCase, setLoadingCase] = useState(false);
const [verdictFilter, setVerdictFilter] = useState("actionable");
const [kindFilter, setKindFilter] = useState("all");
const [shut, setShut] = useState<Record<string, boolean>>({});
const [drawer, setDrawer] = useState<"zendesk" | "jira" | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback(async (force = false) => {
try { setData(await api.get<QueueData>(`/api/alerts${force ? "?force=1" : ""}`)); }
catch (err) { console.error(err); }
}, []);
useEffect(() => {
void load();
const t = setInterval(() => void load(), 60_000);
return () => clearInterval(t);
}, [load]);
// Each request supersedes the last: an abandoned diagnosis must never land on
// screen after the user has moved on to another case.
const openCase = useCallback(async (fingerprint: string, force = false) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setSelected(fingerprint);
setDiagnosis(null);
setKase(null);
setLoadingCase(true);
try {
const started = await api.post<{ job_id: string; case: CaseRef }>("/api/triage", { fingerprint, force });
if (controller.signal.aborted) return;
setKase(started.case);
const result = await pollJob<Diagnosis>(started.job_id, controller.signal);
if (!controller.signal.aborted) setDiagnosis(result);
} catch (err) {
if ((err as Error)?.name !== "AbortError") console.error(err);
} finally {
if (!controller.signal.aborted) setLoadingCase(false);
}
}, []);
const allAlerts = useMemo(() => (data?.groups ?? []).flatMap((g) => g.alerts), [data]);
const actionable = useMemo(
() => allAlerts.filter((a) => a.screen.actionable && !HIDDEN_STATES.includes(a.state)).length,
[allAlerts]);
const visible = useCallback((a: (typeof allAlerts)[number]) => {
if (kindFilter !== "all" && a.kind !== kindFilter) return false;
if (HIDDEN_STATES.includes(a.state) && verdictFilter !== a.state) return false;
if (verdictFilter === "all") return true;
if (verdictFilter === "actionable") return a.screen.actionable;
return a.screen.verdict === verdictFilter;
}, [kindFilter, verdictFilter]);
if (!data) return <div className="empty"><span className="spin" /></div>;
const counts: Record<string, number> = {};
allAlerts.forEach((a) => { counts[a.screen.verdict] = (counts[a.screen.verdict] ?? 0) + 1; });
const byKind: Record<string, number> = {};
allAlerts.forEach((a) => { byKind[a.kind] = (byKind[a.kind] ?? 0) + 1; });
const zAction = diagnosis?.integrations?.actions?.find((x) => x.kind === "zendesk");
const jAction = diagnosis?.integrations?.actions?.find((x) => x.kind === "jira");
const manual = diagnosis?.integrations?.actions?.filter((x) => x.kind === "manual") ?? [];
return (
<>
<div className="filters">
<div className="frow">
<span className="flab">Status</span>
<div className="chips">
<span className={`chip${verdictFilter === "actionable" ? " on" : ""}`}
onClick={() => setVerdictFilter("actionable")}>To action <b>{actionable}</b></span>
{VERDICT_ORDER.filter((v) => counts[v]).map((v) => (
<span key={v} className={`chip ${v}${verdictFilter === v ? " on" : ""}`}
title={VERDICT_HELP[v]} onClick={() => setVerdictFilter(v)}>
{data.summary.labels[v] ?? v} <b>{counts[v]}</b>
</span>
))}
<span className={`chip${verdictFilter === "all" ? " on" : ""}`}
title="Everything except alerts Prometheus has not committed to yet"
onClick={() => setVerdictFilter("all")}>All firing</span>
</div>
</div>
<div className="frow">
<span className="flab">Type</span>
<div className="chips">
<span className={`chip${kindFilter === "all" ? " on" : ""}`}
onClick={() => setKindFilter("all")}>All types</span>
{data.groups.map((g) => (
<span key={g.kind} className={`chip${kindFilter === g.kind ? " on" : ""}`}
onClick={() => setKindFilter(g.kind)}>
{g.title.replace(/^Instance in /, "").replace(/ state$/, "")} <b>{byKind[g.kind] ?? 0}</b>
</span>
))}
</div>
</div>
</div>
{data.warnings.map((w, i) => <div key={i} className="warnbox" style={{ margin: "10px 18px" }}>{w}</div>)}
<div className="split">
<aside className="rail">
{data.groups.map((g) => {
const rows = g.alerts.filter(visible);
if (!rows.length) return null;
const hot = rows.filter((a) => a.screen.actionable).length;
const closed = shut[g.kind];
return (
<div key={g.kind}>
<div className="grp-h" onClick={() => setShut({ ...shut, [g.kind]: !closed })}>
<span>{closed ? "▸" : "▾"}</span>{g.title}
<span className={`n${hot ? " hot" : ""}`} title={`${rows.length} shown of ${g.total} firing`}>
{rows.length}{rows.length < g.total && <span style={{ opacity: .6 }}> of {g.total}</span>}
</span>
</div>
{!closed && rows.map((a) => (
<div key={a.id} className={`case${selected === a.id ? " on" : ""}${a.screen.actionable ? "" : " off"}`}
onClick={() => void openCase(a.id)}>
<span className={`bar ${a.screen.verdict}`} title={a.screen.label} />
<span className="mid">
<span className="nm">{subjectOf(a)}</span>
<span className="sub">{[a.org_name, a.region_label || a.region].filter(Boolean).join(" · ")}</span>
{a.case && a.case.status !== "new" && (
<span className="sub"><span className="badge">{a.case.status.replace(/_/g, " ")}</span></span>
)}
</span>
<span className="ag">{a.effective_age_text}</span>
</div>
))}
</div>
);
})}
</aside>
<section className="stage">
{!selected && <div className="empty">Select a case.</div>}
{selected && loadingCase && !diagnosis && (
<div className="empty"><span className="spin" /><br /><br />
Checking Infrahub, OpenStack and InfraInsight</div>
)}
{diagnosis && (
<>
<div className="card hero">
<div className="crumb">
<span className={`badge ${diagnosis.alert.screen.verdict}`}
title={VERDICT_HELP[diagnosis.alert.screen.verdict]}>
{diagnosis.alert.screen.label}</span>
<span>{diagnosis.alert.title}</span><span>·</span>
<span>{diagnosis.alert.region_label || diagnosis.alert.region}</span><span>·</span>
<span>held {diagnosis.alert.effective_age_text}</span><span>·</span>
<span>{diagnosis.alert.state} in prometheus</span>
</div>
<div className="vd">{diagnosis.verdict || diagnosis.error}</div>
{diagnosis.assessment && <div className="vsub">{diagnosis.assessment}</div>}
<Visuals v={diagnosis.visual} />
</div>
{kase && <CasePanel kase={kase} onChange={setKase} />}
<div className="card">
<h3 className="sec">Do this</h3>
{!diagnosis.alert.screen.actionable && (
<div className="hint">
Screened out ({diagnosis.alert.screen.label}). {diagnosis.alert.screen.reason}
</div>
)}
{zAction && (
<div className="row mt">
<span style={{ color: "var(--dim)" }}>Customer</span>
<b style={{ fontFamily: "var(--mono)", fontSize: 12.5 }}>
{zAction.recipients[0] ?? "unresolved"}</b>
{diagnosis.alert.org_name && <span className="badge">{diagnosis.alert.org_name}</span>}
</div>
)}
<div className="row mt">
{zAction && <button className="pri" onClick={() => setDrawer("zendesk")}>
Contact customer via Zendesk</button>}
{jAction && <button onClick={() => setDrawer("jira")}>
Escalate to Infrastructure (Jira)</button>}
{!diagnosis.alert.screen.actionable && (
<button className="sm" onClick={() => void openCase(diagnosis.alert.id, true)}>
Re-run full diagnosis</button>
)}
{!zAction && !jAction && !manual.length && (
<span className="hint" style={{ margin: 0 }}>
No outbound action for this case the runbook keeps it internal.</span>
)}
</div>
{manual.map((m, i) => (
<div className="cmd" key={i}>
<code>{m.payload?.command ?? m.label}</code>
{m.payload?.command && (
<button className="sm"
onClick={() => navigator.clipboard.writeText(m.payload.command)}>Copy</button>
)}
</div>
))}
{manual.length > 0 && (
<div className="hint">Run these yourself CX Triage never mutates the platform.</div>
)}
</div>
{diagnosis.notes.length > 0 && (
<details className="fold"><summary>Caveats ({diagnosis.notes.length})</summary>
<div className="foldb"><ul style={{ margin: 0, paddingLeft: 18, color: "var(--warn)" }}>
{diagnosis.notes.map((n, i) => <li key={i}>{n}</li>)}</ul></div>
</details>
)}
<details className="fold"><summary>Why what the platforms say</summary>
<div className="foldb"><table><tbody>
{diagnosis.findings.map((f, i) => (
<tr key={i}><td className="k">{f.label}</td>
<td className={`v t-${f.tone}`}>{f.value}
{f.detail && <span className="det">{f.detail}</span>}</td></tr>
))}
</tbody></table></div>
</details>
<details className="fold"><summary>Runbook steps ({diagnosis.actions.length})</summary>
<div className="foldb"><ol style={{ margin: 0, paddingLeft: 18, fontSize: 13 }}>
{diagnosis.actions.map((a, i) => (
<li key={i} style={{ marginBottom: 7 }}>{a.text}
<span className="badge" style={{ marginLeft: 6 }}>{a.owner}</span>
{a.guide && <span className="det">Guide: {a.guide}</span>}</li>
))}
</ol></div>
</details>
<details className="fold"><summary>Raw evidence</summary>
<div className="foldb"><pre>{JSON.stringify(
{ screen: diagnosis.alert.screen, labels: diagnosis.alert.labels,
evidence: diagnosis.evidence }, null, 2)}</pre></div>
</details>
</>
)}
</section>
</div>
{drawer && diagnosis && (
<ActionDrawer kind={drawer} diagnosis={diagnosis} sendEnabled={config.send_enabled}
action={(drawer === "zendesk" ? zAction : jAction)!}
onClose={() => setDrawer(null)}
onDone={() => { void load(true); if (selected) void openCase(selected); }} />
)}
</>
);
}

View File

@@ -0,0 +1,172 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../lib/api";
import type { AppConfig, User } from "../types";
interface Rule {
id: string; name: string; reason: string; enabled: boolean;
conditions: Record<string, string[]>; created_by?: string;
}
interface Config {
rules: Rule[]; conditions: Record<string, string>;
agent_name: string; chronic_days: number; config: AppConfig;
}
const blank = (): Rule => ({ id: "", name: "", reason: "", enabled: true, conditions: {} });
export default function SettingsPage({ user, config }: { user: User; config: AppConfig }) {
const [cfg, setCfg] = useState<Config | null>(null);
const [editing, setEditing] = useState<Rule | null>(null);
const [rows, setRows] = useState<{ field: string; values: string }[]>([]);
const [preview, setPreview] = useState<{ count: number; matches: any[] } | null>(null);
const [saved, setSaved] = useState("");
const load = useCallback(async () => setCfg(await api.get<Config>("/api/settings")), []);
useEffect(() => { void load(); }, [load]);
const flash = (m: string) => { setSaved(m); setTimeout(() => setSaved(""), 2200); };
const startEdit = (rule: Rule) => {
setEditing(rule);
const entries = Object.entries(rule.conditions ?? {});
setRows(entries.length ? entries.map(([f, v]) => ({ field: f, values: v.join(", ") }))
: [{ field: "kind", values: "" }]);
setPreview(null);
};
const collect = (): Rule => ({
...editing!,
conditions: rows.reduce<Record<string, string[]>>((acc, r) => {
const vals = r.values.split(",").map((v) => v.trim()).filter(Boolean);
if (vals.length) acc[r.field] = [...(acc[r.field] ?? []), ...vals];
return acc;
}, {}),
});
if (!cfg) return <div className="empty"><span className="spin" /></div>;
return (
<main style={{ padding: "22px 26px 80px", maxWidth: 1040 }}>
<h3 className="sec">Suppression rules {saved && <span className="t-ok">· {saved}</span>}</h3>
<p style={{ color: "var(--dim)", maxWidth: "82ch" }}>
Hide alerts you already know about. A rule fires when <b>every</b> condition it sets matches, so you
can combine them for example type <code>error</code> <i>and</i> organisation containing <code>modal</code>.
Suppressed alerts are not deleted: they stay reachable under the <b>hidden by a rule</b> filter.
</p>
{cfg.rules.map((r) => (
<div className="card" key={r.id} style={{ opacity: r.enabled ? 1 : 0.55 }}>
<div className="row">
<input type="checkbox" checked={r.enabled} style={{ width: "auto" }}
disabled={!user.is_admin}
onChange={async (e) => {
await api.post("/api/settings/rules", { ...r, enabled: e.target.checked });
await load(); flash(e.target.checked ? "Rule enabled" : "Rule disabled");
}} />
<b style={{ flex: 1 }}>{r.name}</b>
{user.is_admin && <>
<button className="sm" onClick={() => startEdit(r)}>Edit</button>
<button className="sm del" onClick={async () => {
if (!confirm(`Delete “${r.name}”? Alerts it was hiding will come back.`)) return;
await api.del(`/api/settings/rules/${r.id}`); await load(); flash("Rule deleted");
}}>Delete</button>
</>}
</div>
{r.reason && <div style={{ color: "var(--dim)", fontSize: 12.5 }}>{r.reason}</div>}
<div className="row mt">
{Object.entries(r.conditions).map(([k, v], i) => (
<span key={k}>
{i > 0 && <span style={{ color: "var(--faint)", fontSize: 10.5 }}> AND </span>}
<span className="badge"><b>{cfg.conditions[k] ?? k}</b> {v.join(" or ")}</span>
</span>
))}
</div>
</div>
))}
{user.is_admin && !editing && (
<button className="pri" onClick={() => startEdit(blank())}>Add a rule</button>
)}
{editing && (
<div className="card" style={{ borderColor: "var(--accent)" }}>
<div className="fld"><label>Rule name</label>
<input value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} /></div>
<div className="fld"><label>Why (shown on the alert)</label>
<input value={editing.reason} onChange={(e) => setEditing({ ...editing, reason: e.target.value })} /></div>
<label style={{ fontSize: 11, textTransform: "uppercase", color: "var(--faint)" }}>
Conditions all must match</label>
{rows.map((r, i) => (
<div className="row mt" key={i}>
<select value={r.field} style={{ maxWidth: 200 }}
onChange={(e) => setRows(rows.map((x, j) => j === i ? { ...x, field: e.target.value } : x))}>
{Object.entries(cfg.conditions).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
<input value={r.values} placeholder="comma-separated; any one matches" style={{ flex: 1 }}
onChange={(e) => setRows(rows.map((x, j) => j === i ? { ...x, values: e.target.value } : x))} />
<button className="sm del" onClick={() => setRows(rows.filter((_, j) => j !== i))}>×</button>
</div>
))}
<button className="sm mt" onClick={() => setRows([...rows, { field: "organization", values: "" }])}>
+ Add condition</button>
{preview && (
<div className="warnbox mt">
<b className={preview.count ? "t-warn" : "t-ok"}>{preview.count}</b> currently-firing alert(s) would be hidden.
<ul style={{ maxHeight: 170, overflowY: "auto", marginTop: 6 }}>
{preview.matches.slice(0, 40).map((m, i) => (
<li key={i}>{m.title} {m.instance_name || m.host} <span style={{ color: "var(--faint)" }}>{m.org_name}</span></li>
))}
</ul>
</div>
)}
<div className="row mt">
<button className="pri" onClick={async () => {
try {
await api.post("/api/settings/rules", collect());
setEditing(null); await load(); flash("Rule saved");
} catch (err) { alert(err instanceof Error ? err.message : "Save failed"); }
}}>Save rule</button>
<button onClick={async () => setPreview(
await api.post("/api/settings/rules/preview", collect()))}>Preview what this hides</button>
<button onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
)}
<h3 className="sec" style={{ marginTop: 28 }}>Comms identity</h3>
<div className="card">
<div className="fld"><label>Sign-off name</label>
<input defaultValue={cfg.agent_name} placeholder="e.g. Mohammad Affan"
onBlur={async (e) => { await api.post("/api/settings/general", { agent_name: e.target.value }); flash("Saved"); }} />
<div className="hint">Appended after Kind Regards in customer emails.</div></div>
<div className="fld"><label>Chronic after (days)</label>
<input type="number" min={1} max={90} defaultValue={cfg.chronic_days} style={{ maxWidth: 120 }}
onBlur={async (e) => { await api.post("/api/settings/general", { chronic_days: Number(e.target.value) }); flash("Saved"); }} />
<div className="hint">Types with a runbook time commitment become <b>overdue</b> instead.</div></div>
</div>
<h3 className="sec" style={{ marginTop: 28 }}>Integrations</h3>
<div className="card">
<table><tbody>
<tr><td className="k">Zendesk</td><td className="v">
{config.zendesk_ready ? <span className="t-ok">configured</span>
: <span className="t-warn">not configured</span>}</td></tr>
<tr><td className="k">Jira</td><td className="v">
{config.jira_ready ? <span className="t-ok">configured ({config.jira_project})</span>
: <span className="t-warn">not configured</span>}</td></tr>
<tr><td className="k">Sending</td><td className="v">
{config.send_enabled ? <span className="t-ok">enabled</span>
: <span className="t-warn">disabled nothing can leave this instance</span>}</td></tr>
<tr><td className="k">SSO</td><td className="v">
{config.oidc_enabled ? "Authentik" : "local accounts only"}</td></tr>
</tbody></table>
<div className="hint">
These come from the environment see <code>docs/INTEGRATIONS.md</code>. They are deliberately not
editable here so a UI bug cannot switch on customer email.
</div>
</div>
</main>
);
}

164
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,164 @@
:root {
--bg:#0d1017; --panel:#141821; --panel2:#1b202b; --line:#262d3a; --line2:#333b4a;
--fg:#e8ebf2; --dim:#8a93a5; --faint:#5d6675; --accent:#4c8dff;
--ok:#35c46a; --warn:#e0a336; --bad:#f2545b; --info:#59a0f5; --violet:#a97bf0;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; --r:10px;
}
@media (prefers-color-scheme:light){:root{
--bg:#f4f6f9;--panel:#fff;--panel2:#f1f4f8;--line:#e0e5ec;--line2:#cfd6e0;
--fg:#151a22;--dim:#5b6473;--faint:#8e97a5;--accent:#1f6feb;
--ok:#12864a;--warn:#96650a;--bad:#cf2530;--info:#0969da;--violet:#7a44d6;}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
a{color:var(--accent)}
button{font:inherit;font-size:13px;padding:7px 13px;border-radius:8px;border:1px solid var(--line2);
background:var(--panel2);color:var(--fg);cursor:pointer;transition:.12s}
button:hover:not(:disabled){border-color:var(--accent)}
button:disabled{opacity:.45;cursor:not-allowed}
button.pri{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
button.sm{padding:4px 9px;font-size:12px}
button.del{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent)}
input,select,textarea{font:inherit;font-size:13px;padding:7px 10px;border-radius:7px;
border:1px solid var(--line2);background:var(--bg);color:var(--fg);width:100%}
textarea{min-height:220px;resize:vertical;line-height:1.55}
header.top{display:flex;align-items:center;gap:14px;padding:9px 18px;background:var(--panel);
border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20;flex-wrap:wrap}
.brand{font-weight:680;font-size:14px}
.brand em{font-style:normal;color:var(--faint);font-weight:400;font-size:12.5px}
.navlink{font-size:12.5px;text-decoration:none;border:1px solid var(--line2);
padding:4px 10px;border-radius:7px;color:var(--accent)}
.navlink.on{background:var(--accent);border-color:var(--accent);color:#fff}
.spacer{margin-left:auto}
.who{font-size:12px;color:var(--dim)}
.filters{background:var(--panel);border-bottom:1px solid var(--line);padding:7px 18px;
display:flex;flex-direction:column;gap:5px;position:sticky;top:51px;z-index:19}
.frow{display:flex;align-items:center;gap:9px}
.flab{font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);width:44px;flex:none}
.chips{display:flex;gap:6px;flex-wrap:wrap}
.chip{font-size:11.5px;padding:3px 10px;border-radius:99px;border:1px solid var(--line2);
background:var(--panel2);color:var(--dim);cursor:pointer;white-space:nowrap;user-select:none}
.chip.on{background:var(--accent);border-color:var(--accent);color:#fff}
.chip.overdue:not(.on){color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent)}
.split{display:grid;grid-template-columns:320px 1fr;height:calc(100vh - 118px)}
@media(max-width:940px){.split{grid-template-columns:1fr}}
.rail{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto}
.stage{overflow-y:auto;padding:22px 26px 60px}
.grp-h{display:flex;align-items:center;gap:8px;padding:8px 14px;cursor:pointer;background:var(--panel2);
user-select:none;font-size:12px;letter-spacing:.3px;text-transform:uppercase;color:var(--dim);
border-bottom:1px solid var(--line)}
.grp-h:hover{color:var(--fg)}
.grp-h .n{margin-left:auto;font-size:11px;padding:1px 7px;border-radius:99px;background:var(--bg);
color:var(--dim);text-transform:none}
.grp-h .n.hot{background:var(--bad);color:#fff}
.case{padding:9px 14px;border-bottom:1px solid var(--line);cursor:pointer;display:flex;gap:9px;align-items:flex-start}
.case:hover{background:var(--panel2)}
.case.on{background:color-mix(in srgb,var(--accent) 15%,transparent);box-shadow:inset 3px 0 var(--accent)}
.case.off{opacity:.55}
.case .bar{width:3px;align-self:stretch;border-radius:2px;background:var(--faint);flex:none}
.bar.overdue{background:var(--bad)} .bar.real{background:var(--warn)} .bar.unverified{background:var(--info)}
.bar.rule_defect{background:var(--violet)} .bar.resolved{background:var(--ok)}
.bar.pending{background:var(--info);opacity:.5}
.case .mid{min-width:0;flex:1}
.case .nm{font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.case .sub{color:var(--dim);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.case .ag{font-family:var(--mono);font-size:11px;color:var(--faint);flex:none}
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:16px 18px;margin-bottom:14px}
.hero{padding:20px 22px}
.crumb{font-size:11.5px;color:var(--faint);letter-spacing:.3px;text-transform:uppercase;margin-bottom:9px;
display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.vd{font-size:20px;font-weight:660;line-height:1.3}
.vsub{color:var(--dim);margin-top:7px;font-size:13.5px;max-width:80ch}
h3.sec{margin:0 0 4px;font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim)}
.badge{font-size:11px;padding:2px 9px;border-radius:99px;border:1px solid var(--line2);color:var(--dim)}
.badge.overdue{background:var(--bad);border-color:var(--bad);color:#fff;font-weight:600}
.badge.real{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 50%,transparent)}
.badge.rule_defect{color:var(--violet);border-color:color-mix(in srgb,var(--violet) 50%,transparent)}
.badge.resolved,.badge.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.mt{margin-top:12px}
.states{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:16px}
.sbox{flex:1;min-width:150px;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:11px 14px}
.sbox .lbl{font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint)}
.sbox .val{font-family:var(--mono);font-size:16px;font-weight:600;margin-top:3px}
.sbox.bad{border-color:color-mix(in srgb,var(--bad) 45%,transparent)} .sbox.bad .val{color:var(--bad)}
.sbox.ok .val{color:var(--ok)}
.eqlink{font-size:20px;color:var(--faint)} .eqlink.bad{color:var(--bad)}
.slots{display:flex;gap:4px;margin-top:14px;flex-wrap:wrap}
.slot{flex:1 1 78px;min-width:70px;height:52px;border-radius:7px;border:1px solid var(--line2);
display:flex;flex-direction:column;justify-content:center;padding:5px 7px;overflow:hidden}
.slot .sn{font-size:10.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.slot .ss{font-size:9px;text-transform:uppercase;letter-spacing:.4px;opacity:.75;margin-top:1px}
.slot .idx{font-size:9px;color:var(--faint)}
.slot.vm{background:color-mix(in srgb,var(--ok) 22%,transparent);border-color:color-mix(in srgb,var(--ok) 55%,transparent)}
.slot.vm.bad{background:color-mix(in srgb,var(--bad) 20%,transparent);border-color:color-mix(in srgb,var(--bad) 55%,transparent)}
.slot.vm.unlinked{background:color-mix(in srgb,var(--bad) 28%,transparent);border-color:var(--bad)}
.slot.unaccounted{background:color-mix(in srgb,var(--bad) 16%,transparent);border-style:dashed;
border-color:color-mix(in srgb,var(--bad) 50%,transparent);align-items:center;justify-content:center;color:var(--bad)}
.slot.free{border-style:dashed;color:var(--faint);align-items:center;justify-content:center}
.legend{display:flex;gap:16px;margin-top:8px;font-size:12px;color:var(--dim);flex-wrap:wrap}
.legend i{width:9px;height:9px;border-radius:2px;display:inline-block;margin-right:5px}
.roster{margin-top:10px;border:1px solid var(--line);border-radius:9px;overflow:hidden}
.rrow{display:grid;grid-template-columns:1fr 118px 26px 118px 52px;gap:8px;align-items:center;
padding:7px 12px;border-top:1px solid var(--line);font-size:12.5px}
.rrow:first-child{border-top:none;background:var(--panel2);font-size:10.5px;text-transform:uppercase;
letter-spacing:.5px;color:var(--faint)}
.rrow .rn{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
.rrow .rs{font-family:var(--mono);font-size:12px}
.rrow .eq{text-align:center;color:var(--ok)}
.rrow.bad .eq,.rrow.bad .rs{color:var(--bad)}
.rrow.unlinked{background:color-mix(in srgb,var(--bad) 9%,transparent)}
.rrow .rg{text-align:right;color:var(--faint);font-family:var(--mono);font-size:11.5px}
details.fold{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);margin-bottom:10px}
details.fold>summary{cursor:pointer;padding:11px 18px;font-size:12px;text-transform:uppercase;
letter-spacing:.5px;color:var(--dim);list-style:none}
details.fold>summary::-webkit-details-marker{display:none}
details.fold>summary::before{content:"▸ ";font-size:10px;color:var(--faint)}
details.fold[open]>summary::before{content:"▾ "}
.foldb{padding:2px 18px 16px}
table{width:100%;border-collapse:collapse}
td{padding:5px 6px;border-bottom:1px solid var(--line);vertical-align:top;font-size:12.5px}
tr:last-child td{border-bottom:none}
td.k{color:var(--dim);width:180px;white-space:nowrap}
td.v{font-family:var(--mono);word-break:break-word}
.t-ok{color:var(--ok)} .t-warn{color:var(--warn)} .t-bad{color:var(--bad)}
.det{display:block;color:var(--faint);font-family:inherit;font-size:11.5px;margin-top:2px}
pre{background:var(--bg);border:1px solid var(--line);padding:10px;border-radius:7px;
overflow-x:auto;font-size:11.5px;margin:0;white-space:pre-wrap}
.cmd{display:flex;gap:8px;align-items:center;background:var(--bg);border:1px solid var(--line);
border-radius:7px;padding:7px 10px;margin-top:8px;font-family:var(--mono);font-size:12.5px}
.cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap}
.hint{font-size:12px;color:var(--faint);margin-top:9px}
.warnbox{border:1px solid color-mix(in srgb,var(--warn) 50%,transparent);
background:color-mix(in srgb,var(--warn) 11%,transparent);border-radius:8px;padding:10px 12px;
font-size:12.5px;margin-bottom:14px}
.empty{color:var(--faint);text-align:center;padding:60px 20px}
.spin{width:15px;height:15px;border:2px solid var(--line2);border-top-color:var(--accent);
border-radius:50%;display:inline-block;animation:sp .7s linear infinite;vertical-align:-3px}
@keyframes sp{to{transform:rotate(360deg)}}
.scrim{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:40}
.drawer{position:fixed;top:0;right:0;height:100%;width:min(640px,95vw);background:var(--panel);
border-left:1px solid var(--line);z-index:50;display:flex;flex-direction:column}
.dh{padding:15px 20px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px}
.dh h2{margin:0;font-size:15px;flex:1}
.db{padding:18px 20px;overflow-y:auto;flex:1}
.df{padding:14px 20px;border-top:1px solid var(--line);display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.fld{margin-bottom:14px}
.fld label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);margin-bottom:5px}
.timeline{list-style:none;margin:0;padding:0}
.timeline li{padding:8px 0 8px 16px;border-left:2px solid var(--line);position:relative;font-size:12.5px}
.timeline li::before{content:"";position:absolute;left:-5px;top:13px;width:8px;height:8px;
border-radius:50%;background:var(--line2)}
.timeline .ts{color:var(--faint);font-size:11px}
.login{max-width:380px;margin:12vh auto;padding:26px}

139
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,139 @@
export type Verdict =
| "overdue" | "real" | "unverified" | "chronic"
| "low_impact" | "pending" | "resolved" | "rule_defect" | "suppressed";
export interface Screen {
verdict: Verdict;
label: string;
reason: string;
actionable: boolean;
current_state?: string;
detail?: string;
}
export interface CaseRef {
id: number;
fingerprint: string;
status: string;
is_open: boolean;
assignee: { id: number; email: string; name: string } | null;
zendesk_ticket_id: string;
zendesk_ticket_url: string;
jira_issue_key: string;
jira_issue_url: string;
notes: string;
reopen_count: number;
events?: CaseEvent[];
}
export interface CaseEvent {
id: number;
action: string;
detail: string;
actor: string;
created_at: string;
payload?: Record<string, unknown> | null;
}
export interface Alert {
id: string;
kind: string;
title: string;
state: string;
priority: string;
ettr: string;
effective_age_text: string;
age_text: string;
age_is_reset: boolean;
openstack_id: string;
instance_name: string;
host: string;
region: string;
region_label: string;
status: string;
floating_ip: string;
org_id: string;
org_name: string;
is_kubernetes: boolean;
labels: Record<string, string>;
annotations: Record<string, string>;
screen: Screen;
case: CaseRef | null;
}
export interface Group {
kind: string;
title: string;
total: number;
actionable: number;
noise: number;
alerts: Alert[];
}
export interface Queue {
error: string;
warnings: string[];
totals: { prometheus: number; cx: number; infrastructure: number; excluded: number };
excluded_note: string;
summary: { counts: Record<string, number>; actionable: number; screened_out: number; labels: Record<string, string> };
groups: Group[];
infrastructure: { source: string; label: string; total: number; by_alertname: { name: string; count: number }[] }[];
}
export interface Finding { label: string; value: string; tone: string; detail: string }
export interface ActionStep { text: string; owner: string; kind: string; guide: string; status: string; detail: string }
export interface Draft {
template_id: string; label: string; subject: string; body: string;
channel: string; when: string; unfilled: string[]; source: string;
}
export interface GpuSlot {
kind: "vm" | "unaccounted" | "free";
name?: string; linked?: boolean; match?: boolean;
ih_status?: string; os_status?: string;
}
export interface RosterRow {
name: string; openstack_id: string; infrahub_id: string;
ih_status: string; os_status: string; gpus: string;
linked: boolean; match: boolean; tempest: boolean; org: string;
}
export interface Visual {
type?: "states" | "gpu" | "claimants";
infrahub?: string; openstack?: string; task?: string; match?: boolean;
host?: string; name?: string; flavor?: string; gpus?: string; fault?: string; never_built?: boolean;
physical?: number | null; in_use_metric?: number | null; accounted?: number;
gap?: number; instances?: number; spare_capacity_artifact?: boolean;
slots?: GpuSlot[]; roster?: RosterRow[];
ip?: string; items?: { name: string; ih_status: string; os_status: string; verdict: string }[];
}
export interface IntegrationAction {
id: string; kind: "zendesk" | "jira" | "manual"; label: string; summary: string;
payload: any; recipients: string[]; enabled: boolean; blocked_reason: string;
}
export interface Diagnosis {
alert: Alert;
verdict: string;
assessment: string;
confidence: string;
findings: Finding[];
actions: ActionStep[];
drafts: Draft[];
contacts: { organization: string; owners: string[]; resolved: boolean };
evidence: Record<string, unknown>;
notes: string[];
error: string;
visual: Visual;
integrations: { actions: IntegrationAction[]; zendesk_configured: boolean; jira_configured: boolean };
elapsed_seconds?: number;
}
export interface User { id: number; email: string; name: string; is_admin: boolean; provider: string; signoff_name: string }
export interface AppConfig {
app_name: string; oidc_enabled: boolean; local_login: boolean;
zendesk_ready: boolean; jira_ready: boolean; send_enabled: boolean;
linkage_scan: boolean; jira_project: string;
}

19
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"noEmit": true
},
"include": ["src"]
}

19
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// In development Vite serves the app and proxies the API to the Python backend,
// so the two can be worked on independently. In production the built bundle is
// copied into the backend image and served from the same origin.
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: process.env.VITE_API_TARGET ?? "http://localhost:8080",
changeOrigin: true,
},
},
},
build: { outDir: "dist", sourcemap: false },
});