Fas 1a punkt 2: release_gates tabell + go/no-go-vy

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 23:10:09 +07:00
parent 1250640b1d
commit d1455fb581
16 changed files with 10105 additions and 0 deletions
+3
View File
@@ -9,6 +9,7 @@ import { FlagsPage } from "./pages/Flags.js";
import { SubscriptionsPage } from "./pages/Subscriptions.js";
import { AuditLogsPage } from "./pages/AuditLogs.js";
import { AnalyticsPage } from "./pages/Analytics.js";
import { ReleaseGatesPage } from "./pages/ReleaseGates.js";
export function App() {
const [authed, setAuthed] = useState(hasToken());
@@ -30,6 +31,7 @@ export function App() {
<NavLink to="/subscriptions">Prenumerationer</NavLink>
<NavLink to="/audit">Audit logs</NavLink>
<NavLink to="/analytics">Analytics</NavLink>
<NavLink to="/release-gates">Release gates</NavLink>
<button
onClick={() => {
setToken(null);
@@ -48,6 +50,7 @@ export function App() {
<Route path="/subscriptions" element={<SubscriptionsPage />} />
<Route path="/audit" element={<AuditLogsPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/release-gates" element={<ReleaseGatesPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
+220
View File
@@ -0,0 +1,220 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../api.js";
type GateStatus = "pending" | "passed" | "failed" | "blocked" | "not_measurable";
interface Gate {
gateKey: string;
category: string;
nameSv: string;
nameEn: string;
targetValue: number;
comparison: "gte" | "lte" | "eq";
lastValue: number | null;
lastEvaluatedAt: string | null;
status: GateStatus;
blocking: boolean;
evaluationWindowDays: number;
notes: string | null;
}
interface Summary {
overall: "go" | "no_go" | "pending";
passed: number;
failed: number;
blocked: number;
pending: number;
notMeasurable: number;
}
function statusLabel(s: GateStatus): string {
switch (s) {
case "passed":
return "Godkänd";
case "failed":
return "Ej uppnådd";
case "blocked":
return "Blockerad";
case "not_measurable":
return "Ej mätbar";
default:
return "Väntar";
}
}
function statusClass(s: GateStatus): string {
switch (s) {
case "passed":
return "ok";
case "failed":
return "err";
case "blocked":
return "err";
case "not_measurable":
return "warn";
default:
return "";
}
}
function formatPct(n: number | null): string {
if (n === null || Number.isNaN(n)) return "";
return `${(n * 100).toFixed(1)}%`;
}
export function ReleaseGatesPage() {
const [gates, setGates] = useState<Gate[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [category, setCategory] = useState<string>("all");
const load = () => {
setLoading(true);
api<{ gates: Gate[]; summary: Summary }>("/admin/v1/release-gates")
.then((res) => {
setGates(res.gates);
setSummary(res.summary);
setError(null);
})
.catch((e) => setError(String(e.message)))
.finally(() => setLoading(false));
};
useEffect(() => {
load();
}, []);
const evaluate = async () => {
setLoading(true);
try {
const res = await api<{ gates: Gate[]; summary: Summary }>(
"/admin/v1/release-gates/evaluate",
{ method: "POST" },
);
setGates(res.gates);
setSummary(res.summary);
setError(null);
} catch (e) {
setError(String((e as Error).message));
} finally {
setLoading(false);
}
};
const seed = async () => {
setLoading(true);
try {
await api("/admin/v1/release-gates/seed", { method: "POST" });
load();
} catch (e) {
setError(String((e as Error).message));
} finally {
setLoading(false);
}
};
const categories = useMemo(
() => Array.from(new Set(gates.map((g) => g.category))),
[gates],
);
const filtered = useMemo(
() => (category === "all" ? gates : gates.filter((g) => g.category === category)),
[gates, category],
);
return (
<div>
<h2>Release gates</h2>
{error && <div className="error-text">{error}</div>}
<div className="panel">
<div className="form-row">
<button onClick={evaluate} disabled={loading}>
Utvärdera nu
</button>
<button onClick={seed} disabled={loading}>
Återställ grundvärden
</button>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">Alla kategorier</option>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
{summary && (
<div className="stat-grid" style={{ marginTop: "1rem" }}>
<div className="stat">
<div className="value">
<span className={`badge ${summary.overall === "go" ? "ok" : summary.overall === "no_go" ? "err" : ""}`}>
{summary.overall === "go" ? "GO" : summary.overall === "no_go" ? "NO-GO" : "Väntar"}
</span>
</div>
<div className="label">Övergripande beslut</div>
</div>
<div className="stat">
<div className="value">{summary.passed}</div>
<div className="label">Godkända</div>
</div>
<div className="stat">
<div className="value">{summary.failed}</div>
<div className="label">Ej uppnådda</div>
</div>
<div className="stat">
<div className="value">{summary.blocked}</div>
<div className="label">Blockerande</div>
</div>
<div className="stat">
<div className="value">{summary.notMeasurable}</div>
<div className="label">Ej mätbara</div>
</div>
</div>
)}
</div>
<div className="panel">
<table>
<thead>
<tr>
<th>Nyckel</th>
<th>Kategori</th>
<th>Mål</th>
<th>Uppmätt</th>
<th>Status</th>
<th>Fönster</th>
<th>Blockerar</th>
<th>Senast utvärderad</th>
</tr>
</thead>
<tbody>
{filtered.map((g) => (
<tr key={g.gateKey}>
<td>
<strong>{g.nameSv}</strong>
<div style={{ fontSize: "0.8rem", color: "var(--muted)" }}>{g.gateKey}</div>
</td>
<td>{g.category}</td>
<td>
{g.comparison === "gte" ? "≥ " : g.comparison === "lte" ? "≤ " : "= "}
{formatPct(g.targetValue)}
</td>
<td>{formatPct(g.lastValue)}</td>
<td>
<span className={`badge ${statusClass(g.status)}`}>{statusLabel(g.status)}</span>
</td>
<td>{g.evaluationWindowDays} dagar</td>
<td>{g.blocking ? "Ja" : "Nej"}</td>
<td>{g.lastEvaluatedAt ? new Date(g.lastEvaluatedAt).toLocaleString("sv-SE") : ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}