Split into a FastAPI backend and a React frontend, add case state and SSO
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:
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
10
frontend/Dockerfile
Normal file
10
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
24
frontend/package.json
Normal 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
61
frontend/src/App.tsx
Normal 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>— 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
139
frontend/src/components/ActionDrawer.tsx
Normal file
139
frontend/src/components/ActionDrawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/CasePanel.tsx
Normal file
83
frontend/src/components/CasePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/Visuals.tsx
Normal file
135
frontend/src/components/Visuals.tsx
Normal 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
37
frontend/src/lib/api.ts
Normal 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
13
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
134
frontend/src/pages/Linkage.tsx
Normal file
134
frontend/src/pages/Linkage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/pages/Login.tsx
Normal file
57
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
286
frontend/src/pages/Queue.tsx
Normal file
286
frontend/src/pages/Queue.tsx
Normal 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); }} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
172
frontend/src/pages/Settings.tsx
Normal file
172
frontend/src/pages/Settings.tsx
Normal 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
164
frontend/src/styles.css
Normal 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
139
frontend/src/types.ts
Normal 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
19
frontend/tsconfig.json
Normal 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
19
frontend/vite.config.ts
Normal 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 },
|
||||
});
|
||||
Reference in New Issue
Block a user