Fas 1a punkt 2: release_gates tabell + go/no-go-vy
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { schema, evaluateReleaseGates, seedReleaseGates, summarizeReleaseGates } from "@app/database";
|
||||
import { releaseGateUpdateSchema } from "@app/validation";
|
||||
import { z } from "zod";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import { audit } from "../lib/helpers.js";
|
||||
|
||||
/**
|
||||
* Release gate administration (spec §17).
|
||||
* Lists built-in go/no-go criteria, evaluates them against analytics events,
|
||||
* and lets admins tune thresholds.
|
||||
*/
|
||||
export async function adminReleaseGateRoutes(app: FastifyInstance) {
|
||||
const admin = { preHandler: [app.requireAdmin] };
|
||||
|
||||
/** Seed built-in gates. Safe to call multiple times. */
|
||||
app.post("/admin/v1/release-gates/seed", admin, async (req) => {
|
||||
await seedReleaseGates(app.db);
|
||||
await audit(app.db, {
|
||||
actorUserId: req.userId,
|
||||
actorType: "admin",
|
||||
action: "release_gates.seeded",
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Evaluate all gates now. */
|
||||
app.post("/admin/v1/release-gates/evaluate", admin, async (req) => {
|
||||
const gates = await evaluateReleaseGates(app.db);
|
||||
const summary = summarizeReleaseGates(gates);
|
||||
await audit(app.db, {
|
||||
actorUserId: req.userId,
|
||||
actorType: "admin",
|
||||
action: "release_gates.evaluated",
|
||||
metadata: { summary },
|
||||
});
|
||||
return { gates, summary };
|
||||
});
|
||||
|
||||
/** List current gate definitions without re-evaluating. */
|
||||
app.get("/admin/v1/release-gates", admin, async () => {
|
||||
const gates = await app.db.select().from(schema.releaseGates).orderBy(schema.releaseGates.category, schema.releaseGates.gateKey);
|
||||
const summary = summarizeReleaseGates(
|
||||
gates.map((g) => ({
|
||||
gateKey: g.gateKey,
|
||||
category: g.category,
|
||||
nameSv: g.nameSv,
|
||||
nameEn: g.nameEn,
|
||||
targetValue: g.targetValue,
|
||||
comparison: g.comparison,
|
||||
value: g.lastValue,
|
||||
status: g.status,
|
||||
lastEvaluatedAt: g.lastEvaluatedAt?.toISOString() ?? new Date().toISOString(),
|
||||
blocking: g.blocking,
|
||||
notes: g.notes ?? undefined,
|
||||
})),
|
||||
);
|
||||
return { gates, summary };
|
||||
});
|
||||
|
||||
/** Update gate configuration. */
|
||||
app.put("/admin/v1/release-gates/:key", admin, async (req) => {
|
||||
const params = z.object({ key: z.string().max(64) }).parse(req.params);
|
||||
const input = parse(releaseGateUpdateSchema, req.body);
|
||||
|
||||
const [existing] = await app.db
|
||||
.select()
|
||||
.from(schema.releaseGates)
|
||||
.where(eq(schema.releaseGates.gateKey, params.key))
|
||||
.limit(1);
|
||||
if (!existing) throw errors.notFound("Gate finns inte.");
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(schema.releaseGates)
|
||||
.set({
|
||||
...input,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.releaseGates.gateKey, params.key))
|
||||
.returning();
|
||||
|
||||
await audit(app.db, {
|
||||
actorUserId: req.userId,
|
||||
actorType: "admin",
|
||||
action: "release_gates.updated",
|
||||
targetType: "release_gate",
|
||||
targetId: params.key,
|
||||
metadata: input,
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||
import { communityRoutes } from "./routes/community.js";
|
||||
import { adminRoutes } from "./routes/admin.js";
|
||||
import { adminAnalyticsRoutes } from "./routes/admin-analytics.js";
|
||||
import { adminReleaseGateRoutes } from "./routes/admin-release-gates.js";
|
||||
import { analyticsRoutes } from "./routes/analytics.js";
|
||||
|
||||
declare module "fastify" {
|
||||
@@ -83,6 +84,7 @@ export async function buildServer(config: AppConfig) {
|
||||
await app.register(communityRoutes);
|
||||
await app.register(adminRoutes);
|
||||
await app.register(adminAnalyticsRoutes);
|
||||
await app.register(adminReleaseGateRoutes);
|
||||
await app.register(analyticsRoutes);
|
||||
|
||||
return app;
|
||||
|
||||
Reference in New Issue
Block a user