Files
Cibello-app/apps/admin/src/pages/ReleaseGates.tsx
T
2026-08-05 23:10:09 +07:00

221 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}