221 lines
6.1 KiB
TypeScript
221 lines
6.1 KiB
TypeScript
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>
|
||
);
|
||
}
|