feat(ops): show deployment capability receipt
This commit is contained in:
@@ -43,6 +43,7 @@ from govoplan_core.db.session import get_database
|
|||||||
from govoplan_core.settings import settings as core_settings
|
from govoplan_core.settings import settings as core_settings
|
||||||
|
|
||||||
from govoplan_ops.backend.manifest import OPS_READ_SCOPES, OPS_RUN_SCOPES
|
from govoplan_ops.backend.manifest import OPS_READ_SCOPES, OPS_RUN_SCOPES
|
||||||
|
from govoplan_ops.backend.infrastructure import deployment_capability_status
|
||||||
|
|
||||||
router = APIRouter(prefix="/ops", tags=["ops"])
|
router = APIRouter(prefix="/ops", tags=["ops"])
|
||||||
_module_check_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
_module_check_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||||
@@ -207,6 +208,7 @@ def _ops_status_payload(
|
|||||||
)
|
)
|
||||||
storage_check = _storage_check()
|
storage_check = _storage_check()
|
||||||
backup_check = _backup_restore_check()
|
backup_check = _backup_restore_check()
|
||||||
|
infrastructure = deployment_capability_status()
|
||||||
recovery_metrics = runtime_cluster.get("recovery", {}).get("metrics", {})
|
recovery_metrics = runtime_cluster.get("recovery", {}).get("metrics", {})
|
||||||
checks = [
|
checks = [
|
||||||
_check(
|
_check(
|
||||||
@@ -229,6 +231,7 @@ def _ops_status_payload(
|
|||||||
storage_check,
|
storage_check,
|
||||||
backup_check,
|
backup_check,
|
||||||
_deployment_security_check(current_profile),
|
_deployment_security_check(current_profile),
|
||||||
|
_infrastructure_capability_check(infrastructure),
|
||||||
*module_checks,
|
*module_checks,
|
||||||
]
|
]
|
||||||
readiness = _readiness(checks, maintenance_mode)
|
readiness = _readiness(checks, maintenance_mode)
|
||||||
@@ -262,6 +265,12 @@ def _ops_status_payload(
|
|||||||
"failed_operation_count": int(recovery_metrics.get("failed") or 0),
|
"failed_operation_count": int(recovery_metrics.get("failed") or 0),
|
||||||
"outcome_unknown_count": int(recovery_metrics.get("outcome_unknown") or 0),
|
"outcome_unknown_count": int(recovery_metrics.get("outcome_unknown") or 0),
|
||||||
"active_operation_count": int(recovery_metrics.get("active") or 0),
|
"active_operation_count": int(recovery_metrics.get("active") or 0),
|
||||||
|
"infrastructure_capability_count": len(
|
||||||
|
infrastructure.get("capabilities", [])
|
||||||
|
),
|
||||||
|
"pending_post_install_task_count": len(
|
||||||
|
infrastructure.get("post_install_tasks", [])
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"readiness": readiness,
|
"readiness": readiness,
|
||||||
"checks": checks,
|
"checks": checks,
|
||||||
@@ -269,9 +278,40 @@ def _ops_status_payload(
|
|||||||
"deployment_profiles": _deployment_profiles(current_profile),
|
"deployment_profiles": _deployment_profiles(current_profile),
|
||||||
"sizing": _sizing_assumptions(),
|
"sizing": _sizing_assumptions(),
|
||||||
"runtime_cluster": runtime_cluster,
|
"runtime_cluster": runtime_cluster,
|
||||||
|
"infrastructure": infrastructure,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _infrastructure_capability_check(
|
||||||
|
infrastructure: Mapping[str, object],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if infrastructure.get("available") is True:
|
||||||
|
capabilities = infrastructure.get("capabilities")
|
||||||
|
count = len(capabilities) if isinstance(capabilities, list) else 0
|
||||||
|
return _check(
|
||||||
|
"infrastructure_capability_receipt",
|
||||||
|
"Infrastructure capabilities",
|
||||||
|
"ok",
|
||||||
|
f"A validated non-secret deployment receipt reports {count} capabilities.",
|
||||||
|
)
|
||||||
|
if infrastructure.get("configured") is True:
|
||||||
|
return _check(
|
||||||
|
"infrastructure_capability_receipt",
|
||||||
|
"Infrastructure capabilities",
|
||||||
|
"warning",
|
||||||
|
str(
|
||||||
|
infrastructure.get("error")
|
||||||
|
or "The configured deployment capability receipt is unavailable."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return _check(
|
||||||
|
"infrastructure_capability_receipt",
|
||||||
|
"Infrastructure capabilities",
|
||||||
|
"ok",
|
||||||
|
"No deployment capability receipt is mounted in this runtime profile.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _runtime_cluster_status(
|
def _runtime_cluster_status(
|
||||||
*,
|
*,
|
||||||
expected_composition_hash: str | None = None,
|
expected_composition_hash: str | None = None,
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024
|
||||||
|
CAPABILITY_STATES = {
|
||||||
|
"configured",
|
||||||
|
"available_unconfigured",
|
||||||
|
"externally_supplied",
|
||||||
|
"unavailable",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def deployment_capability_status(
|
||||||
|
path: Path | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
configured_path = path
|
||||||
|
if configured_path is None:
|
||||||
|
raw_path = os.getenv("GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH", "").strip()
|
||||||
|
if not raw_path:
|
||||||
|
return _unavailable(configured=False, error=None)
|
||||||
|
configured_path = Path(raw_path)
|
||||||
|
try:
|
||||||
|
document = _read_document(configured_path)
|
||||||
|
return {
|
||||||
|
"configured": True,
|
||||||
|
"available": True,
|
||||||
|
"schema_version": 1,
|
||||||
|
"installation_id": document["installation_id"],
|
||||||
|
"profile": document["profile"],
|
||||||
|
"capabilities": document["capabilities"],
|
||||||
|
"post_install_tasks": document["post_install_tasks"],
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
return _unavailable(configured=True, error=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_document(path: Path) -> dict[str, object]:
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise ValueError("Deployment capability receipt is not a regular file.")
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size > MAX_CAPABILITY_DOCUMENT_BYTES:
|
||||||
|
raise ValueError("Deployment capability receipt exceeds 256 KiB.")
|
||||||
|
raw = path.read_bytes()
|
||||||
|
if len(raw) != size:
|
||||||
|
raise ValueError("Deployment capability receipt changed while being read.")
|
||||||
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
|
if not isinstance(payload, Mapping) or payload.get("schema_version") != 1:
|
||||||
|
raise ValueError("Deployment capability receipt has an unsupported schema.")
|
||||||
|
installation_id = _required_text(payload, "installation_id", maximum=100)
|
||||||
|
profile = _required_text(payload, "profile", maximum=100)
|
||||||
|
raw_capabilities = payload.get("capabilities")
|
||||||
|
if not isinstance(raw_capabilities, list) or len(raw_capabilities) > 100:
|
||||||
|
raise ValueError("Deployment capability receipt has invalid capabilities.")
|
||||||
|
capabilities = [_capability(item) for item in raw_capabilities]
|
||||||
|
ids = [str(item["id"]) for item in capabilities]
|
||||||
|
if len(ids) != len(set(ids)):
|
||||||
|
raise ValueError("Deployment capability receipt repeats a capability id.")
|
||||||
|
raw_tasks = payload.get("post_install_tasks", [])
|
||||||
|
if not isinstance(raw_tasks, list) or len(raw_tasks) > 100:
|
||||||
|
raise ValueError("Deployment capability receipt has invalid post-install tasks.")
|
||||||
|
tasks = [_task(item) for item in raw_tasks]
|
||||||
|
return {
|
||||||
|
"installation_id": installation_id,
|
||||||
|
"profile": profile,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"post_install_tasks": tasks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(value: object) -> dict[str, object]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError("Deployment capability entries must be objects.")
|
||||||
|
state = _required_text(value, "state", maximum=40)
|
||||||
|
if state not in CAPABILITY_STATES:
|
||||||
|
raise ValueError(f"Deployment capability state is unsupported: {state!r}.")
|
||||||
|
endpoint = value.get("endpoint", {})
|
||||||
|
if not isinstance(endpoint, Mapping) or len(endpoint) > 10:
|
||||||
|
raise ValueError("Deployment capability endpoint metadata is invalid.")
|
||||||
|
normalized_endpoint: dict[str, object] = {}
|
||||||
|
for key, raw in endpoint.items():
|
||||||
|
if not isinstance(key, str) or len(key) > 50:
|
||||||
|
raise ValueError("Deployment capability endpoint key is invalid.")
|
||||||
|
if isinstance(raw, bool) or raw is None:
|
||||||
|
normalized_endpoint[key] = raw
|
||||||
|
elif isinstance(raw, int):
|
||||||
|
normalized_endpoint[key] = raw
|
||||||
|
elif isinstance(raw, str) and len(raw) <= 500:
|
||||||
|
normalized_endpoint[key] = raw
|
||||||
|
else:
|
||||||
|
raise ValueError("Deployment capability endpoint value is invalid.")
|
||||||
|
secret_refs = _string_list(value.get("secret_refs"), maximum_items=30)
|
||||||
|
if any(not item.startswith("env:") for item in secret_refs):
|
||||||
|
raise ValueError("Deployment capability secrets must use environment references.")
|
||||||
|
return {
|
||||||
|
"id": _required_text(value, "id", maximum=120),
|
||||||
|
"label": _required_text(value, "label", maximum=200),
|
||||||
|
"state": state,
|
||||||
|
"source": _required_text(value, "source", maximum=120),
|
||||||
|
"detail": _required_text(value, "detail", maximum=1000),
|
||||||
|
"endpoint": normalized_endpoint,
|
||||||
|
"secret_refs": secret_refs,
|
||||||
|
"dependent_modules": _string_list(
|
||||||
|
value.get("dependent_modules"),
|
||||||
|
maximum_items=100,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _task(value: object) -> dict[str, object]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError("Deployment post-install task entries must be objects.")
|
||||||
|
return {
|
||||||
|
"id": _required_text(value, "id", maximum=120),
|
||||||
|
"resume_key": _required_text(value, "resume_key", maximum=240),
|
||||||
|
"capability_id": _required_text(value, "capability_id", maximum=120),
|
||||||
|
"state": _required_text(value, "state", maximum=40),
|
||||||
|
"owner_module": _required_text(value, "owner_module", maximum=120),
|
||||||
|
"summary": _required_text(value, "summary", maximum=1000),
|
||||||
|
"required_inputs": _string_list(
|
||||||
|
value.get("required_inputs"),
|
||||||
|
maximum_items=30,
|
||||||
|
),
|
||||||
|
"secret_boundary": _required_text(
|
||||||
|
value,
|
||||||
|
"secret_boundary",
|
||||||
|
maximum=120,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _required_text(
|
||||||
|
value: Mapping[str, Any],
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
maximum: int,
|
||||||
|
) -> str:
|
||||||
|
raw = value.get(key)
|
||||||
|
text = str(raw).strip() if raw is not None else ""
|
||||||
|
if not text or len(text) > maximum:
|
||||||
|
raise ValueError(f"Deployment capability field {key!r} is invalid.")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: object, *, maximum_items: int) -> list[str]:
|
||||||
|
if not isinstance(value, list) or len(value) > maximum_items:
|
||||||
|
raise ValueError("Deployment capability list field is invalid.")
|
||||||
|
result: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, str) or not item.strip() or len(item) > 500:
|
||||||
|
raise ValueError("Deployment capability list item is invalid.")
|
||||||
|
result.append(item.strip())
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _unavailable(*, configured: bool, error: str | None) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"configured": configured,
|
||||||
|
"available": False,
|
||||||
|
"schema_version": None,
|
||||||
|
"installation_id": None,
|
||||||
|
"profile": None,
|
||||||
|
"capabilities": [],
|
||||||
|
"post_install_tasks": [],
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
@@ -129,7 +129,7 @@ manifest = ModuleManifest(
|
|||||||
id="ops.health-governance-and-sizing",
|
id="ops.health-governance-and-sizing",
|
||||||
title="Inspect platform health and deployment posture",
|
title="Inspect platform health and deployment posture",
|
||||||
summary="Ops combines module-owned health checks with deployment profile, governance inventory, worker assumptions, and sizing guidance.",
|
summary="Ops combines module-owned health checks with deployment profile, governance inventory, worker assumptions, and sizing guidance.",
|
||||||
body="Read-only status distinguishes configured capabilities from healthy integrations. Authorized operators can run bounded probes; a probe must not perform unbounded business work or silently repair data. Use readiness and worker results when diagnosing a node, and use the deployment profile and sizing assumptions when planning horizontal capacity.",
|
body="Read-only status distinguishes configured capabilities from healthy integrations. When the deployment mounts a signed or locally generated non-secret infrastructure capability receipt, Ops shows whether PostgreSQL, Redis, SMTP, file storage, load balancing, and ingress are configured, externally supplied, available but unconfigured, or unavailable. Secret values never cross this boundary; only stable environment or credential-envelope references may be disclosed. Pending post-install tasks remain visible with a stable resume key. Authorized operators can run bounded probes; a probe must not perform unbounded business work or silently repair data. Use readiness and worker results when diagnosing a node, and use the deployment profile and sizing assumptions when planning horizontal capacity.",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "system_admin"),
|
audience=("operator", "system_admin"),
|
||||||
related_modules=("audit", "docs", "notifications"),
|
related_modules=("audit", "docs", "notifications"),
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_ops.backend.infrastructure import deployment_capability_status
|
||||||
|
|
||||||
|
|
||||||
|
class InfrastructureCapabilityTests(unittest.TestCase):
|
||||||
|
def test_reads_bounded_non_secret_capability_receipt(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-ops-capabilities-") as root:
|
||||||
|
path = Path(root) / "capabilities.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"installation_id": "govoplan-test",
|
||||||
|
"profile": "evaluation",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "mail.smtp",
|
||||||
|
"label": "SMTP delivery",
|
||||||
|
"state": "available_unconfigured",
|
||||||
|
"source": "operator-supplied",
|
||||||
|
"detail": "Mail needs a profile.",
|
||||||
|
"endpoint": {},
|
||||||
|
"secret_refs": ["env:SMTP_CREDENTIAL_REF"],
|
||||||
|
"dependent_modules": ["mail"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"post_install_tasks": [
|
||||||
|
{
|
||||||
|
"id": "mail.smtp-profile",
|
||||||
|
"resume_key": "govoplan-test:mail.smtp-profile:v1",
|
||||||
|
"capability_id": "mail.smtp",
|
||||||
|
"state": "pending",
|
||||||
|
"owner_module": "mail",
|
||||||
|
"summary": "Configure Mail.",
|
||||||
|
"required_inputs": ["credential envelope reference"],
|
||||||
|
"secret_boundary": "credential-envelope-reference-only",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = deployment_capability_status(path)
|
||||||
|
|
||||||
|
self.assertTrue(result["available"])
|
||||||
|
self.assertEqual("mail.smtp", result["capabilities"][0]["id"])
|
||||||
|
self.assertEqual("mail.smtp-profile", result["post_install_tasks"][0]["id"])
|
||||||
|
|
||||||
|
def test_rejects_inline_secret_instead_of_reference(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-ops-capabilities-") as root:
|
||||||
|
path = Path(root) / "capabilities.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"installation_id": "govoplan-test",
|
||||||
|
"profile": "evaluation",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "mail.smtp",
|
||||||
|
"label": "SMTP delivery",
|
||||||
|
"state": "configured",
|
||||||
|
"source": "operator-supplied",
|
||||||
|
"detail": "Configured.",
|
||||||
|
"endpoint": {},
|
||||||
|
"secret_refs": ["plaintext-secret"],
|
||||||
|
"dependent_modules": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"post_install_tasks": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = deployment_capability_status(path)
|
||||||
|
|
||||||
|
self.assertFalse(result["available"])
|
||||||
|
self.assertIn("environment references", str(result["error"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -17,6 +17,28 @@ export type OpsDeploymentProfile = {
|
|||||||
fit: string;
|
fit: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OpsInfrastructureCapability = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
state: "configured" | "available_unconfigured" | "externally_supplied" | "unavailable";
|
||||||
|
source: string;
|
||||||
|
detail: string;
|
||||||
|
endpoint: Record<string, string | number | boolean | null>;
|
||||||
|
secret_refs: string[];
|
||||||
|
dependent_modules: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpsPostInstallTask = {
|
||||||
|
id: string;
|
||||||
|
resume_key: string;
|
||||||
|
capability_id: string;
|
||||||
|
state: string;
|
||||||
|
owner_module: string;
|
||||||
|
summary: string;
|
||||||
|
required_inputs: string[];
|
||||||
|
secret_boundary: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type OpsSizingAssumption = {
|
export type OpsSizingAssumption = {
|
||||||
area: string;
|
area: string;
|
||||||
baseline: string;
|
baseline: string;
|
||||||
@@ -186,6 +208,8 @@ export type OpsStatus = {
|
|||||||
failed_operation_count?: number;
|
failed_operation_count?: number;
|
||||||
outcome_unknown_count?: number;
|
outcome_unknown_count?: number;
|
||||||
active_operation_count?: number;
|
active_operation_count?: number;
|
||||||
|
infrastructure_capability_count?: number;
|
||||||
|
pending_post_install_task_count?: number;
|
||||||
};
|
};
|
||||||
readiness: {
|
readiness: {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
@@ -220,6 +244,16 @@ export type OpsStatus = {
|
|||||||
deployment_profiles: OpsDeploymentProfile[];
|
deployment_profiles: OpsDeploymentProfile[];
|
||||||
sizing: OpsSizingAssumption[];
|
sizing: OpsSizingAssumption[];
|
||||||
runtime_cluster: OpsRuntimeCluster;
|
runtime_cluster: OpsRuntimeCluster;
|
||||||
|
infrastructure: {
|
||||||
|
configured: boolean;
|
||||||
|
available: boolean;
|
||||||
|
schema_version?: number | null;
|
||||||
|
installation_id?: string | null;
|
||||||
|
profile?: string | null;
|
||||||
|
capabilities: OpsInfrastructureCapability[];
|
||||||
|
post_install_tasks: OpsPostInstallTask[];
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchOpsStatus(settings: ApiSettings): Promise<OpsStatus> {
|
export function fetchOpsStatus(settings: ApiSettings): Promise<OpsStatus> {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type OpsCheck,
|
type OpsCheck,
|
||||||
type OpsDeploymentProfile,
|
type OpsDeploymentProfile,
|
||||||
type OpsGovernanceModule,
|
type OpsGovernanceModule,
|
||||||
|
type OpsInfrastructureCapability,
|
||||||
type OpsRecoveryOperation,
|
type OpsRecoveryOperation,
|
||||||
type OpsRuntimeNode,
|
type OpsRuntimeNode,
|
||||||
type OpsSizingAssumption,
|
type OpsSizingAssumption,
|
||||||
@@ -168,6 +169,7 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
|
|||||||
<MetricCard label="Database capacity" value={databaseCapacityValue(status)} tone={databaseCapacityTone(status)} detail="Peak pooled connections / available connections" />
|
<MetricCard label="Database capacity" value={databaseCapacityValue(status)} tone={databaseCapacityTone(status)} detail="Peak pooled connections / available connections" />
|
||||||
<MetricCard label="Recovery" value={status?.summary.recovery_required_count ?? 0} tone={status?.summary.recovery_required_count ? "danger" : "good"} detail="Operations requiring recovery attention" />
|
<MetricCard label="Recovery" value={status?.summary.recovery_required_count ?? 0} tone={status?.summary.recovery_required_count ? "danger" : "good"} detail="Operations requiring recovery attention" />
|
||||||
<MetricCard label="Provider bindings" value={status?.governance.summary.configured_external_provider_count ?? 0} tone={status?.governance.summary.provider_attention_count ? "warning" : "good"} detail={`${status?.governance.summary.provider_attention_count ?? 0} requiring attention`} />
|
<MetricCard label="Provider bindings" value={status?.governance.summary.configured_external_provider_count ?? 0} tone={status?.governance.summary.provider_attention_count ? "warning" : "good"} detail={`${status?.governance.summary.provider_attention_count ?? 0} requiring attention`} />
|
||||||
|
<MetricCard label="i18n:govoplan-ops.infrastructure_capabilities" value={status?.summary.infrastructure_capability_count ?? 0} tone={status?.infrastructure.available ? "good" : "neutral"} detail={i18nMessage("i18n:govoplan-ops.pending_post_install_tasks", { value0: status?.summary.pending_post_install_task_count ?? 0 })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
@@ -197,6 +199,16 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
|
|||||||
<ProfileList profiles={status?.deployment_profiles ?? []} />
|
<ProfileList profiles={status?.deployment_profiles ?? []} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-ops.infrastructure_capabilities">
|
||||||
|
<InfrastructureCapabilityTable items={status?.infrastructure.capabilities ?? []} />
|
||||||
|
{(status?.infrastructure.post_install_tasks.length ?? 0) > 0 && <dl className="detail-list">
|
||||||
|
{status?.infrastructure.post_install_tasks.map((task) => <div key={task.resume_key}>
|
||||||
|
<dt><StatusBadge status="warning" label={task.state} /></dt>
|
||||||
|
<dd><strong>{task.summary}</strong><span className="muted"> · {task.owner_module} · {task.required_inputs.join(", ")}</span></dd>
|
||||||
|
</div>)}
|
||||||
|
</dl>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-ops.sizing_assumptions.6ade9a90">
|
<Card title="i18n:govoplan-ops.sizing_assumptions.6ade9a90">
|
||||||
<SizingTable items={status?.sizing ?? []} />
|
<SizingTable items={status?.sizing ?? []} />
|
||||||
</Card>
|
</Card>
|
||||||
@@ -507,6 +519,33 @@ function SizingTable({ items }: {items: OpsSizingAssumption[];}) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InfrastructureCapabilityTable({ items }: {items: OpsInfrastructureCapability[];}) {
|
||||||
|
if (!items.length) return <p className="muted">i18n:govoplan-ops.no_infrastructure_capabilities</p>;
|
||||||
|
const columns: DataGridColumn<OpsInfrastructureCapability>[] = [
|
||||||
|
{ id: "capability", header: "i18n:govoplan-ops.capability", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (item) => `${item.label} ${item.id}`, render: (item) => <div><strong>{item.label}</strong><span className="muted block">{item.id}</span></div> },
|
||||||
|
{ id: "state", header: "i18n:govoplan-ops.status.bae7d5be", width: 190, sortable: true, filterable: true, value: (item) => item.state, render: (item) => <StatusBadge status={capabilityTone(item.state)} label={item.state.replaceAll("_", " ")} /> },
|
||||||
|
{ id: "source", header: "i18n:govoplan-ops.source", width: "minmax(180px, .7fr)", minWidth: 160, resizable: true, filterable: true, value: (item) => item.source },
|
||||||
|
{ id: "endpoint", header: "i18n:govoplan-ops.endpoint", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (item) => endpointLabel(item.endpoint) },
|
||||||
|
{ id: "consumers", header: "i18n:govoplan-ops.consumers", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (item) => item.dependent_modules.join(" "), render: (item) => item.dependent_modules.join(", ") || "-" }
|
||||||
|
];
|
||||||
|
return <DataGrid id="ops-infrastructure-capabilities" rows={items} columns={columns} getRowKey={(item) => item.id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointLabel(endpoint: OpsInfrastructureCapability["endpoint"]): string {
|
||||||
|
if (typeof endpoint.host === "string") {
|
||||||
|
const scheme = typeof endpoint.scheme === "string" ? `${endpoint.scheme}://` : "";
|
||||||
|
const port = typeof endpoint.port === "number" ? `:${endpoint.port}` : "";
|
||||||
|
return `${scheme}${endpoint.host}${port}`;
|
||||||
|
}
|
||||||
|
return typeof endpoint.reference === "string" ? endpoint.reference : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function capabilityTone(state: OpsInfrastructureCapability["state"]): string {
|
||||||
|
if (state === "configured" || state === "externally_supplied") return "success";
|
||||||
|
if (state === "available_unconfigured") return "warning";
|
||||||
|
return "inactive";
|
||||||
|
}
|
||||||
|
|
||||||
function stateTone(state: string): string {
|
function stateTone(state: string): string {
|
||||||
if (state === "ok") return "success";
|
if (state === "ok") return "success";
|
||||||
if (state === "warning") return "warning";
|
if (state === "warning") return "warning";
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-ops.surface.governance": "Governance inventory",
|
"i18n:govoplan-ops.surface.governance": "Governance inventory",
|
||||||
"i18n:govoplan-ops.surface.deployment": "Deployment profiles",
|
"i18n:govoplan-ops.surface.deployment": "Deployment profiles",
|
||||||
"i18n:govoplan-ops.surface.sizing": "Sizing assumptions",
|
"i18n:govoplan-ops.surface.sizing": "Sizing assumptions",
|
||||||
|
"i18n:govoplan-ops.infrastructure_capabilities": "Infrastructure capabilities",
|
||||||
|
"i18n:govoplan-ops.pending_post_install_tasks": "{value0} pending post-install task(s)",
|
||||||
|
"i18n:govoplan-ops.no_infrastructure_capabilities": "No deployment capability receipt is available.",
|
||||||
|
"i18n:govoplan-ops.capability": "Capability",
|
||||||
|
"i18n:govoplan-ops.source": "Source",
|
||||||
|
"i18n:govoplan-ops.endpoint": "Endpoint",
|
||||||
|
"i18n:govoplan-ops.consumers": "Consumers",
|
||||||
"i18n:govoplan-ops.surface.run_probes": "Run operational probes",
|
"i18n:govoplan-ops.surface.run_probes": "Run operational probes",
|
||||||
"i18n:govoplan-ops.surface.drain_node": "Drain runtime node",
|
"i18n:govoplan-ops.surface.drain_node": "Drain runtime node",
|
||||||
"i18n:govoplan-ops.reason.loading": "Operations status is loading.",
|
"i18n:govoplan-ops.reason.loading": "Operations status is loading.",
|
||||||
@@ -80,6 +87,13 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-ops.surface.governance": "Governance-Inventar",
|
"i18n:govoplan-ops.surface.governance": "Governance-Inventar",
|
||||||
"i18n:govoplan-ops.surface.deployment": "Bereitstellungsprofile",
|
"i18n:govoplan-ops.surface.deployment": "Bereitstellungsprofile",
|
||||||
"i18n:govoplan-ops.surface.sizing": "Dimensionierungsannahmen",
|
"i18n:govoplan-ops.surface.sizing": "Dimensionierungsannahmen",
|
||||||
|
"i18n:govoplan-ops.infrastructure_capabilities": "Infrastruktur-Fähigkeiten",
|
||||||
|
"i18n:govoplan-ops.pending_post_install_tasks": "{value0} ausstehende Nachinstallationsaufgabe(n)",
|
||||||
|
"i18n:govoplan-ops.no_infrastructure_capabilities": "Es ist kein Bereitstellungsnachweis für Infrastruktur-Fähigkeiten verfügbar.",
|
||||||
|
"i18n:govoplan-ops.capability": "Fähigkeit",
|
||||||
|
"i18n:govoplan-ops.source": "Quelle",
|
||||||
|
"i18n:govoplan-ops.endpoint": "Endpunkt",
|
||||||
|
"i18n:govoplan-ops.consumers": "Verwendende Module",
|
||||||
"i18n:govoplan-ops.surface.run_probes": "Betriebsprüfungen ausführen",
|
"i18n:govoplan-ops.surface.run_probes": "Betriebsprüfungen ausführen",
|
||||||
"i18n:govoplan-ops.surface.drain_node": "Laufzeitknoten leeren",
|
"i18n:govoplan-ops.surface.drain_node": "Laufzeitknoten leeren",
|
||||||
"i18n:govoplan-ops.reason.loading": "Der Betriebsstatus wird geladen.",
|
"i18n:govoplan-ops.reason.loading": "Der Betriebsstatus wird geladen.",
|
||||||
|
|||||||
Reference in New Issue
Block a user