chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:22 +02:00
parent 41528f1947
commit 63ff9a3366
20 changed files with 1151 additions and 0 deletions

30
webui/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "@govoplan/ops-webui",
"version": "0.1.6",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

58
webui/src/api/ops.ts Normal file
View File

@@ -0,0 +1,58 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type OpsCheck = {
id: string;
label: string;
state: "ok" | "warning" | "error" | string;
detail: string;
readiness_critical?: boolean;
};
export type OpsDeploymentProfile = {
id: string;
name: string;
current: boolean;
components: string[];
fit: string;
};
export type OpsSizingAssumption = {
area: string;
baseline: string;
scale_trigger: string;
operator_note: string;
};
export type OpsStatus = {
summary: {
app_env: string;
active_profile: string;
module_count: number;
celery_enabled: boolean;
celery_queues: string[];
redis_url: string;
maintenance_mode: {
enabled: boolean;
message?: string | null;
};
database_url: string;
file_storage_backend: string;
};
readiness: {
ready: boolean;
profile: string;
blockers: Array<{
id: string;
label: string;
state: string;
detail: string;
}>;
};
checks: OpsCheck[];
deployment_profiles: OpsDeploymentProfile[];
sizing: OpsSizingAssumption[];
};
export function fetchOpsStatus(settings: ApiSettings): Promise<OpsStatus> {
return apiFetch(settings, "/api/v1/ops/status");
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import {
DismissibleAlert,
LoadingFrame,
MetricCard,
StatusBadge,
adminErrorMessage,
type ApiSettings
} from "@govoplan/core-webui";
import { fetchOpsStatus, type OpsStatus } from "../../api/ops";
export default function OpsHealthWidget({ settings, refreshKey }: { settings: ApiSettings; refreshKey: number }) {
const [status, setStatus] = useState<OpsStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
setLoading(true);
setError("");
fetchOpsStatus(settings).
then((next) => {if (!cancelled) setStatus(next);}).
catch((err) => {if (!cancelled) setError(adminErrorMessage(err));}).
finally(() => {if (!cancelled) setLoading(false);});
return () => {cancelled = true;};
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, refreshKey]);
const checks = status?.checks ?? [];
const warningCount = checks.filter((item) => item.state === "warning").length;
const errorCount = checks.filter((item) => item.state === "error").length;
const ready = status?.readiness.ready ?? false;
return (
<LoadingFrame loading={loading} label="Loading operations status">
{error && <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>}
<div className="metric-grid inside dashboard-widget-metrics">
<MetricCard label="Readiness" value={ready ? "ready" : "blocked"} tone={ready ? "good" : "danger"} detail={status?.readiness.profile ?? "-"} />
<MetricCard label="Workers" value={status?.summary.celery_enabled ? "split" : "off"} tone={status?.summary.celery_enabled ? "good" : "warning"} detail={status?.summary.celery_queues?.length ? status.summary.celery_queues.join(", ") : "single-process"} />
<MetricCard label="Warnings" value={warningCount + errorCount} tone={errorCount ? "danger" : warningCount ? "warning" : "good"} detail="Current health checks" />
</div>
{status?.readiness.blockers.length ?
<dl className="detail-list dashboard-compact-list below-grid">
{status.readiness.blockers.slice(0, 3).map((blocker) =>
<div key={blocker.id}>
<dt><StatusBadge status={blocker.state === "error" ? "error" : "warning"} label={blocker.state} /></dt>
<dd><strong>{blocker.label}</strong><span className="muted"> · {blocker.detail}</span></dd>
</div>
)}
</dl> :
<p className="muted below-grid">No readiness blockers reported.</p>
}
</LoadingFrame>);
}

View File

@@ -0,0 +1,147 @@
import { useEffect, useState } from "react";
import { RefreshCw } from "lucide-react";
import {
Button,
Card,
DismissibleAlert,
LoadingFrame,
MetricCard,
PageTitle,
StatusBadge,
adminErrorMessage,
type ApiSettings } from
"@govoplan/core-webui";
import { fetchOpsStatus, type OpsCheck, type OpsDeploymentProfile, type OpsSizingAssumption, type OpsStatus } from "../../api/ops";
export default function OpsPage({ settings }: {settings: ApiSettings;}) {
const [status, setStatus] = useState<OpsStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
async function load() {
setLoading(true);
setError("");
try {
setStatus(await fetchOpsStatus(settings));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => {void load();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const checks = status?.checks ?? [];
const warningCount = checks.filter((item) => item.state === "warning").length;
const errorCount = checks.filter((item) => item.state === "error").length;
const ready = status?.readiness.ready ?? false;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-ops.ops.907a54c2</PageTitle>
<p>i18n:govoplan-ops.runtime_health_deployment_profile_worker_split_a.55340156</p>
</div>
<div className="button-row compact-actions">
<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} /> i18n:govoplan-ops.reload.cce71553</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="i18n:govoplan-ops.loading_operations_status.6890fe6e">
<div className="metric-grid">
<MetricCard label="i18n:govoplan-ops.profile.ff4fc027" value={status?.summary.active_profile ?? "-"} tone="info" detail={status?.summary.database_url ?? "i18n:govoplan-ops.no_database_url.51a2db0c"} />
<MetricCard label="i18n:govoplan-ops.readiness.1db9d6fb" value={ready ? "ready" : "not ready"} tone={ready ? "good" : "danger"} detail={status?.readiness.blockers.length ? `${status.readiness.blockers.length} blocker(s)` : "i18n:govoplan-ops.no_readiness_blockers.0df259bd"} />
<MetricCard label="i18n:govoplan-ops.modules.04e9462c" value={status?.summary.module_count ?? 0} tone="neutral" detail="i18n:govoplan-ops.enabled_in_the_runtime_registry.d2c6142d" />
<MetricCard label="i18n:govoplan-ops.workers.b6ef3acd" value={status?.summary.celery_enabled ? "split" : "off"} tone={status?.summary.celery_enabled ? "good" : "warning"} detail={status?.summary.celery_queues?.length ? status.summary.celery_queues.join(", ") : "i18n:govoplan-ops.celery_worker_setting.323d7737"} />
<MetricCard label="i18n:govoplan-ops.redis.5eaa1f2f" value={status?.summary.redis_url ? "configured" : "-"} tone={status?.summary.celery_enabled ? "info" : "neutral"} detail={status?.summary.redis_url ?? "-"} />
<MetricCard label="i18n:govoplan-ops.warnings.1430f976" value={warningCount + errorCount} tone={errorCount ? "danger" : warningCount ? "warning" : "good"} detail="i18n:govoplan-ops.current_health_checks.7830bccf" />
</div>
<div className="dashboard-grid">
<Card title="i18n:govoplan-ops.health_checks.201c869f">
<CheckList checks={checks} />
</Card>
<Card title="i18n:govoplan-ops.deployment_profiles.b0caa179">
<ProfileList profiles={status?.deployment_profiles ?? []} />
</Card>
<Card title="i18n:govoplan-ops.sizing_assumptions.6ade9a90">
<SizingTable items={status?.sizing ?? []} />
</Card>
</div>
</LoadingFrame>
</div>);
}
function CheckList({ checks }: {checks: OpsCheck[];}) {
if (!checks.length) return <p className="muted">i18n:govoplan-ops.no_health_checks_reported.03c067c4</p>;
return (
<dl className="detail-list">
{checks.map((check) =>
<div key={check.id}>
<dt><StatusBadge status={stateTone(check.state)} label={check.state} /></dt>
<dd><strong>{check.label}</strong><span className="muted"> · {check.detail}</span></dd>
</div>
)}
</dl>);
}
function ProfileList({ profiles }: {profiles: OpsDeploymentProfile[];}) {
if (!profiles.length) return <p className="muted">i18n:govoplan-ops.no_deployment_profiles_reported.7c3af1db</p>;
return (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr><th>i18n:govoplan-ops.profile.ff4fc027</th><th>i18n:govoplan-ops.status.bae7d5be</th><th>i18n:govoplan-ops.components.9289473e</th><th>i18n:govoplan-ops.fit.dab564d8</th></tr>
</thead>
<tbody>
{profiles.map((profile) =>
<tr key={profile.id}>
<td><strong>{profile.name}</strong><span className="muted block">{profile.id}</span></td>
<td><StatusBadge status={profile.current ? "success" : "inactive"} label={profile.current ? "current" : "reference"} /></td>
<td>{profile.components.join(", ")}</td>
<td>{profile.fit}</td>
</tr>
)}
</tbody>
</table>
</div>);
}
function SizingTable({ items }: {items: OpsSizingAssumption[];}) {
if (!items.length) return <p className="muted">i18n:govoplan-ops.no_sizing_assumptions_reported.17515959</p>;
return (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr><th>i18n:govoplan-ops.area.2745deba</th><th>i18n:govoplan-ops.baseline.e6ab7982</th><th>i18n:govoplan-ops.scale_trigger.1c85e10e</th><th>i18n:govoplan-ops.operator_note.1dc58f7b</th></tr>
</thead>
<tbody>
{items.map((item) =>
<tr key={item.area}>
<td><strong>{item.area}</strong></td>
<td>{item.baseline}</td>
<td>{item.scale_trigger}</td>
<td>{item.operator_note}</td>
</tr>
)}
</tbody>
</table>
</div>);
}
function stateTone(state: string): string {
if (state === "ok") return "success";
if (state === "warning") return "warning";
if (state === "error") return "error";
return "inactive";
}

View File

@@ -0,0 +1,64 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-ops.area.2745deba": "Area",
"i18n:govoplan-ops.baseline.e6ab7982": "Baseline",
"i18n:govoplan-ops.celery_worker_setting.323d7737": "Celery worker setting",
"i18n:govoplan-ops.components.9289473e": "Components",
"i18n:govoplan-ops.current_health_checks.7830bccf": "Current health checks",
"i18n:govoplan-ops.deployment_profiles.b0caa179": "Deployment Profiles",
"i18n:govoplan-ops.enabled_in_the_runtime_registry.d2c6142d": "Enabled in the runtime registry",
"i18n:govoplan-ops.fit.dab564d8": "Fit",
"i18n:govoplan-ops.health_checks.201c869f": "Health Checks",
"i18n:govoplan-ops.loading_operations_status.6890fe6e": "Loading operations status...",
"i18n:govoplan-ops.modules.04e9462c": "Modules",
"i18n:govoplan-ops.no_database_url.51a2db0c": "No database URL",
"i18n:govoplan-ops.no_deployment_profiles_reported.7c3af1db": "No deployment profiles reported.",
"i18n:govoplan-ops.no_health_checks_reported.03c067c4": "No health checks reported.",
"i18n:govoplan-ops.no_sizing_assumptions_reported.17515959": "No sizing assumptions reported.",
"i18n:govoplan-ops.operator_note.1dc58f7b": "Operator note",
"i18n:govoplan-ops.ops.907a54c2": "Ops",
"i18n:govoplan-ops.profile.ff4fc027": "Profile",
"i18n:govoplan-ops.readiness.1db9d6fb": "Readiness",
"i18n:govoplan-ops.reload.cce71553": "Reload",
"i18n:govoplan-ops.redis.5eaa1f2f": "Redis",
"i18n:govoplan-ops.no_readiness_blockers.0df259bd": "No readiness blockers",
"i18n:govoplan-ops.runtime_health_deployment_profile_worker_split_a.55340156": "Runtime health, deployment profile, worker split, and sizing assumptions.",
"i18n:govoplan-ops.scale_trigger.1c85e10e": "Scale trigger",
"i18n:govoplan-ops.sizing_assumptions.6ade9a90": "Sizing Assumptions",
"i18n:govoplan-ops.status.bae7d5be": "Status",
"i18n:govoplan-ops.warnings.1430f976": "Warnings",
"i18n:govoplan-ops.workers.b6ef3acd": "Workers"
},
"de": {
"i18n:govoplan-ops.area.2745deba": "Area",
"i18n:govoplan-ops.baseline.e6ab7982": "Baseline",
"i18n:govoplan-ops.celery_worker_setting.323d7737": "Celery worker setting",
"i18n:govoplan-ops.components.9289473e": "Components",
"i18n:govoplan-ops.current_health_checks.7830bccf": "Current health checks",
"i18n:govoplan-ops.deployment_profiles.b0caa179": "Deployment Profiles",
"i18n:govoplan-ops.enabled_in_the_runtime_registry.d2c6142d": "Enabled in the runtime registry",
"i18n:govoplan-ops.fit.dab564d8": "Fit",
"i18n:govoplan-ops.health_checks.201c869f": "Health Checks",
"i18n:govoplan-ops.loading_operations_status.6890fe6e": "Loading operations status...",
"i18n:govoplan-ops.modules.04e9462c": "Module",
"i18n:govoplan-ops.no_database_url.51a2db0c": "No database URL",
"i18n:govoplan-ops.no_deployment_profiles_reported.7c3af1db": "No deployment profiles reported.",
"i18n:govoplan-ops.no_health_checks_reported.03c067c4": "No health checks reported.",
"i18n:govoplan-ops.no_sizing_assumptions_reported.17515959": "No sizing assumptions reported.",
"i18n:govoplan-ops.operator_note.1dc58f7b": "Operator note",
"i18n:govoplan-ops.ops.907a54c2": "Betrieb",
"i18n:govoplan-ops.profile.ff4fc027": "Profil",
"i18n:govoplan-ops.readiness.1db9d6fb": "Bereitschaft",
"i18n:govoplan-ops.reload.cce71553": "Neu laden",
"i18n:govoplan-ops.redis.5eaa1f2f": "Redis",
"i18n:govoplan-ops.no_readiness_blockers.0df259bd": "Keine Bereitschaftsblocker",
"i18n:govoplan-ops.runtime_health_deployment_profile_worker_split_a.55340156": "Runtime health, deployment profile, worker split, and sizing assumptions.",
"i18n:govoplan-ops.scale_trigger.1c85e10e": "Scale trigger",
"i18n:govoplan-ops.sizing_assumptions.6ade9a90": "Sizing Assumptions",
"i18n:govoplan-ops.status.bae7d5be": "Status",
"i18n:govoplan-ops.warnings.1430f976": "Warnungen",
"i18n:govoplan-ops.workers.b6ef3acd": "Worker"
}
};

2
webui/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { opsModule as default, opsModule } from "./module";
export { default as OpsPage } from "./features/ops/OpsPage";

46
webui/src/module.ts Normal file
View File

@@ -0,0 +1,46 @@
import { createElement, lazy } from "react";
import type { DashboardWidgetsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import OpsHealthWidget from "./features/ops/OpsHealthWidget";
import { generatedTranslations } from "./i18n/generatedTranslations";
const OpsPage = lazy(() => import("./features/ops/OpsPage"));
const opsReadScopes = ["ops:operations:read", "system:settings:read", "admin:settings:read"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const dashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "ops.health",
title: "Operations health",
description: "Readiness, worker mode, and current warning count.",
moduleId: "ops",
category: "Operations",
order: 100,
defaultSize: "wide",
anyOf: opsReadScopes,
refreshIntervalMs: 30_000,
render: ({ settings, refreshKey }) => createElement(OpsHealthWidget, { settings, refreshKey })
}
]
};
export const opsModule: PlatformWebModule = {
id: "ops",
label: "i18n:govoplan-ops.ops.907a54c2",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["audit", "docs", "notifications"],
translations,
navItems: [{ to: "/ops", label: "i18n:govoplan-ops.ops.907a54c2", iconName: "activity", anyOf: opsReadScopes, order: 890 }],
routes: [
{ path: "/ops", anyOf: opsReadScopes, order: 890, render: ({ settings }) => createElement(OpsPage, { settings }) }],
uiCapabilities: {
"dashboard.widgets": dashboardWidgets
}
};
export default opsModule;