Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b175658a0f | ||
|
|
f4f09314e0 | ||
|
|
399e1f8e80 | ||
|
|
6ecb94c99e | ||
|
|
f84097224b | ||
|
|
f1b7d0e4ff | ||
|
|
db66320d68 | ||
|
|
de5bf357c9 | ||
|
|
d1114ee637 | ||
|
|
1082a6524d | ||
|
|
3efdee77de | ||
|
|
d26a601dda | ||
|
|
71666db45c | ||
|
|
b09b653987 | ||
|
|
02e5d91d56 | ||
|
|
ce07404405 | ||
|
|
7228ecad52 | ||
|
|
017d93dcbd | ||
|
|
fee7d0e833 | ||
|
|
2b3264372b | ||
|
|
cdcb477b55 | ||
|
|
0b4e601719 | ||
|
|
e5f4021de9 | ||
|
|
e3e0fdaab5 | ||
|
|
63f393afcb | ||
|
|
0105fd49f5 | ||
|
|
1426fd96b1 | ||
|
|
b464d016b2 | ||
|
|
c241085806 | ||
|
|
05ce4dc8ec | ||
|
|
b84cab1aa4 | ||
|
|
1ec47647ab | ||
|
|
2f11f08b72 | ||
|
|
430a48402f | ||
|
|
4d5bb22de5 | ||
|
|
b24eaa12ee | ||
|
|
1e67ec2244 | ||
|
|
341773a4ff |
@@ -0,0 +1,270 @@
|
|||||||
|
name: Module Package Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: Existing protected version tag to publish
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-packages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Select and validate protected release tag
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||||
|
case "$tag" in
|
||||||
|
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||||
|
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||||
|
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||||
|
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||||
|
echo "Release tag is not contained in main" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
git checkout --detach "$tag"
|
||||||
|
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||||
|
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||||
|
- name: Validate package versions
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
tag = os.environ["RELEASE_TAG"]
|
||||||
|
expected = tag.removeprefix("v")
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
if project.get("version") != expected:
|
||||||
|
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||||
|
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||||
|
webui = Path("webui/package.json")
|
||||||
|
if webui.is_file():
|
||||||
|
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||||
|
if package.get("version") != expected:
|
||||||
|
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||||
|
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||||
|
release = Path("webui/package.release.json")
|
||||||
|
if release.is_file():
|
||||||
|
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
release_package.get("name") != package.get("name")
|
||||||
|
or release_package.get("version") != expected
|
||||||
|
):
|
||||||
|
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||||
|
PY
|
||||||
|
- name: Build immutable package artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||||
|
rm -rf dist .package-webui
|
||||||
|
python -m build --wheel --outdir dist
|
||||||
|
python -m twine check dist/*.whl
|
||||||
|
if [[ -f webui/package.json ]]; then
|
||||||
|
mkdir .package-webui
|
||||||
|
cp -a webui/. .package-webui/
|
||||||
|
rm -rf .package-webui/node_modules .package-webui/dist
|
||||||
|
if [[ -f .package-webui/package.release.json ]]; then
|
||||||
|
cp .package-webui/package.release.json .package-webui/package.json
|
||||||
|
fi
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = ".package-webui/package.json";
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||||
|
if (!name.startsWith("@govoplan/")) continue;
|
||||||
|
if (typeof specifier !== "string") {
|
||||||
|
throw new Error(`${group}.${name} must use a string version`);
|
||||||
|
}
|
||||||
|
const packageSlug = name.slice("@govoplan/".length);
|
||||||
|
if (!packageSlug.endsWith("-webui")) {
|
||||||
|
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||||
|
}
|
||||||
|
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||||
|
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const gitTag = specifier.match(
|
||||||
|
new RegExp(
|
||||||
|
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (gitTag) {
|
||||||
|
packageJson[group][name] = gitTag[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||||
|
throw new Error(
|
||||||
|
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete packageJson.private;
|
||||||
|
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
npm pkg delete private --prefix .package-webui
|
||||||
|
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||||
|
fi
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
artifacts = []
|
||||||
|
for path in sorted(Path("dist").iterdir()):
|
||||||
|
if path.suffix not in {".whl", ".tgz"}:
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||||
|
payload = {
|
||||||
|
"schema_version": "1",
|
||||||
|
"repository": os.environ["GITEA_REPOSITORY"],
|
||||||
|
"tag": os.environ["RELEASE_TAG"],
|
||||||
|
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
Path("dist/package-artifacts.json").write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
- name: Retain package hash evidence
|
||||||
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||||
|
with:
|
||||||
|
name: module-packages-${{ gitea.ref_name }}
|
||||||
|
path: dist/package-artifacts.json
|
||||||
|
- name: Check immutable registry state
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||||
|
token = os.environ["PACKAGE_TOKEN"]
|
||||||
|
|
||||||
|
def should_publish(kind, name, version, path):
|
||||||
|
package_url = "/".join(
|
||||||
|
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
package_url,
|
||||||
|
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
files = json.load(response)
|
||||||
|
except HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
print(f"{kind} package {name}=={version} is not published yet")
|
||||||
|
return True
|
||||||
|
raise
|
||||||
|
if not isinstance(files, list) or len(files) != 1:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||||
|
)
|
||||||
|
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
if files[0].get("sha256") != expected_sha256:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||||
|
)
|
||||||
|
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
wheels = tuple(Path("dist").glob("*.whl"))
|
||||||
|
if len(wheels) != 1:
|
||||||
|
raise SystemExit("release build must contain exactly one wheel")
|
||||||
|
publish_pypi = should_publish(
|
||||||
|
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||||
|
if len(tarballs) > 1:
|
||||||
|
raise SystemExit("release build must contain at most one npm package")
|
||||||
|
publish_npm = False
|
||||||
|
if tarballs:
|
||||||
|
webui = json.loads(
|
||||||
|
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
publish_npm = should_publish(
|
||||||
|
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||||
|
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||||
|
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||||
|
PY
|
||||||
|
- name: Publish wheel and WebUI package
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_USERNAME"
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||||
|
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||||
|
python -m twine upload --non-interactive \
|
||||||
|
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||||
|
dist/*.whl
|
||||||
|
else
|
||||||
|
echo "Exact wheel is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
|
shopt -s nullglob
|
||||||
|
webui_packages=(dist/*.tgz)
|
||||||
|
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||||
|
npmrc="$(mktemp)"
|
||||||
|
trap 'rm -f "$npmrc"' EXIT
|
||||||
|
chmod 600 "$npmrc"
|
||||||
|
printf '%s\n' \
|
||||||
|
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||||
|
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||||
|
> "$npmrc"
|
||||||
|
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||||
|
--ignore-scripts --access public \
|
||||||
|
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||||
|
elif (( ${#webui_packages[@]} )); then
|
||||||
|
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
@@ -16,11 +16,13 @@ webui/dist/
|
|||||||
.policy-test-build/
|
.policy-test-build/
|
||||||
.template-preview-test-build/
|
.template-preview-test-build/
|
||||||
.import-test-build/
|
.import-test-build/
|
||||||
|
.runtime-status-test-build/
|
||||||
webui/.component-test-build/
|
webui/.component-test-build/
|
||||||
webui/.module-test-build/
|
webui/.module-test-build/
|
||||||
webui/.policy-test-build/
|
webui/.policy-test-build/
|
||||||
webui/.template-preview-test-build/
|
webui/.template-preview-test-build/
|
||||||
webui/.import-test-build/
|
webui/.import-test-build/
|
||||||
|
webui/.runtime-status-test-build/
|
||||||
|
|
||||||
# GovOPlaN shared ignore rules from govoplan-core
|
# GovOPlaN shared ignore rules from govoplan-core
|
||||||
# ---> Node
|
# ---> Node
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# GovOPlaN Ops Codex Guide
|
# GovOPlaN Ops Codex Guide
|
||||||
|
|
||||||
|
## Documentation Contract
|
||||||
|
|
||||||
|
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||||
|
- Keep feature content here; `govoplan-docs` projects it without importing Ops internals.
|
||||||
|
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This repository owns the GovOPlaN operations module seed: runtime health,
|
This repository owns the GovOPlaN operations module seed: runtime health,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# govoplan-ops
|
# govoplan-ops
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (platform).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
GovOPlaN Ops provides the operator surface for deployment health, runtime
|
GovOPlaN Ops provides the operator surface for deployment health, runtime
|
||||||
profile visibility, worker split assumptions, and sizing guidance.
|
profile visibility, worker split assumptions, and sizing guidance.
|
||||||
|
|
||||||
@@ -9,6 +13,19 @@ This repository owns:
|
|||||||
|
|
||||||
- backend module manifest `ops`
|
- backend module manifest `ops`
|
||||||
- operator-facing status APIs
|
- operator-facing status APIs
|
||||||
|
- runtime-node registration, heartbeat, composition, stale-node, and expected
|
||||||
|
replica projection
|
||||||
|
- queue-specific worker-pool coverage, release/composition skew, and the
|
||||||
|
deployment-rendered PostgreSQL connection budget
|
||||||
|
- audited API and worker drain/cancel controls
|
||||||
|
- recovery-operation status and evidence-chain summaries
|
||||||
|
- sanitized, expiry-aware coordinated backup and isolated-restore status from
|
||||||
|
the deployment verifier, without backup artifact or key-custody references
|
||||||
|
- governance inventory for module-declared permissions, roles, capabilities,
|
||||||
|
policies, documentation, access-control hooks, search providers, and
|
||||||
|
migration ownership
|
||||||
|
- sanitized runtime health, freshness, conflict, and recovery state for
|
||||||
|
configured external-provider bindings
|
||||||
- deployment profile and sizing assumption summaries
|
- deployment profile and sizing assumption summaries
|
||||||
- WebUI route contribution `@govoplan/ops-webui`
|
- WebUI route contribution `@govoplan/ops-webui`
|
||||||
- future operational runbooks that describe the configured platform rather than
|
- future operational runbooks that describe the configured platform rather than
|
||||||
@@ -17,7 +34,40 @@ This repository owns:
|
|||||||
Core owns lifecycle management, module discovery, maintenance mode, package
|
Core owns lifecycle management, module discovery, maintenance mode, package
|
||||||
installation safety, and shared WebUI shell behavior.
|
installation safety, and shared WebUI shell behavior.
|
||||||
|
|
||||||
|
Core exposes the registry contract but does not own an operations dashboard.
|
||||||
|
Ops projects the provider-neutral registry metadata and runtime checks into the
|
||||||
|
operator-facing governance surface.
|
||||||
|
|
||||||
|
Provider declarations describe supported behavior; module-owned runtime-state
|
||||||
|
providers describe the currently configured bindings. An optional provider
|
||||||
|
failure is isolated and reported as an attention state without suppressing the
|
||||||
|
rest of the governance inventory. Secrets, endpoints, and raw provider errors
|
||||||
|
are not part of this projection.
|
||||||
|
|
||||||
## Runbooks
|
## Runbooks
|
||||||
|
|
||||||
- `docs/SCALABILITY_PROFILES.md` explains how to use the Ops page with the
|
- `docs/SCALABILITY_PROFILES.md` explains how to use the Ops page with the
|
||||||
canonical sizing matrix, readiness model, and profile-selection worksheet.
|
canonical sizing matrix, readiness model, and profile-selection worksheet.
|
||||||
|
|
||||||
|
The Runtime cluster panel is backed by Core's shared PostgreSQL coordination
|
||||||
|
tables. A drain request is durable and is observed on the node heartbeat: API
|
||||||
|
readiness closes and workers stop taking new queue work. The Recovery panel
|
||||||
|
shows operations requiring forward recovery or manual intervention; it does not
|
||||||
|
claim that a production database backup exists.
|
||||||
|
For provider effects, Ops is the status and evidence-chain projection rather
|
||||||
|
than the reconciliation authority. Follow the owning module's bounded action:
|
||||||
|
for example, reconcile an unknown Mail SMTP command from provider evidence and
|
||||||
|
never retry the original effect merely because its caller state is incomplete.
|
||||||
|
For Dataflow, a database-only run is atomic. A published-output run is forward
|
||||||
|
recovery: inspect its recorded output digest and sink idempotency key, and do
|
||||||
|
not start another publication while its state is `outcome_unknown`.
|
||||||
|
For Workflow Engine, Ops exposes the action operation and evidence chain while
|
||||||
|
the Workflow handoff remains the reconciliation surface. Verify the provider,
|
||||||
|
then record **Effect confirmed** to continue without replay or **Effect absent**
|
||||||
|
to enable a deliberate retry. Instance, trigger-delivery, and timer leases show
|
||||||
|
which runtime currently owns transition authority across hosts.
|
||||||
|
For Core module lifecycle, the installer run record supplies the operation id.
|
||||||
|
Treat `recovery_required` and `outcome_unknown` as a deployment-wide stop: verify
|
||||||
|
the hashed package/database evidence, complete the declared rollback or forward
|
||||||
|
repair, and reconcile the operation before another install or live graph change.
|
||||||
|
The local installer lock is not a substitute for this database fence.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Backup Evidence Status
|
||||||
|
|
||||||
|
The Ops health surface reports the sanitized result of deployment-side backup
|
||||||
|
verification. It never reads backup artifacts, provider URLs, credentials,
|
||||||
|
encryption-key references, signing keys, or orchestrator APIs.
|
||||||
|
|
||||||
|
The status can be:
|
||||||
|
|
||||||
|
- `verified`: a signed coordinated PostgreSQL, object, configuration, and key
|
||||||
|
custody recovery point and its isolated restore drill are current;
|
||||||
|
- `expired`: the retained receipt is no longer fresh enough to authorize a
|
||||||
|
release-changing migration;
|
||||||
|
- `absent`: no signed receipt has been adopted;
|
||||||
|
- `invalid`: the deployment verifier rejected, lost, or only partially received
|
||||||
|
the evidence set.
|
||||||
|
|
||||||
|
The panel shows the recovery point and drill identifiers, capture/expiry times,
|
||||||
|
component count, and measured RPO/RTO. These values help operators find the
|
||||||
|
private report in the approved evidence store; they are not themselves a
|
||||||
|
backup. Resolve an absent, expired, or invalid state through the deployment
|
||||||
|
runbook and `govoplan-deploy verify-backup --adopt`, then reconcile the runtime
|
||||||
|
environment. Do not upload provider reports or keys through the application.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Ops interface pattern migration
|
||||||
|
|
||||||
|
Ops uses the platform monitoring and governed-operation patterns. It projects sanitized state owned by Core and modules; it does not become the repair authority for domain effects.
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
- `ops.page` is the route-level operations workspace and `ops.navigation` is its navigation entry.
|
||||||
|
- `ops.page.summary` presents readiness, capacity, queue, storage, backup, recovery, provider, and governance metrics.
|
||||||
|
- `ops.page.health` owns bounded module probes; `ops.action.run-probes` is available only to an operations runner.
|
||||||
|
- `ops.page.runtime` owns runtime heartbeats and the confirmed `ops.action.drain-node` lifecycle action.
|
||||||
|
- `ops.page.recovery` presents sanitized durable recovery evidence without replay controls.
|
||||||
|
- `ops.page.governance`, `ops.page.deployment`, and `ops.page.sizing` expose declared architecture, provider, topology, and capacity assumptions.
|
||||||
|
- `ops.widget.health` is a read-only Dashboard projection of the same status endpoint.
|
||||||
|
|
||||||
|
Backend and WebUI manifests publish the same identifiers and parent hierarchy for Views and configured-system Docs.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Running probes performs only bounded, declared operational checks and refreshes the status projection. Draining a node stops new work from being routed to the selected runtime incarnation while in-flight work completes; cancellation is possible while the node remains draining. The shared confirmation dialog names the target and consequence. Stale, stopped, unauthorized, loading, and already-running states remain visible with an explicit reason.
|
||||||
|
|
||||||
|
Ops does not reconcile outcome-unknown work, restore backups, expose secrets, or invoke private sibling-module code. Operators follow the owning module's recovery contract and use Ops only for bounded observation and runtime coordination.
|
||||||
@@ -15,6 +15,12 @@ live Ops page.
|
|||||||
campaigns, imports, exports, or workflow automation.
|
campaigns, imports, exports, or workflow automation.
|
||||||
6. Record the current profile and open measurements before moving to a larger
|
6. Record the current profile and open measurements before moving to a larger
|
||||||
topology.
|
topology.
|
||||||
|
7. In a replicated profile, compare active non-stale API and worker counts with
|
||||||
|
configured expectations, and resolve composition skew before rollout.
|
||||||
|
8. Drain a node before replacement, then verify it is no longer ready/consuming
|
||||||
|
before terminating it.
|
||||||
|
9. Inspect recovery-required and outcome-unknown operations; follow the recorded
|
||||||
|
recovery mode rather than retrying or rolling back blindly.
|
||||||
|
|
||||||
## Live Profile Signals
|
## Live Profile Signals
|
||||||
|
|
||||||
@@ -30,10 +36,45 @@ The Ops API reports:
|
|||||||
- HTTP/certificate deployment posture through the `deployment_security` check
|
- HTTP/certificate deployment posture through the `deployment_security` check
|
||||||
- readiness blockers
|
- readiness blockers
|
||||||
- reference deployment profiles and sizing assumptions
|
- reference deployment profiles and sizing assumptions
|
||||||
|
- runtime node identity, role, software/module composition, queues, heartbeat,
|
||||||
|
stale state, and drain state
|
||||||
|
- configured versus active API and worker replica counts
|
||||||
|
- active worker-pool names, exact queue coverage, and missing queue owners
|
||||||
|
- provider-neutral worker state (`disabled`, `unconfigured`, `starting`,
|
||||||
|
`healthy`, `idle`, `busy`, `degraded`, `stale`, or `unreachable`), with the
|
||||||
|
configured backend, latest heartbeat age, and stale threshold
|
||||||
|
- queue depth, active/reserved work, and failure count only when reported by
|
||||||
|
the provider; unavailable values are never interpreted as zero or healthy
|
||||||
|
- release/module-composition skew and software-version skew across active nodes
|
||||||
|
- rendered PostgreSQL connection peak, declared server limit, and operator reserve
|
||||||
|
- recovery operation status, mode, checkpoint count, and last update
|
||||||
|
|
||||||
These values are intentionally diagnostic. They do not replace deployment
|
These values are intentionally diagnostic. They do not replace deployment
|
||||||
configuration management, backups, monitoring, or restore drills.
|
configuration management, backups, monitoring, or restore drills.
|
||||||
|
|
||||||
|
Drain controls are cooperative. API and worker processes observe the request on
|
||||||
|
their next heartbeat. API readiness then fails; workers cancel their configured
|
||||||
|
queue consumers but may still be finishing already claimed work. Confirm the
|
||||||
|
process state and queue evidence before forcefully terminating a node.
|
||||||
|
|
||||||
|
Recovery evidence is similarly conservative. A forward-recovery or
|
||||||
|
manual-intervention record means an operator must repair the current release or
|
||||||
|
restore a separately verified coordinated backup. Ops does not convert that
|
||||||
|
state into a safe rollback.
|
||||||
|
|
||||||
|
An `outcome_unknown` provider operation must be resolved in its owning module.
|
||||||
|
Mail SMTP and IMAP APPEND operations, for example, carry stable attempt IDs and
|
||||||
|
digest-only evidence; use Mail's command reconciliation with provider evidence.
|
||||||
|
Do not replay the original effect from Ops. Read-only Mail index/source scans
|
||||||
|
are fenced across nodes and may be repeated only after the prior fence closes.
|
||||||
|
|
||||||
|
For the `shared` profile, missing expected replicas, release/composition skew,
|
||||||
|
unserved queues, or an invalid PostgreSQL connection budget are readiness
|
||||||
|
errors. Stale historical records remain visible, but cannot downgrade one of
|
||||||
|
those errors to a warning. A database budget at or above 80 percent is reported
|
||||||
|
as a warning so operators retain room for measured bursts and administrative
|
||||||
|
connections.
|
||||||
|
|
||||||
`deployment_security` is inactive for local/test profiles. In staging or pilot
|
`deployment_security` is inactive for local/test profiles. In staging or pilot
|
||||||
profiles it warns when secure cookies or CORS origins still look local. In
|
profiles it warns when secure cookies or CORS origins still look local. In
|
||||||
production it becomes readiness-critical because TLS certificates, proxy
|
production it becomes readiness-critical because TLS certificates, proxy
|
||||||
@@ -46,6 +87,23 @@ Promote from local development to a production-like profile when a feature
|
|||||||
depends on PostgreSQL, Redis, Celery, module package lifecycle, or durable file
|
depends on PostgreSQL, Redis, Celery, module package lifecycle, or durable file
|
||||||
storage.
|
storage.
|
||||||
|
|
||||||
|
The Operations page is the canonical monitoring surface. Its summary reports
|
||||||
|
worker and queue coverage, queue depth, active tasks, local filesystem capacity
|
||||||
|
when observable, backup/restore evidence, runtime-node skew, and recovery-ledger
|
||||||
|
operations split into active, failed, recovery-required, and outcome-unknown
|
||||||
|
states. S3 capacity remains provider-owned unless a configured module check
|
||||||
|
supplies bounded usage metrics; Ops must not enumerate an object store merely to
|
||||||
|
render a dashboard.
|
||||||
|
|
||||||
|
The WebUI polls this read-only projection every 15 seconds only while its page
|
||||||
|
is visible. It permits one request at a time, stops the timer when the document
|
||||||
|
is hidden, and performs one refresh when visibility returns. Runtime providers
|
||||||
|
register through the Core contract; Ops itself does not import Celery, Redis,
|
||||||
|
or module-owned job implementations. A local development profile may
|
||||||
|
intentionally disable workers without becoming unready. A production or other
|
||||||
|
non-development profile treats a disabled, unconfigured, stale, or unreachable
|
||||||
|
provider as readiness-critical.
|
||||||
|
|
||||||
Promote from a single-process profile to a split-worker profile when queued
|
Promote from a single-process profile to a split-worker profile when queued
|
||||||
work becomes part of normal operation:
|
work becomes part of normal operation:
|
||||||
|
|
||||||
@@ -93,23 +151,26 @@ Stateless and horizontally replicable:
|
|||||||
- API workers when `MASTER_KEY_B64`, `DATABASE_URL`, storage, queue, and module
|
- API workers when `MASTER_KEY_B64`, `DATABASE_URL`, storage, queue, and module
|
||||||
configuration are shared
|
configuration are shared
|
||||||
- Background workers when queues and idempotency keys are used
|
- Background workers when queues and idempotency keys are used
|
||||||
- Scheduler replicas only when leader election or an external lock exists
|
- Scheduler processes only through Core's renewable PostgreSQL lease and
|
||||||
|
fencing-token runner; deploy one desired scheduler replica
|
||||||
|
|
||||||
Stateful or singleton-sensitive:
|
Stateful or singleton-sensitive:
|
||||||
|
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- local file storage when not replaced by object storage
|
- local file storage when not replaced by object storage (or a one-host shared
|
||||||
|
volume under the `host-shared` profile)
|
||||||
- Redis/queue state
|
- Redis/queue state
|
||||||
- module installer daemon and package mutation operations
|
- module installer daemon and package mutation operations
|
||||||
- migration execution
|
- migration execution
|
||||||
- scheduler without distributed locking
|
- migration execution, although competing jobs are serialized by a PostgreSQL
|
||||||
|
advisory lock
|
||||||
- outgoing campaign append/send jobs unless claim tokens are enforced
|
- outgoing campaign append/send jobs unless claim tokens are enforced
|
||||||
|
|
||||||
## Readiness And Degraded Modes
|
## Readiness And Degraded Modes
|
||||||
|
|
||||||
| Component | Ready When | Degraded Mode |
|
| Component | Ready When | Degraded Mode |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| API | Database reachable, migrations current, enabled module registry builds, maintenance mode understood | Read-only/admin-only where routes allow it; otherwise fail closed. |
|
| API | Database reachable, migrations current, enabled module registry builds, maintenance mode understood, and the node is not draining | Read-only/admin-only where routes allow it; otherwise fail closed. |
|
||||||
| WebUI | Static assets match backend module metadata contract | Show unavailable modules/routes with reason; do not invent routes. |
|
| WebUI | Static assets match backend module metadata contract | Show unavailable modules/routes with reason; do not invent routes. |
|
||||||
| PostgreSQL | Accepts connections and migration head is current | Block writes and package changes if migration state is unknown. |
|
| PostgreSQL | Accepts connections and migration head is current | Block writes and package changes if migration state is unknown. |
|
||||||
| Storage | Configured backend is reachable and writable for write flows | Read-only file views may continue if storage is read-only but reachable. |
|
| Storage | Configured backend is reachable and writable for write flows | Read-only file views may continue if storage is read-only but reachable. |
|
||||||
@@ -148,6 +209,22 @@ make failures worse.
|
|||||||
PostgreSQL is the production database. SQLite remains a local-development and
|
PostgreSQL is the production database. SQLite remains a local-development and
|
||||||
tiny disposable profile only.
|
tiny disposable profile only.
|
||||||
|
|
||||||
|
Files avoids SQLite's second-writer deadlock by recording blob recovery intent
|
||||||
|
inside the caller transaction. Handled rollback reconstructs durable recovery
|
||||||
|
evidence, but a hard process loss before commit can leave an object without an
|
||||||
|
Ops ledger row. Run a complete Files integrity scan after such a loss and
|
||||||
|
reconcile reported orphans before resuming writes. PostgreSQL retains the
|
||||||
|
independent pre-effect recovery-intent guarantee required for production.
|
||||||
|
|
||||||
|
Files hard-purge and S3 connector-write operations use independently durable
|
||||||
|
Core recovery records and distributed fences. A purge releases database
|
||||||
|
references before a separate, reference-checked blob-GC operation deletes
|
||||||
|
bytes. An S3 write records only an opaque target digest plus request/content
|
||||||
|
evidence and verifies provider metadata after its conditional effect. When Ops
|
||||||
|
shows `outcome_unknown` or `recovery_required`, do not replay the action from
|
||||||
|
Ops: reconcile the exact blob reference/object or S3 request/content markers
|
||||||
|
through the owning Files workflow first.
|
||||||
|
|
||||||
Production migrations should run explicitly before startup or package
|
Production migrations should run explicitly before startup or package
|
||||||
activation. Module install/uninstall workflows must use database backup and
|
activation. Module install/uninstall workflows must use database backup and
|
||||||
restore-check hooks for PostgreSQL before migrations or destructive retirement.
|
restore-check hooks for PostgreSQL before migrations or destructive retirement.
|
||||||
|
|||||||
+7
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/ops-webui",
|
"name": "@govoplan/ops-webui",
|
||||||
"version": "0.1.7",
|
"version": "0.1.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -17,14 +17,14 @@
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.7",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router": ">=8.3.0 <9",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
+3
-3
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-ops"
|
name = "govoplan-ops"
|
||||||
version = "0.1.7"
|
version = "0.1.20"
|
||||||
description = "GovOPlaN operations module for health, deployment profile, and sizing visibility."
|
description = "GovOPlaN operations module for health, deployment profile, and sizing visibility."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.7",
|
"govoplan-core>=0.1.18",
|
||||||
"govoplan-access>=0.1.7",
|
"govoplan-access>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.6"
|
__version__ = "0.1.20"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'ops.health-governance-and-sizing': {'consequence_classes': {'run_probes': 'Führen Sie begrenzte '
|
||||||
|
'moduleigene '
|
||||||
|
'Gesundheitssonden aus '
|
||||||
|
'und aktualisieren Sie '
|
||||||
|
'die sanierte '
|
||||||
|
'Betriebsprojektion'}},
|
||||||
|
'ops.runtime-coordination-and-recovery': {'consequence_classes': {'cancel_node_drain': 'Rückkehr '
|
||||||
|
'eines '
|
||||||
|
'noch '
|
||||||
|
'ablaufenden '
|
||||||
|
'Laufzeitknotens '
|
||||||
|
'zu Active '
|
||||||
|
'Scheduling',
|
||||||
|
'drain_node': 'Beenden Sie die '
|
||||||
|
'Weiterleitung '
|
||||||
|
'neuer Arbeiten '
|
||||||
|
'an die '
|
||||||
|
'ausgewählte '
|
||||||
|
'Laufzeitinkarnation, '
|
||||||
|
'während die '
|
||||||
|
'Arbeiten während '
|
||||||
|
'des Fluges '
|
||||||
|
'abgeschlossen '
|
||||||
|
'sind',
|
||||||
|
'inspect_recovery': 'Lesen Sie '
|
||||||
|
'den '
|
||||||
|
'sanierten '
|
||||||
|
'dauerhaften '
|
||||||
|
'Wiederherstellungszustand, '
|
||||||
|
'ohne den '
|
||||||
|
'Besitzeffekt '
|
||||||
|
'zu '
|
||||||
|
'wiederholen '
|
||||||
|
'oder zu '
|
||||||
|
'reparieren'},
|
||||||
|
'limitations': ['Drain wird im '
|
||||||
|
'Laufzeit-Herzschlagintervall '
|
||||||
|
'beobachtet und beendet die aktive '
|
||||||
|
'Arbeit nicht zwangsweise.',
|
||||||
|
'Ops erstellt oder stellt keine Backups '
|
||||||
|
'her und erhält niemals private '
|
||||||
|
'Artefakte oder '
|
||||||
|
'Schlüssel-Depotreferenzen.',
|
||||||
|
'SQLite ist ein rein '
|
||||||
|
'entwicklungsbezogenes '
|
||||||
|
'Wiederherstellungsprofil; Dateien, die '
|
||||||
|
'vor einem unbehandelten Prozessverlust '
|
||||||
|
'erstellt wurden, erfordern '
|
||||||
|
'möglicherweise eine Erkennung durch '
|
||||||
|
'Integritätsscan, da die Absicht der '
|
||||||
|
'Anrufertransaktion nicht festgelegt '
|
||||||
|
'wurde.',
|
||||||
|
'Eine verifizierte Quittung beweist die '
|
||||||
|
'aufgezeichnete Übung; es macht keinen '
|
||||||
|
'unsicheren '
|
||||||
|
'Post-Migrationscode-Rollback '
|
||||||
|
'reversibel.'],
|
||||||
|
'steps': ['Vergleichen Sie aktive nicht-stale Knoten '
|
||||||
|
'mit den konfigurierten API- und '
|
||||||
|
'Worker-Replica-Erwartungen.',
|
||||||
|
'Fordern Sie die Entleerung an und warten '
|
||||||
|
'Sie, bis der Knoten die Entleerung meldet, '
|
||||||
|
'bevor Sie ihn ersetzen.',
|
||||||
|
'Überprüfen Sie alle '
|
||||||
|
'Wiederherstellungs-erforderlichen, '
|
||||||
|
'ergebnisunbekannten oder manuellen Eingriffe '
|
||||||
|
'und folgen Sie dem aufgezeichneten '
|
||||||
|
'Wiederherstellungsmodus.',
|
||||||
|
'Unterscheiden Sie bei Dateien zwischen Blob '
|
||||||
|
'Upload/Reparatur, genehmigtem Hard Purge, '
|
||||||
|
'Blob Garbage Collection und '
|
||||||
|
'S3-Connector-Rückschreibung, bevor Sie die '
|
||||||
|
'Eigentümerdatenbank/das Objekt oder den '
|
||||||
|
'Anbieternachweis überprüfen.',
|
||||||
|
'Bestätigen Sie, dass der Backup-Beweis '
|
||||||
|
'verifiziert und aktuell ist, bevor Sie eine '
|
||||||
|
'Release-Änderung der Migration genehmigen.',
|
||||||
|
'Überprüfen Sie Ersatzzusammensetzung, '
|
||||||
|
'Bereitschaft, Warteschlangenverbraucher und '
|
||||||
|
'Wiederherstellungsnachweise, bevor Sie den '
|
||||||
|
'Vorgang abschließen.']}}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_core.core.infrastructure_capabilities import (
|
||||||
|
deployment_capability_status as _deployment_capability_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def deployment_capability_status(
|
||||||
|
path: Path | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Project Core's validated non-secret deployment receipt into Ops."""
|
||||||
|
|
||||||
|
return _deployment_capability_status(path)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["deployment_capability_status"]
|
||||||
@@ -1,10 +1,74 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
from govoplan_ops.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ModuleArchitectureDeclaration,
|
||||||
|
ModuleArchitectureDocumentation,
|
||||||
|
ModuleMaturityEvidence,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.operations import RuntimeWorkStatusProviderRegistration
|
||||||
|
from govoplan_core.core.runtime_work import celery_runtime_work_status
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
|
||||||
OPS_READ_SCOPE = "ops:operations:read"
|
OPS_READ_SCOPE = "ops:operations:read"
|
||||||
OPS_READ_SCOPES = (OPS_READ_SCOPE, "system:settings:read", "admin:settings:read")
|
OPS_READ_SCOPES = (OPS_READ_SCOPE, "system:settings:read", "admin:settings:read")
|
||||||
|
OPS_RUN_SCOPE = "ops:operations:run"
|
||||||
|
OPS_RUN_SCOPES = (OPS_RUN_SCOPE, "system:settings:write")
|
||||||
|
|
||||||
|
ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||||
|
layer="runtime_meta",
|
||||||
|
kind="operations",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
evidence=(
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="test",
|
||||||
|
reference="tests/test_governance_inventory.py",
|
||||||
|
summary="Tests safe operational projection of module governance declarations.",
|
||||||
|
),
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="documentation",
|
||||||
|
reference="docs/SCALABILITY_PROFILES.md",
|
||||||
|
summary="Documents operational topology and scaling posture.",
|
||||||
|
),
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="documentation",
|
||||||
|
reference="docs/BACKUP_EVIDENCE_STATUS.md",
|
||||||
|
summary="Documents the sanitized signed backup and restore status projection.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
known_limits=(
|
||||||
|
"Provider health observations depend on module-owned operational probes and may be unavailable until configured.",
|
||||||
|
),
|
||||||
|
owned_concepts=("operations status projection", "bounded operational probes"),
|
||||||
|
non_owned_concepts=("domain repair", "external provider credentials"),
|
||||||
|
documentation=ModuleArchitectureDocumentation(
|
||||||
|
recovery=(
|
||||||
|
"docs/SCALABILITY_PROFILES.md",
|
||||||
|
"docs/BACKUP_EVIDENCE_STATUS.md",
|
||||||
|
),
|
||||||
|
operations=(
|
||||||
|
"docs/SCALABILITY_PROFILES.md",
|
||||||
|
"docs/BACKUP_EVIDENCE_STATUS.md",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
@@ -31,8 +95,11 @@ def _route_factory(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="ops",
|
id="ops",
|
||||||
name="Ops",
|
name="Ops",
|
||||||
version="0.1.7",
|
version="0.1.20",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
optional_dependencies=("audit", "docs", "notifications"),
|
optional_dependencies=("audit", "docs", "notifications"),
|
||||||
permissions=(
|
permissions=(
|
||||||
_permission(
|
_permission(
|
||||||
@@ -40,6 +107,11 @@ manifest = ModuleManifest(
|
|||||||
"View operations status",
|
"View operations status",
|
||||||
"Read runtime health, deployment profile, and sizing information.",
|
"Read runtime health, deployment profile, and sizing information.",
|
||||||
),
|
),
|
||||||
|
_permission(
|
||||||
|
OPS_RUN_SCOPE,
|
||||||
|
"Run operational checks",
|
||||||
|
"Run bounded module-owned persistence and integration probes.",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
role_templates=(
|
role_templates=(
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
@@ -49,15 +121,263 @@ manifest = ModuleManifest(
|
|||||||
permissions=(OPS_READ_SCOPE,),
|
permissions=(OPS_READ_SCOPE,),
|
||||||
level="system",
|
level="system",
|
||||||
),
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="ops_operator",
|
||||||
|
name="Operations operator",
|
||||||
|
description="Read platform health and run bounded operational probes.",
|
||||||
|
permissions=(OPS_READ_SCOPE, OPS_RUN_SCOPE),
|
||||||
|
level="system",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
runtime_work_status_providers=(
|
||||||
|
RuntimeWorkStatusProviderRegistration(
|
||||||
|
module_id="ops",
|
||||||
|
provider_id="core.celery",
|
||||||
|
provider=celery_runtime_work_status,
|
||||||
|
cache_seconds=15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="ops.health-governance-and-sizing",
|
||||||
|
title="Inspect platform health and deployment posture",
|
||||||
|
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. Worker and queue providers use a Core runtime-status contract, so Ops never imports a provider backend. The surface distinguishes intentionally disabled, unconfigured, starting, healthy with unsupported queue depth, measured idle, busy, degraded, stale, and unreachable states. It shows enabled/configured state, backend, workers, heartbeat age and stale threshold, queue depth, active/reserved work, and failures only when each value is actually reported; unavailable values are never rendered as zero or healthy. Local development treats intentionally disabled workers as expected, while production profiles require an enabled, configured, reachable provider before queue-backed work is accepted. Polling is bounded to one request, pauses while the page is hidden, and refreshes on return. 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"),
|
||||||
|
audience=("operator", "system_admin"),
|
||||||
|
related_modules=("audit", "docs", "notifications"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Plattformzustand und Bereitstellungsprofil prüfen",
|
||||||
|
"summary": "Ops führt modulbezogene Zustandsprüfungen mit Bereitstellungsprofil, Governance-Inventar, Worker-Annahmen und Dimensionierungshinweisen zusammen.",
|
||||||
|
"body": (
|
||||||
|
"Der schreibgeschützte Status unterscheidet konfigurierte Fähigkeiten von funktionsfähigen Integrationen. "
|
||||||
|
"Worker- und Warteschlangenanbieter verwenden einen Core-Vertrag für den Laufzeitstatus, sodass Ops niemals ein Anbieter-Backend importiert. "
|
||||||
|
"Die Oberfläche unterscheidet bewusst deaktivierte, nicht konfigurierte, startende, gesunde, untätige, ausgelastete, beeinträchtigte, veraltete und nicht erreichbare Zustände; eine nicht unterstützte Warteschlangentiefe wird ausdrücklich ausgewiesen. "
|
||||||
|
"Aktivierung, Konfiguration, Backend, Worker, Alter und Grenzwert des Heartbeats, Warteschlangentiefe, aktive oder reservierte Arbeit sowie Fehler werden nur angezeigt, wenn der Anbieter den jeweiligen Wert tatsächlich meldet; fehlende Werte erscheinen niemals als null oder gesund. "
|
||||||
|
"In der lokalen Entwicklung sind bewusst deaktivierte Worker zulässig, während Produktionsprofile einen aktivierten, konfigurierten und erreichbaren Anbieter verlangen, bevor warteschlangengestützte Arbeit angenommen wird. "
|
||||||
|
"Die Abfrage bleibt auf eine Anfrage je Intervall begrenzt, pausiert bei ausgeblendeter Seite und wird bei der Rückkehr fortgesetzt. "
|
||||||
|
"Ist ein signierter oder lokal erzeugter Infrastrukturbeleg ohne Geheimwerte eingebunden, zeigt Ops für PostgreSQL, Redis, SMTP, Dateispeicher, Lastverteilung und Ingress, ob die Fähigkeit konfiguriert, extern bereitgestellt, verfügbar aber nicht konfiguriert oder nicht verfügbar ist. "
|
||||||
|
"Geheimwerte überschreiten diese Grenze nie; offengelegt werden dürfen nur stabile Umgebungs- oder Credential-Envelope-Referenzen. "
|
||||||
|
"Ausstehende Aufgaben nach einer Installation bleiben mit einem stabilen Fortsetzungsschlüssel sichtbar. Autorisierte Betriebsverantwortliche dürfen begrenzte Prüfungen ausführen; eine Prüfung darf weder unbegrenzte Facharbeit auslösen noch Daten stillschweigend reparieren. "
|
||||||
|
"Nutzen Sie Bereitschafts- und Worker-Ergebnisse zur Diagnose eines Knotens sowie Bereitstellungsprofil und Dimensionierungsannahmen zur Planung horizontaler Kapazität."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"ops.page",
|
||||||
|
"ops.page.summary",
|
||||||
|
"ops.page.health",
|
||||||
|
"ops.page.runtime",
|
||||||
|
"ops.page.governance",
|
||||||
|
"ops.page.deployment",
|
||||||
|
"ops.page.sizing",
|
||||||
|
"ops.widget.health",
|
||||||
|
"ops.state.read-only",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"run_probes": "run bounded module-owned health probes and refresh the sanitized operational projection",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="ops.runtime-coordination-and-recovery",
|
||||||
|
title="Drain runtime nodes and inspect recovery evidence",
|
||||||
|
summary="Ops projects shared runtime heartbeats, replica gaps, drain controls, and recovery states that require operator attention.",
|
||||||
|
body="Use the runtime table to identify stale or composition-skewed API and worker replicas. Drain before replacement so API readiness closes and workers stop taking new queue work; cancellation is available while the node is still draining. The recovery table reports durable Core recovery operations. A rejected operation is a verified provider rejection and needs no recovery; outcome-unknown and recovery-required operations still require reconciliation through the owning module. Core module-lifecycle entries block every later install or live graph change: use the installer run id to verify package, backup, migration, and health evidence before rollback or forward repair. Mail SMTP and IMAP APPEND entries use stable attempt identifiers and digest-only evidence: reconcile the Mail command from provider evidence, never by replaying the original effect from Ops. Files blob writes, hard purge, reference-checked garbage collection, and conditional S3 connector writes record Core recovery evidence. For a Files connector outcome, inspect the provider request/content markers and revision before allowing another write to the fenced path; for blob GC, recheck FileVersion references and exact object absence. Development SQLite can show only handled-rollback reconstruction for caller-transaction blob uploads; after a hard SQLite process loss, run the owning Files integrity scan because an orphan may have no Ops ledger row. Dataflow database-only runs are atomic, while published-output runs use forward recovery: reconcile the recorded output digest and sink idempotency key before allowing another publication. Backup status separately projects only the sanitized deployment verification receipt: a verified status identifies a coordinated recovery point and isolated restore drill, while absent, expired, or invalid evidence blocks a release-changing migration.",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("operator", "system_admin"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("ops",),
|
||||||
|
any_scopes=OPS_READ_SCOPES,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("audit", "files", "notifications"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Laufzeitknoten leeren und Wiederherstellungsnachweise prüfen",
|
||||||
|
"summary": "Ops projiziert gemeinsame Laufzeit-Heartbeats, Replikatlücken, Leerungssteuerung und Wiederherstellungszustände, die betriebliche Aufmerksamkeit erfordern.",
|
||||||
|
"body": (
|
||||||
|
"Verwenden Sie die Laufzeittabelle, um veraltete API- und Worker-Replikate oder Replikate mit abweichender Modulzusammensetzung zu erkennen. "
|
||||||
|
"Leeren Sie einen Knoten vor dem Austausch, damit seine API-Bereitschaft geschlossen wird und Worker keine neue Warteschlangenarbeit annehmen; solange der Knoten noch geleert wird, kann der Vorgang abgebrochen werden. "
|
||||||
|
"Die Wiederherstellungstabelle zeigt dauerhafte Core-Wiederherstellungsvorgänge. Eine abgelehnte Operation ist eine bestätigte Ablehnung des Anbieters und benötigt keine Wiederherstellung; Vorgänge mit unbekanntem Ergebnis oder erforderlicher Wiederherstellung müssen weiterhin im besitzenden Modul abgeglichen werden. "
|
||||||
|
"Core-Einträge zum Modullebenszyklus sperren jede spätere Installation oder Änderung des laufenden Modulgraphen: Prüfen Sie anhand der Installer-Lauf-ID Paket-, Sicherungs-, Migrations- und Zustandsnachweise, bevor Sie zurückrollen oder vorwärts reparieren. "
|
||||||
|
"SMTP- und IMAP-APPEND-Einträge von Mail verwenden stabile Versuchskennungen und ausschließlich Digest-Nachweise; gleichen Sie den Mail-Befehl mit Anbieternachweisen ab und wiederholen Sie niemals die ursprüngliche Wirkung aus Ops. "
|
||||||
|
"Files erfasst Core-Wiederherstellungsnachweise für Blob-Schreibvorgänge, genehmigte harte Löschung, referenzgeprüfte Speicherbereinigung und bedingte S3-Connector-Schreibvorgänge. Prüfen Sie bei einem Files-Connector-Ergebnis Anbieteranfrage, Inhaltsmerkmale und Revision, bevor ein weiterer Schreibvorgang auf den gesperrten Pfad zugelassen wird; prüfen Sie bei der Blob-Bereinigung erneut FileVersion-Referenzen und die genaue Abwesenheit des Objekts. "
|
||||||
|
"SQLite für die Entwicklung kann bei Blob-Uploads innerhalb einer aufrufenden Transaktion nur die Rekonstruktion behandelter Rollbacks zeigen; nach einem harten Prozessverlust muss die Files-Integritätsprüfung ausgeführt werden, weil ein verwaistes Objekt ohne Ops-Ledger-Eintrag existieren kann. "
|
||||||
|
"Reine Datenbankläufe von Dataflow sind atomar, während Läufe mit veröffentlichten Ausgaben vorwärts repariert werden: Gleichen Sie den erfassten Ausgabedigest und den Idempotenzschlüssel des Ziels ab, bevor eine weitere Veröffentlichung erlaubt wird. "
|
||||||
|
"Der Sicherungsstatus projiziert getrennt nur den bereinigten Bereitstellungsbeleg: Ein verifizierter Status weist einen koordinierten Wiederherstellungspunkt und eine isolierte Wiederherstellungsprobe nach; fehlende, abgelaufene oder ungültige Nachweise sperren eine migrationsbedingte Release-Änderung."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/ops",
|
||||||
|
"screen": "Runtime cluster and recovery evidence",
|
||||||
|
"steps": [
|
||||||
|
"Compare active non-stale nodes with the configured API and worker replica expectations.",
|
||||||
|
"Request drain and wait for the node to report draining before replacing it.",
|
||||||
|
"Inspect every recovery-required, outcome-unknown, or manual-intervention operation and follow its recorded recovery mode.",
|
||||||
|
"For Files, distinguish blob upload/repair, approved hard purge, blob garbage collection, and S3 connector write-back before checking the owning database/object or provider evidence.",
|
||||||
|
"Confirm that backup evidence is verified and current before authorizing a release-changing migration.",
|
||||||
|
"Verify replacement composition, readiness, queue consumers, and recovery evidence before closing the operation.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Drain is observed on the runtime heartbeat interval and does not forcibly terminate active work.",
|
||||||
|
"Ops does not create or restore backups and never receives private artifact or key-custody references.",
|
||||||
|
"SQLite is a development-only recovery profile; Files objects created before an unhandled process loss may require integrity-scan discovery because the caller-transaction intent was not committed.",
|
||||||
|
"A verified receipt proves the recorded drill; it does not make an unsafe post-migration code rollback reversible.",
|
||||||
|
],
|
||||||
|
"help_contexts": [
|
||||||
|
"ops.page.runtime",
|
||||||
|
"ops.page.recovery",
|
||||||
|
"ops.action.drain-node",
|
||||||
|
"ops.state.readiness-blocked",
|
||||||
|
"ops.state.stale-node",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"drain_node": "stop routing new work to the selected runtime incarnation while in-flight work completes",
|
||||||
|
"cancel_node_drain": "return a still-draining runtime node to active scheduling",
|
||||||
|
"inspect_recovery": "read sanitized durable recovery state without replaying or repairing the owning effect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
nav_items=(NavItem(path="/ops", label="Ops", icon="activity", required_any=OPS_READ_SCOPES, order=890),),
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/ops",
|
||||||
|
label="Ops",
|
||||||
|
icon="activity",
|
||||||
|
required_any=OPS_READ_SCOPES,
|
||||||
|
order=890,
|
||||||
|
),
|
||||||
|
),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="ops",
|
module_id="ops",
|
||||||
package_name="@govoplan/ops-webui",
|
package_name="@govoplan/ops-webui",
|
||||||
routes=(FrontendRoute(path="/ops", component="OpsPage", required_any=OPS_READ_SCOPES, order=890),),
|
routes=(
|
||||||
nav_items=(NavItem(path="/ops", label="Ops", icon="activity", required_any=OPS_READ_SCOPES, order=890),),
|
FrontendRoute(
|
||||||
|
path="/ops",
|
||||||
|
component="OpsPage",
|
||||||
|
required_any=OPS_READ_SCOPES,
|
||||||
|
order=890,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/ops",
|
||||||
|
label="Ops",
|
||||||
|
icon="activity",
|
||||||
|
required_any=OPS_READ_SCOPES,
|
||||||
|
order=890,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.navigation",
|
||||||
|
module_id="ops",
|
||||||
|
kind="navigation",
|
||||||
|
label="Operations navigation",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.page",
|
||||||
|
module_id="ops",
|
||||||
|
kind="route",
|
||||||
|
label="Operations workspace",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.page.summary",
|
||||||
|
module_id="ops",
|
||||||
|
kind="section",
|
||||||
|
label="Operations summary",
|
||||||
|
parent_id="ops.page",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.page.health",
|
||||||
|
module_id="ops",
|
||||||
|
kind="section",
|
||||||
|
label="Health checks",
|
||||||
|
parent_id="ops.page",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.page.runtime",
|
||||||
|
module_id="ops",
|
||||||
|
kind="section",
|
||||||
|
label="Runtime cluster",
|
||||||
|
parent_id="ops.page",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.page.recovery",
|
||||||
|
module_id="ops",
|
||||||
|
kind="section",
|
||||||
|
label="Recovery evidence",
|
||||||
|
parent_id="ops.page",
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.page.governance",
|
||||||
|
module_id="ops",
|
||||||
|
kind="section",
|
||||||
|
label="Governance inventory",
|
||||||
|
parent_id="ops.page",
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.page.deployment",
|
||||||
|
module_id="ops",
|
||||||
|
kind="section",
|
||||||
|
label="Deployment profiles",
|
||||||
|
parent_id="ops.page",
|
||||||
|
order=60,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.page.sizing",
|
||||||
|
module_id="ops",
|
||||||
|
kind="section",
|
||||||
|
label="Sizing assumptions",
|
||||||
|
parent_id="ops.page",
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.action.run-probes",
|
||||||
|
module_id="ops",
|
||||||
|
kind="action",
|
||||||
|
label="Run operational probes",
|
||||||
|
parent_id="ops.page.health",
|
||||||
|
order=80,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.action.drain-node",
|
||||||
|
module_id="ops",
|
||||||
|
kind="action",
|
||||||
|
label="Drain runtime node",
|
||||||
|
parent_id="ops.page.runtime",
|
||||||
|
order=90,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="ops.widget.health",
|
||||||
|
module_id="ops",
|
||||||
|
kind="section",
|
||||||
|
label="Operations health widget",
|
||||||
|
order=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
architecture=ARCHITECTURE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationTopic,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleManifest,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ExternalProviderDeclaration,
|
||||||
|
ModuleArchitectureDeclaration,
|
||||||
|
ModuleArchitectureDocumentation,
|
||||||
|
ModuleMaturityEvidence,
|
||||||
|
ProviderBehaviorDeclaration,
|
||||||
|
ProviderObjectDeclaration,
|
||||||
|
)
|
||||||
|
|
||||||
|
from govoplan_ops.backend.api.v1.routes import _governance_inventory
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceInventoryTests(unittest.TestCase):
|
||||||
|
def test_inventory_projects_manifest_governance_without_provider_internals(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1.2.3",
|
||||||
|
permissions=(
|
||||||
|
PermissionDefinition(
|
||||||
|
scope="example:item:read",
|
||||||
|
label="Read examples",
|
||||||
|
description="Read example records.",
|
||||||
|
category="Examples",
|
||||||
|
level="tenant",
|
||||||
|
module_id="example",
|
||||||
|
resource="item",
|
||||||
|
action="read",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="example_reader",
|
||||||
|
name="Example reader",
|
||||||
|
description="Reads examples.",
|
||||||
|
permissions=("example:item:read",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
"example.lookup": lambda _context: object(),
|
||||||
|
"policy.example": lambda _context: object(),
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
"example.lookup": CapabilityDocumentation(
|
||||||
|
label="Example lookup",
|
||||||
|
summary="Resolves examples.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="example.reference",
|
||||||
|
title="Example reference",
|
||||||
|
summary="Documents the example module.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migration_spec=MigrationSpec(module_id="example"),
|
||||||
|
architecture=ModuleArchitectureDeclaration(
|
||||||
|
layer="governance_accountability",
|
||||||
|
kind="governance",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
evidence=(
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="test",
|
||||||
|
reference="tests/test_governance_inventory.py",
|
||||||
|
summary="Exercises the governance projection.",
|
||||||
|
),
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="documentation",
|
||||||
|
reference="example.reference",
|
||||||
|
summary="Documents the example provider.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
known_limits=("The example has no runtime implementation.",),
|
||||||
|
supported_authority_modes=("external_mirror",),
|
||||||
|
owned_concepts=("example projections",),
|
||||||
|
documentation=ModuleArchitectureDocumentation(
|
||||||
|
operations=("example.reference",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
external_providers=(
|
||||||
|
ExternalProviderDeclaration(
|
||||||
|
id="example.read",
|
||||||
|
module_id="example",
|
||||||
|
label="Example reader",
|
||||||
|
maturity="read",
|
||||||
|
operations=("read", "search"),
|
||||||
|
objects=(
|
||||||
|
ProviderObjectDeclaration(
|
||||||
|
object_type="example.record",
|
||||||
|
field_groups=("identity", "summary"),
|
||||||
|
authority_modes=("external_mirror",),
|
||||||
|
default_authority_mode="external_mirror",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
behavior=ProviderBehaviorDeclaration(
|
||||||
|
freshness="Request-time read with a five-minute cache.",
|
||||||
|
health="Reported through example.health.",
|
||||||
|
max_read_items=100,
|
||||||
|
outage="Reads fail closed and retain the last inspected snapshot.",
|
||||||
|
classifications=("internal",),
|
||||||
|
purposes=("operations",),
|
||||||
|
retention="No provider-owned payload is retained.",
|
||||||
|
),
|
||||||
|
documentation_topic_ids=("example.reference",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
payload = _governance_inventory(
|
||||||
|
registry,
|
||||||
|
external_provider_states={
|
||||||
|
"example.read": {
|
||||||
|
"configured": True,
|
||||||
|
"active": True,
|
||||||
|
"health": "warning",
|
||||||
|
"freshness": "stale",
|
||||||
|
"recovery": "attention",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(payload["summary"]["module_count"], 1)
|
||||||
|
self.assertEqual(payload["summary"]["permission_count"], 1)
|
||||||
|
self.assertEqual(payload["summary"]["role_template_count"], 1)
|
||||||
|
self.assertEqual(payload["summary"]["capability_count"], 2)
|
||||||
|
self.assertEqual(payload["summary"]["policy_count"], 1)
|
||||||
|
self.assertEqual(payload["summary"]["documented_module_count"], 1)
|
||||||
|
self.assertEqual(payload["summary"]["migration_module_count"], 1)
|
||||||
|
self.assertEqual(payload["summary"]["architecture_declared_module_count"], 1)
|
||||||
|
self.assertEqual(payload["summary"]["external_provider_count"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
payload["summary"]["configured_external_provider_count"],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["summary"]["provider_attention_count"], 1)
|
||||||
|
self.assertEqual(payload["modules"][0]["architecture"]["maturity"], "vertical_slice")
|
||||||
|
self.assertEqual(payload["modules"][0]["external_providers"][0]["id"], "example.read")
|
||||||
|
self.assertEqual(
|
||||||
|
payload["modules"][0]["external_providers"][0]["runtime_state"][
|
||||||
|
"freshness"
|
||||||
|
],
|
||||||
|
"stale",
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["modules"][0]["module_id"], "example")
|
||||||
|
self.assertNotIn("capability_factories", payload["modules"][0])
|
||||||
|
self.assertFalse(any(
|
||||||
|
callable(value)
|
||||||
|
for value in payload["modules"][0].values()
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_ops.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class OpsInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
|
||||||
|
frontend = get_manifest().frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
|
||||||
|
expected = {
|
||||||
|
"ops.navigation",
|
||||||
|
"ops.page",
|
||||||
|
"ops.page.summary",
|
||||||
|
"ops.page.health",
|
||||||
|
"ops.page.runtime",
|
||||||
|
"ops.page.recovery",
|
||||||
|
"ops.page.governance",
|
||||||
|
"ops.page.deployment",
|
||||||
|
"ops.page.sizing",
|
||||||
|
"ops.action.run-probes",
|
||||||
|
"ops.action.drain-node",
|
||||||
|
"ops.widget.health",
|
||||||
|
}
|
||||||
|
self.assertEqual(expected, set(surfaces))
|
||||||
|
for surface_id in (
|
||||||
|
"ops.page.summary",
|
||||||
|
"ops.page.health",
|
||||||
|
"ops.page.runtime",
|
||||||
|
"ops.page.recovery",
|
||||||
|
"ops.page.governance",
|
||||||
|
"ops.page.deployment",
|
||||||
|
"ops.page.sizing",
|
||||||
|
):
|
||||||
|
self.assertEqual("ops.page", surfaces[surface_id].parent_id)
|
||||||
|
self.assertEqual("ops.page.health", surfaces["ops.action.run-probes"].parent_id)
|
||||||
|
self.assertEqual("ops.page.runtime", surfaces["ops.action.drain-node"].parent_id)
|
||||||
|
|
||||||
|
def test_help_and_consequence_metadata_remain_published(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in get_manifest().documentation}
|
||||||
|
status = topics["ops.health-governance-and-sizing"]
|
||||||
|
recovery = topics["ops.runtime-coordination-and-recovery"]
|
||||||
|
|
||||||
|
self.assertIn("ops.page.health", status.metadata["help_contexts"])
|
||||||
|
self.assertIn("run_probes", status.metadata["consequence_classes"])
|
||||||
|
self.assertIn("ops.action.drain-node", recovery.metadata["help_contexts"])
|
||||||
|
self.assertIn("drain_node", recovery.metadata["consequence_classes"])
|
||||||
|
self.assertIn("cancel_node_drain", recovery.metadata["consequence_classes"])
|
||||||
|
self.assertIn("inspect_recovery", recovery.metadata["consequence_classes"])
|
||||||
|
|
||||||
|
def test_public_documentation_has_complete_german_baseline(self) -> None:
|
||||||
|
for topic in get_manifest().documentation:
|
||||||
|
german = topic.translations.get("de", {})
|
||||||
|
self.assertTrue(
|
||||||
|
all(german.get(field) for field in ("title", "summary", "body")),
|
||||||
|
topic.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_webui_uses_shared_operational_patterns(self) -> None:
|
||||||
|
page = (REPO_ROOT / "webui/src/features/ops/OpsPage.tsx").read_text(encoding="utf-8")
|
||||||
|
widget = (REPO_ROOT / "webui/src/features/ops/OpsHealthWidget.tsx").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for component in (
|
||||||
|
"ActionBlockerHint",
|
||||||
|
"ConfirmDialog",
|
||||||
|
"DataGrid",
|
||||||
|
"DocumentationHelpLink",
|
||||||
|
"LoadingFrame",
|
||||||
|
"MetricCard",
|
||||||
|
"TableActionGroup",
|
||||||
|
):
|
||||||
|
self.assertIn(component, page)
|
||||||
|
self.assertIn("DocumentationHelpLink", widget)
|
||||||
|
self.assertIn("LoadingFrame", widget)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_core.core.operations import (
|
||||||
|
OperationalCheck,
|
||||||
|
OperationalCheckProviderRegistration,
|
||||||
|
RuntimeWorkStatus,
|
||||||
|
RuntimeWorkStatusContext,
|
||||||
|
RuntimeWorkStatusProviderRegistration,
|
||||||
|
)
|
||||||
|
from govoplan_ops.backend.api.v1 import routes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Manifest:
|
||||||
|
operational_check_providers: tuple[OperationalCheckProviderRegistration, ...]
|
||||||
|
runtime_work_status_providers: tuple[RuntimeWorkStatusProviderRegistration, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, *registrations: OperationalCheckProviderRegistration):
|
||||||
|
self._manifest = _Manifest(tuple(registrations))
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (self._manifest,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_operational_checks_cache_and_force() -> None:
|
||||||
|
routes._module_check_cache.clear()
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def provider() -> OperationalCheck:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return OperationalCheck("example.roundtrip", "Example", "ok", "Passed")
|
||||||
|
|
||||||
|
registration = OperationalCheckProviderRegistration(
|
||||||
|
module_id="example",
|
||||||
|
check_id="example.roundtrip",
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
registry = _Registry(registration)
|
||||||
|
|
||||||
|
assert routes._module_operational_checks(registry, force=False)[0]["state"] == "ok"
|
||||||
|
assert routes._module_operational_checks(registry, force=False)[0]["state"] == "ok"
|
||||||
|
assert calls == 1
|
||||||
|
routes._module_operational_checks(registry, force=True)
|
||||||
|
assert calls == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_operational_check_failure_is_isolated() -> None:
|
||||||
|
routes._module_check_cache.clear()
|
||||||
|
|
||||||
|
def provider() -> OperationalCheck:
|
||||||
|
raise RuntimeError("secret detail")
|
||||||
|
|
||||||
|
result = routes._module_operational_checks(
|
||||||
|
_Registry(
|
||||||
|
OperationalCheckProviderRegistration(
|
||||||
|
module_id="example",
|
||||||
|
check_id="example.failed",
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
force=True,
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
assert result["state"] == "error"
|
||||||
|
assert "secret detail" not in result["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_work_provider_cache_force_and_unknown_metrics() -> None:
|
||||||
|
routes._runtime_work_cache.clear()
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def provider(context: RuntimeWorkStatusContext) -> RuntimeWorkStatus:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return RuntimeWorkStatus(
|
||||||
|
provider_id="example.queue",
|
||||||
|
label="Example queue",
|
||||||
|
backend="Example",
|
||||||
|
enabled=True,
|
||||||
|
configured=True,
|
||||||
|
state="healthy",
|
||||||
|
detail="Workers answered; queue depth unsupported.",
|
||||||
|
observed_at=context.observed_at,
|
||||||
|
active_workers=1,
|
||||||
|
queue_depths={"example": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
registry = _Registry()
|
||||||
|
registry._manifest.runtime_work_status_providers = ( # type: ignore[misc]
|
||||||
|
RuntimeWorkStatusProviderRegistration(
|
||||||
|
module_id="example",
|
||||||
|
provider_id="example.queue",
|
||||||
|
provider=provider,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context = RuntimeWorkStatusContext(
|
||||||
|
profile="split-worker",
|
||||||
|
observed_at=datetime.now(UTC),
|
||||||
|
stale_after_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = routes._runtime_work_statuses(registry, context)
|
||||||
|
second = routes._runtime_work_statuses(registry, context)
|
||||||
|
forced = routes._runtime_work_statuses(registry, context, force=True)
|
||||||
|
|
||||||
|
assert first[0]["queue_depths"] == {"example": None}
|
||||||
|
assert second == first
|
||||||
|
assert forced[0]["state"] == "healthy"
|
||||||
|
assert calls == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_work_provider_failure_is_sanitized() -> None:
|
||||||
|
routes._runtime_work_cache.clear()
|
||||||
|
|
||||||
|
def provider(context: RuntimeWorkStatusContext) -> RuntimeWorkStatus:
|
||||||
|
del context
|
||||||
|
raise RuntimeError("redis://user:secret@example.test")
|
||||||
|
|
||||||
|
registry = _Registry()
|
||||||
|
registry._manifest.runtime_work_status_providers = ( # type: ignore[misc]
|
||||||
|
RuntimeWorkStatusProviderRegistration(
|
||||||
|
module_id="example",
|
||||||
|
provider_id="example.failed",
|
||||||
|
provider=provider,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result = routes._runtime_work_statuses(
|
||||||
|
registry,
|
||||||
|
RuntimeWorkStatusContext(
|
||||||
|
profile="split-worker",
|
||||||
|
observed_at=datetime.now(UTC),
|
||||||
|
stale_after_seconds=60,
|
||||||
|
),
|
||||||
|
force=True,
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
assert result["state"] == "unreachable"
|
||||||
|
assert "secret" not in result["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_workers_are_expected_only_in_development() -> None:
|
||||||
|
disabled = {
|
||||||
|
"provider_id": "example.queue",
|
||||||
|
"state": "disabled",
|
||||||
|
"detail": "Intentionally disabled.",
|
||||||
|
"enabled": False,
|
||||||
|
"configured": True,
|
||||||
|
"queue_depths": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
development = routes._runtime_work_check([disabled], "local-dev")
|
||||||
|
production = routes._runtime_work_check([disabled], "single-process")
|
||||||
|
|
||||||
|
assert development["state"] == "inactive"
|
||||||
|
assert development["readiness_critical"] is False
|
||||||
|
assert production["state"] == "warning"
|
||||||
|
assert production["readiness_critical"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_runtime_cluster_missing_replicas_blocks_readiness() -> None:
|
||||||
|
check = routes._runtime_cluster_check(
|
||||||
|
{
|
||||||
|
"available": True,
|
||||||
|
"state_profile": "shared",
|
||||||
|
"nodes": [
|
||||||
|
{"role": "api", "state": "active", "stale": False},
|
||||||
|
{"role": "worker", "state": "active", "stale": True},
|
||||||
|
],
|
||||||
|
"expected": {"api": 2, "worker": 1},
|
||||||
|
"active": {"api": 1, "worker": 0},
|
||||||
|
"recovery": {"requires_attention": 1},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert check["state"] == "error"
|
||||||
|
assert check["readiness_critical"] is True
|
||||||
|
assert check["metrics"]["recovery_required"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_runtime_cluster_skew_or_unserved_queue_blocks_readiness() -> None:
|
||||||
|
check = routes._runtime_cluster_check(
|
||||||
|
{
|
||||||
|
"available": True,
|
||||||
|
"state_profile": "shared",
|
||||||
|
"nodes": [],
|
||||||
|
"expected": {"api": 2, "worker": 2},
|
||||||
|
"active": {"api": 2, "worker": 2},
|
||||||
|
"composition": {"skewed": True},
|
||||||
|
"software_versions": {"skewed": False},
|
||||||
|
"queues": {"missing": ["calendar"]},
|
||||||
|
"recovery": {"requires_attention": 0},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert check["state"] == "error"
|
||||||
|
assert check["readiness_critical"] is True
|
||||||
|
assert check["metrics"]["composition_skewed"] is True
|
||||||
|
assert check["metrics"]["missing_queues"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_capacity_check_enforces_shared_rendered_budget(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(routes.core_settings, "state_profile", "shared")
|
||||||
|
monkeypatch.setattr(routes.core_settings, "database_connection_limit", 100)
|
||||||
|
monkeypatch.setattr(routes.core_settings, "database_connection_reserve", 10)
|
||||||
|
monkeypatch.setattr(routes.core_settings, "database_connection_available", 90)
|
||||||
|
monkeypatch.setattr(routes.core_settings, "database_connection_peak", 70)
|
||||||
|
|
||||||
|
healthy = routes._database_capacity_check()
|
||||||
|
assert healthy["state"] == "ok"
|
||||||
|
assert healthy["metrics"]["peak"] == 70
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes.core_settings, "database_connection_peak", 91)
|
||||||
|
overrun = routes._database_capacity_check()
|
||||||
|
assert overrun["state"] == "error"
|
||||||
|
assert overrun["readiness_critical"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_metrics_separate_failure_unknown_and_active_work() -> None:
|
||||||
|
metrics = routes._recovery_metrics(
|
||||||
|
[
|
||||||
|
{"status": "running"},
|
||||||
|
{"status": "failed"},
|
||||||
|
{"status": "outcome_unknown"},
|
||||||
|
{"status": "recovery_required"},
|
||||||
|
{"status": "manual_intervention"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert metrics == {
|
||||||
|
"failed": 2,
|
||||||
|
"outcome_unknown": 1,
|
||||||
|
"recovery_required": 1,
|
||||||
|
"active": 1,
|
||||||
|
"requires_attention": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_storage_capacity_reports_bounded_filesystem_metrics(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
metrics = routes._local_storage_capacity(tmp_path)
|
||||||
|
|
||||||
|
assert metrics["backend"] == "local"
|
||||||
|
assert metrics["capacity_observable"] is True
|
||||||
|
assert metrics["capacity_total_bytes"] > 0
|
||||||
|
assert 0 <= metrics["capacity_used_percent"] <= 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_deployer_backup_receipt_reports_verified_and_expired_state(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
values = {
|
||||||
|
"GOVOPLAN_BACKUP_EVIDENCE_STATE": "verified",
|
||||||
|
"GOVOPLAN_BACKUP_EVIDENCE_ID": "evidence-1",
|
||||||
|
"GOVOPLAN_BACKUP_RECOVERY_POINT_ID": "recovery-1",
|
||||||
|
"GOVOPLAN_BACKUP_RESTORE_DRILL_ID": "drill-1",
|
||||||
|
"GOVOPLAN_BACKUP_EVIDENCE_SHA256": "a" * 64,
|
||||||
|
"GOVOPLAN_BACKUP_RELEASE_MANIFEST_SHA256": "b" * 64,
|
||||||
|
"GOVOPLAN_BACKUP_CAPTURED_AT": (now - timedelta(hours=2)).isoformat(),
|
||||||
|
"GOVOPLAN_BACKUP_EXPIRES_AT": (now + timedelta(hours=2)).isoformat(),
|
||||||
|
"GOVOPLAN_BACKUP_RESTORE_STARTED_AT": (
|
||||||
|
now - timedelta(hours=1, minutes=5)
|
||||||
|
).isoformat(),
|
||||||
|
"GOVOPLAN_BACKUP_RESTORE_COMPLETED_AT": (now - timedelta(hours=1)).isoformat(),
|
||||||
|
"GOVOPLAN_BACKUP_VERIFIED_AT": (now - timedelta(minutes=30)).isoformat(),
|
||||||
|
"GOVOPLAN_BACKUP_MEASURED_RPO_SECONDS": "120",
|
||||||
|
"GOVOPLAN_BACKUP_MEASURED_RTO_SECONDS": "300",
|
||||||
|
"GOVOPLAN_BACKUP_COMPONENT_COUNT": "4",
|
||||||
|
}
|
||||||
|
for key, value in values.items():
|
||||||
|
monkeypatch.setenv(key, value)
|
||||||
|
|
||||||
|
verified = routes._backup_restore_check()
|
||||||
|
assert verified["state"] == "ok"
|
||||||
|
assert verified["metrics"]["recovery_point_id"] == "recovery-1"
|
||||||
|
assert verified["metrics"]["measured_rto_seconds"] == 300
|
||||||
|
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"GOVOPLAN_BACKUP_EXPIRES_AT",
|
||||||
|
(now - timedelta(minutes=1)).isoformat(),
|
||||||
|
)
|
||||||
|
expired = routes._backup_restore_check()
|
||||||
|
assert expired["state"] == "warning"
|
||||||
|
assert expired["metrics"]["evidence_state"] == "expired"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deployer_backup_receipt_fails_closed_without_exposing_private_evidence(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("GOVOPLAN_BACKUP_EVIDENCE_STATE", "invalid")
|
||||||
|
invalid = routes._backup_restore_check()
|
||||||
|
|
||||||
|
assert invalid["state"] == "warning"
|
||||||
|
assert invalid["metrics"] == {
|
||||||
|
"evidence_state": "invalid",
|
||||||
|
"restore_drill_ok": False,
|
||||||
|
}
|
||||||
|
assert "artifact" not in invalid["detail"].lower()
|
||||||
|
assert "key" not in invalid["detail"].lower()
|
||||||
+10
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/ops-webui",
|
"name": "@govoplan/ops-webui",
|
||||||
"version": "0.1.7",
|
"version": "0.1.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -12,15 +12,18 @@
|
|||||||
"import": "./src/index.ts"
|
"import": "./src/index.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:runtime-status": "rm -rf .runtime-status-test-build && mkdir -p .runtime-status-test-build && printf '{\"type\":\"commonjs\"}\\n' > .runtime-status-test-build/package.json && ../../govoplan-core/webui/node_modules/.bin/tsc -p tsconfig.runtime-status-tests.json && node .runtime-status-test-build/tests/runtime-status.test.js"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.7",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router": ">=8.3.0 <9",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type OpsCheck = {
|
|||||||
state: "ok" | "warning" | "error" | string;
|
state: "ok" | "warning" | "error" | string;
|
||||||
detail: string;
|
detail: string;
|
||||||
readiness_critical?: boolean;
|
readiness_critical?: boolean;
|
||||||
|
metrics?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OpsDeploymentProfile = {
|
export type OpsDeploymentProfile = {
|
||||||
@@ -16,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;
|
||||||
@@ -23,6 +46,148 @@ export type OpsSizingAssumption = {
|
|||||||
operator_note: string;
|
operator_note: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OpsGovernanceModule = {
|
||||||
|
module_id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
permission_count: number;
|
||||||
|
role_template_count: number;
|
||||||
|
capability_count: number;
|
||||||
|
policy_count: number;
|
||||||
|
documentation_count: number;
|
||||||
|
documentation_provider_count: number;
|
||||||
|
access_control_count: number;
|
||||||
|
search_provider_count: number;
|
||||||
|
migration_managed: boolean;
|
||||||
|
architecture?: {
|
||||||
|
contract_version: string;
|
||||||
|
layer: string;
|
||||||
|
kind: string;
|
||||||
|
maturity: string;
|
||||||
|
known_limits: string[];
|
||||||
|
supported_authority_modes: string[];
|
||||||
|
owned_concepts: string[];
|
||||||
|
non_owned_concepts: string[];
|
||||||
|
evidence: Array<{ kind: string; reference: string; summary: string }>;
|
||||||
|
} | null;
|
||||||
|
external_provider_count: number;
|
||||||
|
external_providers: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
maturity: string;
|
||||||
|
authority_modes: string[];
|
||||||
|
operations: string[];
|
||||||
|
behavior: { outage?: string | null };
|
||||||
|
runtime_state?: {
|
||||||
|
configured: boolean;
|
||||||
|
active: boolean;
|
||||||
|
authority_mode?: string | null;
|
||||||
|
authority_modes?: string[];
|
||||||
|
health: string;
|
||||||
|
freshness: string;
|
||||||
|
conflict: string;
|
||||||
|
recovery: string;
|
||||||
|
observed_at: string;
|
||||||
|
last_success_at?: string | null;
|
||||||
|
bindings?: Array<{
|
||||||
|
binding_ref: string;
|
||||||
|
authority_mode: string;
|
||||||
|
active: boolean;
|
||||||
|
health: string;
|
||||||
|
freshness: string;
|
||||||
|
conflict: string;
|
||||||
|
recovery: string;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpsRuntimeNode = {
|
||||||
|
node_id: string;
|
||||||
|
incarnation: string;
|
||||||
|
role: string;
|
||||||
|
software_version: string;
|
||||||
|
composition_hash: string;
|
||||||
|
queues: string[];
|
||||||
|
state: "active" | "draining" | "stopped" | string;
|
||||||
|
started_at: string;
|
||||||
|
last_heartbeat_at: string;
|
||||||
|
drain_requested_at?: string | null;
|
||||||
|
drain_reason?: string | null;
|
||||||
|
stopped_at?: string | null;
|
||||||
|
stale: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpsRecoveryOperation = {
|
||||||
|
id: string;
|
||||||
|
module_id: string;
|
||||||
|
operation_type: string;
|
||||||
|
resource_type?: string | null;
|
||||||
|
resource_id?: string | null;
|
||||||
|
mode: string;
|
||||||
|
status: string;
|
||||||
|
checkpoint_count: number;
|
||||||
|
evidence_head_sha256?: string | null;
|
||||||
|
failure_summary?: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpsRuntimeCluster = {
|
||||||
|
available: boolean;
|
||||||
|
detail: string;
|
||||||
|
installation_id?: string;
|
||||||
|
state_profile: string;
|
||||||
|
expected?: { api: number; worker: number };
|
||||||
|
active?: { api: number; worker: number };
|
||||||
|
composition?: {
|
||||||
|
expected_hash?: string | null;
|
||||||
|
active_hashes: string[];
|
||||||
|
unexpected_nodes: string[];
|
||||||
|
skewed: boolean;
|
||||||
|
};
|
||||||
|
software_versions?: {
|
||||||
|
active: string[];
|
||||||
|
skewed: boolean;
|
||||||
|
};
|
||||||
|
queues?: {
|
||||||
|
expected: string[];
|
||||||
|
active: string[];
|
||||||
|
missing: string[];
|
||||||
|
worker_pools: string[];
|
||||||
|
};
|
||||||
|
nodes: OpsRuntimeNode[];
|
||||||
|
recovery: {
|
||||||
|
operations: OpsRecoveryOperation[];
|
||||||
|
requires_attention: number;
|
||||||
|
metrics?: {
|
||||||
|
failed?: number;
|
||||||
|
outcome_unknown?: number;
|
||||||
|
recovery_required?: number;
|
||||||
|
active?: number;
|
||||||
|
requires_attention?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpsRuntimeWorkStatus = {
|
||||||
|
provider_id: string;
|
||||||
|
label: string;
|
||||||
|
backend: string;
|
||||||
|
enabled: boolean | null;
|
||||||
|
configured: boolean | null;
|
||||||
|
state: "disabled" | "unconfigured" | "starting" | "healthy" | "idle" | "busy" | "degraded" | "stale" | "unreachable" | string;
|
||||||
|
detail: string;
|
||||||
|
observed_at: string;
|
||||||
|
active_workers?: number | null;
|
||||||
|
last_heartbeat_at?: string | null;
|
||||||
|
queue_depths: Record<string, number | null>;
|
||||||
|
active_work?: number | null;
|
||||||
|
reserved_work?: number | null;
|
||||||
|
failures?: number | null;
|
||||||
|
stale_after_seconds?: number | null;
|
||||||
|
guidance: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type OpsStatus = {
|
export type OpsStatus = {
|
||||||
summary: {
|
summary: {
|
||||||
app_env: string;
|
app_env: string;
|
||||||
@@ -36,7 +201,37 @@ export type OpsStatus = {
|
|||||||
message?: string | null;
|
message?: string | null;
|
||||||
};
|
};
|
||||||
database_url: string;
|
database_url: string;
|
||||||
|
database_connection_peak?: number | null;
|
||||||
|
database_connection_available?: number | null;
|
||||||
file_storage_backend: string;
|
file_storage_backend: string;
|
||||||
|
storage_metrics?: {
|
||||||
|
backend?: string;
|
||||||
|
capacity_observable?: boolean;
|
||||||
|
capacity_total_bytes?: number;
|
||||||
|
capacity_used_bytes?: number;
|
||||||
|
capacity_free_bytes?: number;
|
||||||
|
capacity_used_percent?: number;
|
||||||
|
};
|
||||||
|
backup_state?: string;
|
||||||
|
worker_metrics: {
|
||||||
|
state?: string;
|
||||||
|
workers?: number | null;
|
||||||
|
active_tasks?: number | null;
|
||||||
|
reserved_tasks?: number | null;
|
||||||
|
failures?: number | null;
|
||||||
|
expected_queues?: string[];
|
||||||
|
active_queues?: string[];
|
||||||
|
missing_queues?: string[];
|
||||||
|
queue_depths?: Record<string, number | null>;
|
||||||
|
};
|
||||||
|
operational_probe_count: number;
|
||||||
|
runtime_node_count: number;
|
||||||
|
recovery_required_count: number;
|
||||||
|
failed_operation_count?: number;
|
||||||
|
outcome_unknown_count?: number;
|
||||||
|
active_operation_count?: number;
|
||||||
|
infrastructure_capability_count?: number;
|
||||||
|
pending_post_install_task_count?: number;
|
||||||
};
|
};
|
||||||
readiness: {
|
readiness: {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
@@ -49,10 +244,72 @@ export type OpsStatus = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
checks: OpsCheck[];
|
checks: OpsCheck[];
|
||||||
|
governance: {
|
||||||
|
summary: {
|
||||||
|
module_count: number;
|
||||||
|
permission_count: number;
|
||||||
|
role_template_count: number;
|
||||||
|
capability_count: number;
|
||||||
|
policy_count: number;
|
||||||
|
documented_module_count: number;
|
||||||
|
access_control_count: number;
|
||||||
|
search_provider_count: number;
|
||||||
|
migration_module_count: number;
|
||||||
|
architecture_declared_module_count: number;
|
||||||
|
external_provider_count: number;
|
||||||
|
configured_external_provider_count: number;
|
||||||
|
provider_attention_count: number;
|
||||||
|
supported_module_count: number;
|
||||||
|
};
|
||||||
|
modules: OpsGovernanceModule[];
|
||||||
|
};
|
||||||
deployment_profiles: OpsDeploymentProfile[];
|
deployment_profiles: OpsDeploymentProfile[];
|
||||||
sizing: OpsSizingAssumption[];
|
sizing: OpsSizingAssumption[];
|
||||||
|
runtime_cluster: OpsRuntimeCluster;
|
||||||
|
runtime_work: OpsRuntimeWorkStatus[];
|
||||||
|
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> {
|
||||||
return apiFetch(settings, "/api/v1/ops/status");
|
return apiFetch(settings, "/api/v1/ops/status");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function runOpsChecks(settings: ApiSettings): Promise<OpsStatus> {
|
||||||
|
return apiFetch(settings, "/api/v1/ops/checks/run", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drainRuntimeNode(
|
||||||
|
settings: ApiSettings,
|
||||||
|
nodeId: string,
|
||||||
|
reason = "operator request"
|
||||||
|
): Promise<{ node_id: string; state: string; drain_reason: string }> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/ops/runtime/nodes/${encodeURIComponent(nodeId)}/drain`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reason })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelRuntimeNodeDrain(
|
||||||
|
settings: ApiSettings,
|
||||||
|
nodeId: string
|
||||||
|
): Promise<{ node_id: string; state: string }> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/ops/runtime/nodes/${encodeURIComponent(nodeId)}/drain`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
DocumentationHelpLink,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -8,6 +11,8 @@ import {
|
|||||||
type ApiSettings
|
type ApiSettings
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { fetchOpsStatus, type OpsStatus } from "../../api/ops";
|
import { fetchOpsStatus, type OpsStatus } from "../../api/ops";
|
||||||
|
import { OPS_DOCUMENTATION } from "./interfacePatterns";
|
||||||
|
import { knownMetric, knownQueueDepthTotal, runtimeWorkTone } from "./runtimeStatus";
|
||||||
|
|
||||||
export default function OpsHealthWidget({ settings, refreshKey }: { settings: ApiSettings; refreshKey: number }) {
|
export default function OpsHealthWidget({ settings, refreshKey }: { settings: ApiSettings; refreshKey: number }) {
|
||||||
const [status, setStatus] = useState<OpsStatus | null>(null);
|
const [status, setStatus] = useState<OpsStatus | null>(null);
|
||||||
@@ -29,26 +34,39 @@ export default function OpsHealthWidget({ settings, refreshKey }: { settings: Ap
|
|||||||
const warningCount = checks.filter((item) => item.state === "warning").length;
|
const warningCount = checks.filter((item) => item.state === "warning").length;
|
||||||
const errorCount = checks.filter((item) => item.state === "error").length;
|
const errorCount = checks.filter((item) => item.state === "error").length;
|
||||||
const ready = status?.readiness.ready ?? false;
|
const ready = status?.readiness.ready ?? false;
|
||||||
|
const workerMetrics = status?.summary.worker_metrics;
|
||||||
|
const queueDepths = workerMetrics?.queue_depths ?? {};
|
||||||
|
const queuedTasks = knownQueueDepthTotal(queueDepths);
|
||||||
|
const storageUsage = status?.summary.storage_metrics?.capacity_used_percent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingFrame loading={loading} label="Loading operations status">
|
<LoadingFrame loading={loading} label="Loading operations status">
|
||||||
{error && <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
<div className="metric-grid inside dashboard-widget-metrics">
|
<div className="button-row compact-actions">
|
||||||
<MetricCard label="Readiness" value={ready ? "ready" : "blocked"} tone={ready ? "good" : "danger"} detail={status?.readiness.profile ?? "-"} />
|
<DocumentationHelpLink reference={OPS_DOCUMENTATION} />
|
||||||
<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>
|
</div>
|
||||||
|
<MetricGrid columns={3} spacing="none">
|
||||||
|
<MetricCard label="Readiness" value={ready ? "ready" : "blocked"} tone={ready ? "good" : "danger"} detail={status?.readiness.profile ?? "-"} />
|
||||||
|
<MetricCard label="Workers" value={knownMetric(workerMetrics?.workers)} tone={runtimeWorkTone(workerMetrics?.state ?? "unreachable")} detail={workerMetrics?.state ?? "unavailable"} />
|
||||||
|
<MetricCard label="Active tasks" value={knownMetric(workerMetrics?.active_tasks)} tone="info" detail={workerMetrics?.active_tasks == null ? "Metric unavailable" : "Reported by the runtime provider"} />
|
||||||
|
<MetricCard label="Queued tasks" value={queuedTasks ?? "unavailable"} tone={queuedTasks === null ? "neutral" : queuedTasks ? "warning" : "good"} detail={queuedTasks === null ? "Queue depth unavailable" : `${Object.values(queueDepths).filter((value) => typeof value === "number").length} measured queue(s)`} />
|
||||||
|
<MetricCard label="Storage" value={storageUsage === undefined ? status?.summary.file_storage_backend ?? "-" : `${storageUsage}%`} tone={storageUsage !== undefined && storageUsage >= 90 ? "danger" : storageUsage !== undefined && storageUsage >= 75 ? "warning" : "neutral"} detail="Managed Files backend capacity" />
|
||||||
|
<MetricCard label="Failed" value={status?.summary.failed_operation_count ?? 0} tone={status?.summary.failed_operation_count ? "danger" : "good"} detail="Recovery-ledger operations" />
|
||||||
|
<MetricCard label="Unknown" value={status?.summary.outcome_unknown_count ?? 0} tone={status?.summary.outcome_unknown_count ? "danger" : "good"} detail="Outcome-unknown operations" />
|
||||||
|
<MetricCard label="Backup" value={status?.summary.backup_state ?? "unknown"} tone={status?.summary.backup_state === "ok" ? "good" : "warning"} detail="Backup and restore evidence" />
|
||||||
|
<MetricCard label="Probes" value={status?.summary.operational_probe_count ?? 0} tone="neutral" detail="Module-owned operational checks" />
|
||||||
|
<MetricCard label="Warnings" value={warningCount + errorCount} tone={errorCount ? "danger" : warningCount ? "warning" : "good"} detail="Current health checks" />
|
||||||
|
</MetricGrid>
|
||||||
{status?.readiness.blockers.length ?
|
{status?.readiness.blockers.length ?
|
||||||
<dl className="detail-list dashboard-compact-list below-grid">
|
<DescriptionList variant="inline" termWidth="compact" className="below-grid">
|
||||||
{status.readiness.blockers.slice(0, 3).map((blocker) =>
|
{status.readiness.blockers.slice(0, 3).map((blocker) =>
|
||||||
<div key={blocker.id}>
|
<div key={blocker.id}>
|
||||||
<dt><StatusBadge status={blocker.state === "error" ? "error" : "warning"} label={blocker.state} /></dt>
|
<dt><StatusBadge status={blocker.state === "error" ? "error" : "warning"} label={blocker.state} /></dt>
|
||||||
<dd><strong>{blocker.label}</strong><span className="muted"> · {blocker.detail}</span></dd>
|
<dd><strong>{blocker.label}</strong><span className="muted"> · {blocker.detail}</span></dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl> :
|
</DescriptionList> :
|
||||||
<p className="muted below-grid">No readiness blockers reported.</p>
|
<p className="muted below-grid">No readiness blockers reported.</p>
|
||||||
}
|
}
|
||||||
</LoadingFrame>);
|
</LoadingFrame>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,147 +1,732 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { RefreshCw } from "lucide-react";
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import {
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { PauseCircle, PlayCircle, RefreshCw } from "lucide-react";
|
||||||
|
import { ContentGrid,
|
||||||
|
ActionBlockerHint,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DismissibleAlert,
|
ConfirmDialog,
|
||||||
|
DataGrid,
|
||||||
|
DocumentationHelpLink,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
PageTitle,
|
PageActionBar,
|
||||||
|
PageLayout,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
adminErrorMessage,
|
adminErrorMessage,
|
||||||
type ApiSettings } from
|
hasAnyScope,
|
||||||
|
i18nMessage,
|
||||||
|
type ApiSettings,
|
||||||
|
type AuthInfo,
|
||||||
|
type DataGridColumn } from
|
||||||
"@govoplan/core-webui";
|
"@govoplan/core-webui";
|
||||||
import { fetchOpsStatus, type OpsCheck, type OpsDeploymentProfile, type OpsSizingAssumption, type OpsStatus } from "../../api/ops";
|
import {
|
||||||
|
fetchOpsStatus,
|
||||||
|
cancelRuntimeNodeDrain,
|
||||||
|
drainRuntimeNode,
|
||||||
|
runOpsChecks,
|
||||||
|
type OpsCheck,
|
||||||
|
type OpsDeploymentProfile,
|
||||||
|
type OpsGovernanceModule,
|
||||||
|
type OpsInfrastructureCapability,
|
||||||
|
type OpsRecoveryOperation,
|
||||||
|
type OpsRuntimeNode,
|
||||||
|
type OpsRuntimeWorkStatus,
|
||||||
|
type OpsSizingAssumption,
|
||||||
|
type OpsStatus
|
||||||
|
} from "../../api/ops";
|
||||||
|
import {
|
||||||
|
OPS_DOCUMENTATION,
|
||||||
|
OPS_I18N,
|
||||||
|
OPS_RECOVERY_DOCUMENTATION,
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
import {
|
||||||
|
heartbeatAgeLabel,
|
||||||
|
heartbeatAgeSeconds,
|
||||||
|
knownMetric,
|
||||||
|
knownQueueDepthTotal,
|
||||||
|
runtimeWorkTone,
|
||||||
|
shouldPollRuntimeStatus
|
||||||
|
} from "./runtimeStatus";
|
||||||
|
|
||||||
export default function OpsPage({ settings }: {settings: ApiSettings;}) {
|
export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
|
||||||
const [status, setStatus] = useState<OpsStatus | null>(null);
|
const [status, setStatus] = useState<OpsStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [runningProbes, setRunningProbes] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [drainTarget, setDrainTarget] = useState<OpsRuntimeNode | null>(null);
|
||||||
|
const [nodeActionId, setNodeActionId] = useState("");
|
||||||
|
const loadInFlight = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
async function load() {
|
const load = useCallback((background = false): Promise<void> => {
|
||||||
|
if (loadInFlight.current) return loadInFlight.current;
|
||||||
|
const request = (async () => {
|
||||||
|
if (!background) setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setStatus(await fetchOpsStatus(settings));
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
if (!background) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
loadInFlight.current = request;
|
||||||
|
void request.finally(() => {
|
||||||
|
if (loadInFlight.current === request) loadInFlight.current = null;
|
||||||
|
});
|
||||||
|
return request;
|
||||||
|
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
|
async function runChecks() {
|
||||||
|
setRunningProbes(true);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
setStatus(await fetchOpsStatus(settings));
|
setStatus(await runOpsChecks(settings));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(adminErrorMessage(err));
|
setError(adminErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
setRunningProbes(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {void load();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
async function confirmDrain() {
|
||||||
|
if (!drainTarget) return;
|
||||||
|
setNodeActionId(drainTarget.node_id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await drainRuntimeNode(settings, drainTarget.node_id);
|
||||||
|
setDrainTarget(null);
|
||||||
|
setStatus(await fetchOpsStatus(settings));
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setNodeActionId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelDrain(node: OpsRuntimeNode) {
|
||||||
|
setNodeActionId(node.node_id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await cancelRuntimeNodeDrain(settings, node.node_id);
|
||||||
|
setStatus(await fetchOpsStatus(settings));
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setNodeActionId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let intervalId: number | null = null;
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (intervalId !== null) window.clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
};
|
||||||
|
const startPolling = () => {
|
||||||
|
stopPolling();
|
||||||
|
if (document.visibilityState === "hidden") return;
|
||||||
|
intervalId = window.setInterval(() => {
|
||||||
|
if (shouldPollRuntimeStatus(document.visibilityState === "hidden", Boolean(loadInFlight.current))) {
|
||||||
|
void load(true);
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
|
};
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldPollRuntimeStatus(false, Boolean(loadInFlight.current))) void load(true);
|
||||||
|
startPolling();
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
startPolling();
|
||||||
|
document.addEventListener("visibilitychange", handleVisibility);
|
||||||
|
return () => {
|
||||||
|
stopPolling();
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibility);
|
||||||
|
};
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const checks = status?.checks ?? [];
|
const checks = status?.checks ?? [];
|
||||||
const warningCount = checks.filter((item) => item.state === "warning").length;
|
const warningCount = checks.filter((item) => item.state === "warning").length;
|
||||||
const errorCount = checks.filter((item) => item.state === "error").length;
|
const errorCount = checks.filter((item) => item.state === "error").length;
|
||||||
const ready = status?.readiness.ready ?? false;
|
const ready = status?.readiness.ready ?? false;
|
||||||
|
const canRunChecks = hasAnyScope(auth, ["ops:operations:run", "system:settings:write"]);
|
||||||
|
const runProbesDisabledReason = runningProbes
|
||||||
|
? OPS_I18N.runningProbes
|
||||||
|
: loading
|
||||||
|
? OPS_I18N.loading
|
||||||
|
: !canRunChecks
|
||||||
|
? OPS_I18N.runPermissionRequired
|
||||||
|
: undefined;
|
||||||
|
const queueDepths = status?.summary.worker_metrics.queue_depths ?? {};
|
||||||
|
const queuedTasks = knownQueueDepthTotal(queueDepths);
|
||||||
|
const storageUsage = status?.summary.storage_metrics?.capacity_used_percent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="overview"
|
||||||
<div>
|
title="i18n:govoplan-ops.ops.907a54c2"
|
||||||
<PageTitle loading={loading}>i18n:govoplan-ops.ops.907a54c2</PageTitle>
|
description="i18n:govoplan-ops.runtime_health_deployment_profile_worker_split_a.55340156"
|
||||||
<p>i18n:govoplan-ops.runtime_health_deployment_profile_worker_split_a.55340156</p>
|
error={error}
|
||||||
</div>
|
actions={<PageActionBar
|
||||||
<div className="button-row compact-actions">
|
variant="overview"
|
||||||
<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} /> i18n:govoplan-ops.reload.cce71553</Button>
|
refreshable
|
||||||
</div>
|
reloadAction={{ onReload: () => void load(), loading, disabledReason: loading ? OPS_I18N.loading : undefined }}
|
||||||
</div>
|
helpAction={<DocumentationHelpLink reference={OPS_DOCUMENTATION} />}
|
||||||
|
primaryActions={<Button variant="primary" onClick={() => void runChecks()} disabled={Boolean(runProbesDisabledReason)} disabledReason={runProbesDisabledReason}>i18n:govoplan-ops.surface.run_probes</Button>}
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
/>}
|
||||||
|
>
|
||||||
|
{status && !ready && (
|
||||||
|
<ActionBlockerHint
|
||||||
|
reason={{
|
||||||
|
summary: "i18n:govoplan-ops.readiness_blocked_summary",
|
||||||
|
details: i18nMessage("i18n:govoplan-ops.readiness_blocked_details", { value0: status.readiness.blockers.length }),
|
||||||
|
requiredAction: "i18n:govoplan-ops.readiness_blocked_action",
|
||||||
|
actor: "i18n:govoplan-ops.operations_operator",
|
||||||
|
target: "i18n:govoplan-ops.readiness_blocked_target"
|
||||||
|
}}
|
||||||
|
documentation={OPS_RECOVERY_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-ops.loading_operations_status.6890fe6e">
|
<LoadingFrame loading={loading} label="i18n:govoplan-ops.loading_operations_status.6890fe6e">
|
||||||
<div className="metric-grid">
|
<MetricGrid>
|
||||||
<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.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.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.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.permissions.842c35eb" value={status?.governance.summary.permission_count ?? 0} tone="neutral" detail="i18n:govoplan-ops.declared_governance_permissions.d08d3bf1" />
|
||||||
|
<MetricCard label="i18n:govoplan-ops.policies.e7800f56" value={status?.governance.summary.policy_count ?? 0} tone="neutral" detail="i18n:govoplan-ops.registered_policy_capabilities.112a2b64" />
|
||||||
|
<MetricCard label="i18n:govoplan-ops.workers.b6ef3acd" value={knownMetric(status?.summary.worker_metrics.workers)} tone={runtimeWorkTone(status?.summary.worker_metrics.state ?? "unreachable")} detail={workerMetricDetail(status)} />
|
||||||
|
<MetricCard label="Queued tasks" value={queuedTasks ?? "unavailable"} tone={queuedTasks === null ? "neutral" : queuedTasks ? "warning" : "good"} detail={queuedTasks === null ? "Queue depth unavailable" : `${Object.values(queueDepths).filter((value) => typeof value === "number").length} measured queue(s)`} />
|
||||||
|
<MetricCard label="Storage" value={storageUsage === undefined ? status?.summary.file_storage_backend ?? "-" : `${storageUsage}%`} tone={storageUsage !== undefined && storageUsage >= 90 ? "danger" : storageUsage !== undefined && storageUsage >= 75 ? "warning" : "neutral"} detail={storageMetricDetail(status)} />
|
||||||
|
<MetricCard label="Backup evidence" value={status?.summary.backup_state ?? "unknown"} tone={status?.summary.backup_state === "ok" ? "good" : "warning"} detail="Latest coordinated backup and restore-drill evidence" />
|
||||||
|
<MetricCard label="Failed operations" value={status?.summary.failed_operation_count ?? 0} tone={status?.summary.failed_operation_count ? "danger" : "good"} detail="Terminal failures or manual intervention" />
|
||||||
|
<MetricCard label="Unknown outcomes" value={status?.summary.outcome_unknown_count ?? 0} tone={status?.summary.outcome_unknown_count ? "danger" : "good"} detail={`${status?.summary.active_operation_count ?? 0} active recovery-ledger operation(s)`} />
|
||||||
<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.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" />
|
<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>
|
<MetricCard label="Runtime nodes" value={status?.summary.runtime_node_count ?? 0} tone={status?.runtime_cluster.available ? "good" : "danger"} detail={runtimeNodeMetricDetail(status)} />
|
||||||
|
<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="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 })} />
|
||||||
|
</MetricGrid>
|
||||||
|
|
||||||
<div className="dashboard-grid">
|
<ContentGrid columns={2} collapseAt="workspace" className="">
|
||||||
<Card title="i18n:govoplan-ops.health_checks.201c869f">
|
<Card title="i18n:govoplan-ops.health_checks.201c869f">
|
||||||
<CheckList checks={checks} />
|
<CheckList checks={checks} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Runtime cluster">
|
||||||
|
<RuntimeNodeTable
|
||||||
|
nodes={status?.runtime_cluster.nodes ?? []}
|
||||||
|
canManage={canRunChecks}
|
||||||
|
busyNodeId={nodeActionId}
|
||||||
|
onDrain={setDrainTarget}
|
||||||
|
onCancelDrain={(node) => void cancelDrain(node)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Worker and queue readiness">
|
||||||
|
<RuntimeWorkTable items={status?.runtime_work ?? []} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Recovery evidence">
|
||||||
|
<RecoveryTable operations={status?.runtime_cluster.recovery.operations ?? []} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-ops.governance_inventory.835d8e57">
|
||||||
|
<GovernanceTable modules={status?.governance.modules ?? []} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-ops.deployment_profiles.b0caa179">
|
<Card title="i18n:govoplan-ops.deployment_profiles.b0caa179">
|
||||||
<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 && <DescriptionList variant="inline">
|
||||||
|
{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>)}
|
||||||
|
</DescriptionList>}
|
||||||
|
</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>
|
||||||
</div>
|
</ContentGrid>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>);
|
<ConfirmDialog
|
||||||
|
open={Boolean(drainTarget)}
|
||||||
|
title="i18n:govoplan-ops.surface.drain_node"
|
||||||
|
message={i18nMessage("i18n:govoplan-ops.drain_runtime_node_message", { value0: drainTarget?.node_id ?? "i18n:govoplan-ops.this_node" })}
|
||||||
|
confirmLabel="i18n:govoplan-ops.drain_node"
|
||||||
|
busy={Boolean(nodeActionId)}
|
||||||
|
onCancel={() => setDrainTarget(null)}
|
||||||
|
onConfirm={() => void confirmDrain()}
|
||||||
|
/>
|
||||||
|
</PageLayout>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RuntimeWorkTable({ items }: { items: OpsRuntimeWorkStatus[] }) {
|
||||||
|
const columns: DataGridColumn<OpsRuntimeWorkStatus>[] = [
|
||||||
|
{
|
||||||
|
id: "provider",
|
||||||
|
header: "Backend",
|
||||||
|
width: "minmax(200px, 1fr)",
|
||||||
|
minWidth: 180,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (item) => `${item.label} ${item.backend}`,
|
||||||
|
render: (item) => <div><strong>{item.label}</strong><span className="muted block">{item.backend} · {item.provider_id}</span></div>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "state",
|
||||||
|
header: "State",
|
||||||
|
width: 150,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (item) => item.state,
|
||||||
|
render: (item) => <StatusBadge status={stateTone(item.state)} label={item.state} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "activity",
|
||||||
|
header: "Activity",
|
||||||
|
width: "minmax(180px, .7fr)",
|
||||||
|
minWidth: 170,
|
||||||
|
value: (item) => `${item.active_workers ?? ""} ${item.active_work ?? ""} ${item.reserved_work ?? ""}`,
|
||||||
|
render: (item) => `${knownMetric(item.active_workers)} workers · ${knownMetric(item.active_work)} active · ${knownMetric(item.reserved_work)} reserved`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "queues",
|
||||||
|
header: "Queue depth",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
minWidth: 190,
|
||||||
|
resizable: true,
|
||||||
|
value: (item) => Object.entries(item.queue_depths).map(([queue, depth]) => `${queue}:${depth ?? "unavailable"}`).join(" "),
|
||||||
|
render: (item) => runtimeQueueSummary(item.queue_depths)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "heartbeat",
|
||||||
|
header: "Last heartbeat",
|
||||||
|
width: "minmax(180px, .7fr)",
|
||||||
|
minWidth: 170,
|
||||||
|
sortable: true,
|
||||||
|
value: (item) => item.last_heartbeat_at ?? "",
|
||||||
|
render: (item) => {
|
||||||
|
const age = heartbeatAgeSeconds(Date.now(), item.last_heartbeat_at);
|
||||||
|
return <div>{heartbeatAgeLabel(age)}<span className="muted block">stale after {item.stale_after_seconds ?? "unavailable"}s</span></div>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "guidance",
|
||||||
|
header: "Guidance",
|
||||||
|
width: "minmax(260px, 1.3fr)",
|
||||||
|
minWidth: 220,
|
||||||
|
resizable: true,
|
||||||
|
value: (item) => `${item.detail} ${item.guidance}`,
|
||||||
|
render: (item) => <div>{item.detail}<span className="muted block">{item.guidance}</span></div>
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return <DataGrid id="ops-runtime-work-status" rows={items} columns={columns} getRowKey={(item) => item.provider_id} emptyText="Worker and queue status unavailable." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuntimeNodeTable({
|
||||||
|
nodes,
|
||||||
|
canManage,
|
||||||
|
busyNodeId,
|
||||||
|
onDrain,
|
||||||
|
onCancelDrain
|
||||||
|
}: {
|
||||||
|
nodes: OpsRuntimeNode[];
|
||||||
|
canManage: boolean;
|
||||||
|
busyNodeId: string;
|
||||||
|
onDrain: (node: OpsRuntimeNode) => void;
|
||||||
|
onCancelDrain: (node: OpsRuntimeNode) => void;
|
||||||
|
}) {
|
||||||
|
const columns: DataGridColumn<OpsRuntimeNode>[] = [
|
||||||
|
{
|
||||||
|
id: "node",
|
||||||
|
header: "Node",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
minWidth: 200,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (node) => `${node.node_id} ${node.role}`,
|
||||||
|
render: (node) => <div><strong>{node.node_id}</strong><span className="muted block">{node.role} · {node.software_version}</span></div>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "state",
|
||||||
|
header: "State",
|
||||||
|
width: 150,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (node) => node.stale ? "stale" : node.state,
|
||||||
|
render: (node) => <StatusBadge status={node.stale ? "error" : stateTone(node.state)} label={node.stale ? "stale" : node.state} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "heartbeat",
|
||||||
|
header: "Last heartbeat",
|
||||||
|
width: "minmax(210px, .8fr)",
|
||||||
|
minWidth: 190,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
value: (node) => node.last_heartbeat_at,
|
||||||
|
render: (node) => <div>{heartbeatAgeLabel(heartbeatAgeSeconds(Date.now(), node.last_heartbeat_at))}<span className="muted block">{new Date(node.last_heartbeat_at).toLocaleString()}</span></div>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "queues",
|
||||||
|
header: "Queues",
|
||||||
|
width: "minmax(180px, .7fr)",
|
||||||
|
minWidth: 160,
|
||||||
|
resizable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (node) => node.queues.join(" "),
|
||||||
|
render: (node) => node.queues.join(", ") || "-"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
width: 56,
|
||||||
|
value: (node) => node.state,
|
||||||
|
render: (node) => {
|
||||||
|
const busy = busyNodeId === node.node_id;
|
||||||
|
const cancelling = node.state === "draining";
|
||||||
|
const disabledReason = !canManage
|
||||||
|
? OPS_I18N.runPermissionRequired
|
||||||
|
: busy
|
||||||
|
? OPS_I18N.nodeActionActive
|
||||||
|
: node.state === "stopped"
|
||||||
|
? OPS_I18N.stoppedNode
|
||||||
|
: !cancelling && node.stale
|
||||||
|
? OPS_I18N.staleNode
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<TableActionGroup
|
||||||
|
minimumSlots={1}
|
||||||
|
label={i18nMessage("i18n:govoplan-ops.lifecycle_actions_for_value", { value0: node.node_id })}
|
||||||
|
actions={[{
|
||||||
|
id: cancelling ? "cancel-drain" : "drain",
|
||||||
|
label: cancelling
|
||||||
|
? i18nMessage("i18n:govoplan-ops.cancel_drain_for_value", { value0: node.node_id })
|
||||||
|
: i18nMessage("i18n:govoplan-ops.drain_value", { value0: node.node_id }),
|
||||||
|
icon: cancelling ? <PlayCircle size={16} /> : <PauseCircle size={16} />,
|
||||||
|
disabled: Boolean(disabledReason),
|
||||||
|
disabledReason,
|
||||||
|
onClick: () => cancelling ? onCancelDrain(node) : onDrain(node)
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return <DataGrid id="ops-runtime-nodes" rows={nodes} columns={columns} getRowKey={(node) => `${node.node_id}:${node.incarnation}`} emptyText="No runtime nodes reported." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecoveryTable({ operations }: { operations: OpsRecoveryOperation[] }) {
|
||||||
|
const columns: DataGridColumn<OpsRecoveryOperation>[] = [
|
||||||
|
{
|
||||||
|
id: "operation",
|
||||||
|
header: "Operation",
|
||||||
|
width: "minmax(240px, 1fr)",
|
||||||
|
minWidth: 220,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (operation) => `${operation.module_id} ${operation.operation_type}`,
|
||||||
|
render: (operation) => <div><strong>{operation.operation_type}</strong><span className="muted block">{operation.module_id} · {operation.mode}</span></div>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resource",
|
||||||
|
header: "Resource",
|
||||||
|
width: "minmax(180px, .8fr)",
|
||||||
|
minWidth: 160,
|
||||||
|
resizable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (operation) => `${operation.resource_type ?? ""} ${operation.resource_id ?? ""}`,
|
||||||
|
render: (operation) => operation.resource_type ? `${operation.resource_type}: ${operation.resource_id ?? "-"}` : "-"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
width: 170,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (operation) => operation.status,
|
||||||
|
render: (operation) => <StatusBadge status={recoveryTone(operation.status)} label={operation.status.replaceAll("_", " ")} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evidence",
|
||||||
|
header: "Evidence",
|
||||||
|
width: 150,
|
||||||
|
sortable: true,
|
||||||
|
value: (operation) => operation.checkpoint_count,
|
||||||
|
render: (operation) => `${operation.checkpoint_count} checkpoint(s)`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "updated",
|
||||||
|
header: "Updated",
|
||||||
|
width: "minmax(210px, .8fr)",
|
||||||
|
minWidth: 190,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
value: (operation) => operation.updated_at,
|
||||||
|
render: (operation) => new Date(operation.updated_at).toLocaleString()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return <DataGrid id="ops-recovery-operations" rows={operations} columns={columns} getRowKey={(operation) => operation.id} emptyText="No recovery operations recorded." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GovernanceTable({ modules }: { modules: OpsGovernanceModule[] }) {
|
||||||
|
const columns: DataGridColumn<OpsGovernanceModule>[] = [
|
||||||
|
{
|
||||||
|
id: "module",
|
||||||
|
header: "i18n:govoplan-ops.module.b8ff0289",
|
||||||
|
width: "minmax(200px, 1fr)",
|
||||||
|
minWidth: 180,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (module) => `${module.name} ${module.module_id} ${module.version}`,
|
||||||
|
render: (module) => <div><strong>{module.name}</strong><span className="muted block">{module.module_id} · {module.version}</span></div>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "authority",
|
||||||
|
header: "i18n:govoplan-ops.authority.8802e425",
|
||||||
|
width: "minmax(190px, .8fr)",
|
||||||
|
minWidth: 170,
|
||||||
|
resizable: true,
|
||||||
|
value: (module) => `${module.permission_count} ${module.role_template_count}`,
|
||||||
|
render: (module) => `${module.permission_count} permissions · ${module.role_template_count} roles`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contracts",
|
||||||
|
header: "i18n:govoplan-ops.contracts.57d80902",
|
||||||
|
width: "minmax(190px, .8fr)",
|
||||||
|
minWidth: 170,
|
||||||
|
resizable: true,
|
||||||
|
value: (module) => `${module.capability_count} ${module.policy_count}`,
|
||||||
|
render: (module) => `${module.capability_count} capabilities · ${module.policy_count} policies`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "architecture",
|
||||||
|
header: "Architecture",
|
||||||
|
width: "minmax(210px, .9fr)",
|
||||||
|
minWidth: 190,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (module) => module.architecture
|
||||||
|
? `${module.architecture.layer} ${module.architecture.kind} ${module.architecture.maturity}`
|
||||||
|
: "undeclared",
|
||||||
|
render: (module) => module.architecture ? (
|
||||||
|
<div>
|
||||||
|
<strong>{module.architecture.maturity}</strong>
|
||||||
|
<span className="muted block">{module.architecture.layer} · {module.architecture.kind}</span>
|
||||||
|
{module.architecture.known_limits.length ? <span className="muted block">{module.architecture.known_limits.length} known limit(s)</span> : null}
|
||||||
|
</div>
|
||||||
|
) : <span className="muted">staged declaration pending</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "providers",
|
||||||
|
header: "External providers",
|
||||||
|
width: "minmax(230px, 1fr)",
|
||||||
|
minWidth: 210,
|
||||||
|
resizable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (module) => `${module.external_provider_count} ${module.external_providers.map((provider) => `${provider.id} ${provider.maturity} ${provider.authority_modes.join(" ")} ${provider.runtime_state?.health ?? "unobserved"} ${provider.runtime_state?.freshness ?? ""}`).join(" ")}`,
|
||||||
|
render: (module) => module.external_provider_count ? (
|
||||||
|
<div>
|
||||||
|
<strong>{module.external_provider_count} declared</strong>
|
||||||
|
{module.external_providers.map((provider) => (
|
||||||
|
<span className="muted block" key={provider.id}>
|
||||||
|
{provider.label}: {provider.maturity} · {provider.runtime_state ? `${provider.runtime_state.health}/${provider.runtime_state.freshness} · ${provider.runtime_state.bindings?.length ?? 0} binding(s)` : "unobserved"}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <span className="muted">none declared</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "controls",
|
||||||
|
header: "i18n:govoplan-ops.controls.0cdb80fb",
|
||||||
|
width: "minmax(190px, .8fr)",
|
||||||
|
minWidth: 170,
|
||||||
|
resizable: true,
|
||||||
|
value: (module) => `${module.access_control_count} ${module.search_provider_count}`,
|
||||||
|
render: (module) => `${module.access_control_count} access · ${module.search_provider_count} search`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evidence",
|
||||||
|
header: "i18n:govoplan-ops.evidence.7ea014de",
|
||||||
|
width: "minmax(190px, .8fr)",
|
||||||
|
minWidth: 170,
|
||||||
|
resizable: true,
|
||||||
|
value: (module) => `${module.documentation_count} ${module.documentation_provider_count} ${module.migration_managed}`,
|
||||||
|
render: (module) => (
|
||||||
|
<div>
|
||||||
|
{module.documentation_count + module.documentation_provider_count} docs
|
||||||
|
<span className="muted block">{module.migration_managed ? "migration managed" : "no module migrations"}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
id="ops-governance-inventory"
|
||||||
|
rows={modules}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={(module) => module.module_id}
|
||||||
|
emptyText="i18n:govoplan-ops.no_modules_reported.847f06d9"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CheckList({ checks }: {checks: OpsCheck[];}) {
|
function CheckList({ checks }: {checks: OpsCheck[];}) {
|
||||||
if (!checks.length) return <p className="muted">i18n:govoplan-ops.no_health_checks_reported.03c067c4</p>;
|
if (!checks.length) return <p className="muted">i18n:govoplan-ops.no_health_checks_reported.03c067c4</p>;
|
||||||
return (
|
return (
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
{checks.map((check) =>
|
{checks.map((check) =>
|
||||||
<div key={check.id}>
|
<div key={check.id}>
|
||||||
<dt><StatusBadge status={stateTone(check.state)} label={check.state} /></dt>
|
<dt><StatusBadge status={stateTone(check.state)} label={check.state} /></dt>
|
||||||
<dd><strong>{check.label}</strong><span className="muted"> · {check.detail}</span></dd>
|
<dd><strong>{check.label}</strong><span className="muted"> · {check.detail}</span></dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>);
|
</DescriptionList>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileList({ profiles }: {profiles: OpsDeploymentProfile[];}) {
|
function ProfileList({ profiles }: {profiles: OpsDeploymentProfile[];}) {
|
||||||
if (!profiles.length) return <p className="muted">i18n:govoplan-ops.no_deployment_profiles_reported.7c3af1db</p>;
|
if (!profiles.length) return <p className="muted">i18n:govoplan-ops.no_deployment_profiles_reported.7c3af1db</p>;
|
||||||
|
const columns: DataGridColumn<OpsDeploymentProfile>[] = [
|
||||||
|
{ id: "profile", header: "i18n:govoplan-ops.profile.ff4fc027", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (profile) => `${profile.name} ${profile.id}`, render: (profile) => <div><strong>{profile.name}</strong><span className="muted block">{profile.id}</span></div> },
|
||||||
|
{ id: "status", header: "i18n:govoplan-ops.status.bae7d5be", width: 140, sortable: true, filterable: true, value: (profile) => profile.current ? "current" : "reference", render: (profile) => <StatusBadge status={profile.current ? "success" : "inactive"} label={profile.current ? "current" : "reference"} /> },
|
||||||
|
{ id: "components", header: "i18n:govoplan-ops.components.9289473e", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (profile) => profile.components.join(" "), render: (profile) => profile.components.join(", ") },
|
||||||
|
{ id: "fit", header: "i18n:govoplan-ops.fit.dab564d8", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, filterable: true, value: (profile) => profile.fit }
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<div className="admin-table-wrap">
|
<DataGrid id="ops-deployment-profiles" rows={profiles} columns={columns} getRowKey={(profile) => profile.id} />);
|
||||||
<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[];}) {
|
function SizingTable({ items }: {items: OpsSizingAssumption[];}) {
|
||||||
if (!items.length) return <p className="muted">i18n:govoplan-ops.no_sizing_assumptions_reported.17515959</p>;
|
if (!items.length) return <p className="muted">i18n:govoplan-ops.no_sizing_assumptions_reported.17515959</p>;
|
||||||
|
const columns: DataGridColumn<OpsSizingAssumption>[] = [
|
||||||
|
{ id: "area", header: "i18n:govoplan-ops.area.2745deba", width: "minmax(180px, .7fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (item) => item.area, render: (item) => <strong>{item.area}</strong> },
|
||||||
|
{ id: "baseline", header: "i18n:govoplan-ops.baseline.e6ab7982", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (item) => item.baseline },
|
||||||
|
{ id: "trigger", header: "i18n:govoplan-ops.scale_trigger.1c85e10e", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (item) => item.scale_trigger },
|
||||||
|
{ id: "note", header: "i18n:govoplan-ops.operator_note.1dc58f7b", width: "minmax(240px, 1.2fr)", minWidth: 200, resizable: true, filterable: true, value: (item) => item.operator_note }
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<div className="admin-table-wrap">
|
<DataGrid id="ops-sizing-assumptions" rows={items} columns={columns} getRowKey={(item) => item.area} />);
|
||||||
<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 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 (["ok", "healthy", "idle"].includes(state)) return "success";
|
||||||
if (state === "warning") return "warning";
|
if (state === "busy") return "info";
|
||||||
if (state === "error") return "error";
|
if (["warning", "starting", "degraded", "unconfigured"].includes(state)) return "warning";
|
||||||
|
if (["error", "stale", "unreachable"].includes(state)) return "error";
|
||||||
return "inactive";
|
return "inactive";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recoveryTone(state: string): string {
|
||||||
|
if (["succeeded", "recovered"].includes(state)) return "success";
|
||||||
|
if (state === "rejected") return "warning";
|
||||||
|
if (["failed", "recovery_required"].includes(state)) return "error";
|
||||||
|
if (["running", "recovering", "prepared"].includes(state)) return "warning";
|
||||||
|
return "inactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeNodeMetricDetail(status: OpsStatus | null): string {
|
||||||
|
const cluster = status?.runtime_cluster;
|
||||||
|
if (!cluster?.available) return cluster?.detail ?? "Runtime directory unavailable";
|
||||||
|
const active = cluster.active ?? { api: 0, worker: 0 };
|
||||||
|
const expected = cluster.expected ?? { api: 0, worker: 0 };
|
||||||
|
return `${active.api}/${expected.api} API · ${active.worker}/${expected.worker} workers`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseCapacityValue(status: OpsStatus | null): string {
|
||||||
|
const peak = status?.summary.database_connection_peak;
|
||||||
|
const available = status?.summary.database_connection_available;
|
||||||
|
return typeof peak === "number" && typeof available === "number"
|
||||||
|
? `${peak} / ${available}`
|
||||||
|
: "not declared";
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseCapacityTone(status: OpsStatus | null): "good" | "warning" | "danger" {
|
||||||
|
const peak = status?.summary.database_connection_peak;
|
||||||
|
const available = status?.summary.database_connection_available;
|
||||||
|
if (typeof peak !== "number" || typeof available !== "number") {
|
||||||
|
return status?.runtime_cluster.state_profile === "shared" ? "danger" : "warning";
|
||||||
|
}
|
||||||
|
if (peak > available) return "danger";
|
||||||
|
return peak / available >= 0.8 ? "warning" : "good";
|
||||||
|
}
|
||||||
|
|
||||||
|
function workerMetricDetail(status: OpsStatus | null): string {
|
||||||
|
if (!status) return "Worker status unavailable";
|
||||||
|
const metrics = status.summary.worker_metrics;
|
||||||
|
if (metrics.state === "disabled") return "Workers intentionally disabled";
|
||||||
|
const queued = knownQueueDepthTotal(metrics.queue_depths ?? {});
|
||||||
|
return `${knownMetric(metrics.active_tasks)} active · ${knownMetric(metrics.reserved_tasks)} reserved · ${queued ?? "unavailable"} queued`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeQueueSummary(depths: Record<string, number | null>): string {
|
||||||
|
const entries = Object.entries(depths);
|
||||||
|
if (!entries.length) return "unavailable";
|
||||||
|
return entries.map(([queue, depth]) => `${queue}: ${depth ?? "unavailable"}`).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageMetricDetail(status: OpsStatus | null): string {
|
||||||
|
const metrics = status?.summary.storage_metrics;
|
||||||
|
if (!metrics?.capacity_observable) {
|
||||||
|
return `${status?.summary.file_storage_backend ?? "Storage"} capacity is provider-managed or unavailable`;
|
||||||
|
}
|
||||||
|
return `${formatBytes(metrics.capacity_used_bytes ?? 0)} used of ${formatBytes(metrics.capacity_total_bytes ?? 0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number): string {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
|
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||||
|
return `${(value / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const OPS_DOCUMENTATION = {
|
||||||
|
topicId: "ops.health-governance-and-sizing",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const OPS_RECOVERY_DOCUMENTATION = {
|
||||||
|
topicId: "ops.runtime-coordination-and-recovery",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const OPS_I18N = {
|
||||||
|
loading: "i18n:govoplan-ops.reason.loading",
|
||||||
|
runningProbes: "i18n:govoplan-ops.reason.running_probes",
|
||||||
|
runPermissionRequired: "i18n:govoplan-ops.reason.run_permission_required",
|
||||||
|
nodeActionActive: "i18n:govoplan-ops.reason.node_action_active",
|
||||||
|
staleNode: "i18n:govoplan-ops.reason.stale_node",
|
||||||
|
stoppedNode: "i18n:govoplan-ops.reason.stopped_node"
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export function knownMetric(value: number | null | undefined): number | "unavailable" {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function knownQueueDepthTotal(depths: Record<string, number | null | undefined>): number | null {
|
||||||
|
const values = Object.values(depths).filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||||
|
return values.length ? values.reduce((total, value) => total + value, 0) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeWorkTone(state: string): "good" | "info" | "warning" | "danger" | "neutral" {
|
||||||
|
if (state === "healthy" || state === "idle") return "good";
|
||||||
|
if (state === "busy") return "info";
|
||||||
|
if (state === "starting" || state === "degraded" || state === "unconfigured") return "warning";
|
||||||
|
if (state === "stale" || state === "unreachable") return "danger";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function heartbeatAgeSeconds(now: number, value?: string | null): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
if (!Number.isFinite(timestamp)) return null;
|
||||||
|
return Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function heartbeatAgeLabel(ageSeconds: number | null): string {
|
||||||
|
if (ageSeconds === null) return "unavailable";
|
||||||
|
if (ageSeconds < 60) return `${ageSeconds}s ago`;
|
||||||
|
if (ageSeconds < 3600) return `${Math.floor(ageSeconds / 60)}m ago`;
|
||||||
|
return `${Math.floor(ageSeconds / 3600)}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldPollRuntimeStatus(hidden: boolean, requestInFlight: boolean): boolean {
|
||||||
|
return !hidden && !requestInFlight;
|
||||||
|
}
|
||||||
@@ -2,27 +2,73 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
|||||||
|
|
||||||
export const generatedTranslations: PlatformTranslations = {
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
"en": {
|
"en": {
|
||||||
|
"i18n:govoplan-ops.surface.navigation": "Operations navigation",
|
||||||
|
"i18n:govoplan-ops.surface.page": "Operations workspace",
|
||||||
|
"i18n:govoplan-ops.surface.summary": "Operations summary",
|
||||||
|
"i18n:govoplan-ops.surface.health": "Health checks",
|
||||||
|
"i18n:govoplan-ops.surface.runtime": "Runtime cluster",
|
||||||
|
"i18n:govoplan-ops.surface.recovery": "Recovery evidence",
|
||||||
|
"i18n:govoplan-ops.surface.governance": "Governance inventory",
|
||||||
|
"i18n:govoplan-ops.surface.deployment": "Deployment profiles",
|
||||||
|
"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.drain_node": "Drain runtime node",
|
||||||
|
"i18n:govoplan-ops.reason.loading": "Operations status is loading.",
|
||||||
|
"i18n:govoplan-ops.reason.running_probes": "Operational probes are already running.",
|
||||||
|
"i18n:govoplan-ops.reason.run_permission_required": "Operations-run permission is required.",
|
||||||
|
"i18n:govoplan-ops.reason.node_action_active": "A lifecycle action is already running for this node.",
|
||||||
|
"i18n:govoplan-ops.reason.stale_node": "A stale node cannot be drained because its current incarnation is not reporting.",
|
||||||
|
"i18n:govoplan-ops.reason.stopped_node": "A stopped node cannot accept lifecycle actions.",
|
||||||
|
"i18n:govoplan-ops.readiness_blocked_summary": "The runtime is not ready for normal traffic.",
|
||||||
|
"i18n:govoplan-ops.readiness_blocked_details": "{value0} readiness blocker(s) are active.",
|
||||||
|
"i18n:govoplan-ops.readiness_blocked_action": "Review the health checks, runtime cluster, and owning-module recovery evidence before restoring traffic.",
|
||||||
|
"i18n:govoplan-ops.operations_operator": "Operations operator",
|
||||||
|
"i18n:govoplan-ops.readiness_blocked_target": "Health checks, Runtime cluster, and Recovery evidence below",
|
||||||
|
"i18n:govoplan-ops.lifecycle_actions_for_value": "Lifecycle actions for {value0}",
|
||||||
|
"i18n:govoplan-ops.cancel_drain_for_value": "Cancel drain for {value0}",
|
||||||
|
"i18n:govoplan-ops.drain_value": "Drain {value0}",
|
||||||
|
"i18n:govoplan-ops.drain_runtime_node_message": "Stop routing new work to {value0}. In-flight work is allowed to finish.",
|
||||||
|
"i18n:govoplan-ops.this_node": "this node",
|
||||||
|
"i18n:govoplan-ops.drain_node": "Drain node",
|
||||||
"i18n:govoplan-ops.area.2745deba": "Area",
|
"i18n:govoplan-ops.area.2745deba": "Area",
|
||||||
"i18n:govoplan-ops.baseline.e6ab7982": "Baseline",
|
"i18n:govoplan-ops.baseline.e6ab7982": "Baseline",
|
||||||
|
"i18n:govoplan-ops.authority.8802e425": "Authority",
|
||||||
"i18n:govoplan-ops.celery_worker_setting.323d7737": "Celery worker setting",
|
"i18n:govoplan-ops.celery_worker_setting.323d7737": "Celery worker setting",
|
||||||
"i18n:govoplan-ops.components.9289473e": "Components",
|
"i18n:govoplan-ops.components.9289473e": "Components",
|
||||||
"i18n:govoplan-ops.current_health_checks.7830bccf": "Current health checks",
|
"i18n:govoplan-ops.current_health_checks.7830bccf": "Current health checks",
|
||||||
|
"i18n:govoplan-ops.contracts.57d80902": "Contracts",
|
||||||
|
"i18n:govoplan-ops.controls.0cdb80fb": "Controls",
|
||||||
|
"i18n:govoplan-ops.declared_governance_permissions.d08d3bf1": "Declared governance permissions",
|
||||||
"i18n:govoplan-ops.deployment_profiles.b0caa179": "Deployment Profiles",
|
"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.enabled_in_the_runtime_registry.d2c6142d": "Enabled in the runtime registry",
|
||||||
|
"i18n:govoplan-ops.evidence.7ea014de": "Evidence",
|
||||||
"i18n:govoplan-ops.fit.dab564d8": "Fit",
|
"i18n:govoplan-ops.fit.dab564d8": "Fit",
|
||||||
"i18n:govoplan-ops.health_checks.201c869f": "Health Checks",
|
"i18n:govoplan-ops.health_checks.201c869f": "Health Checks",
|
||||||
|
"i18n:govoplan-ops.governance_inventory.835d8e57": "Governance Inventory",
|
||||||
"i18n:govoplan-ops.loading_operations_status.6890fe6e": "Loading operations status...",
|
"i18n:govoplan-ops.loading_operations_status.6890fe6e": "Loading operations status...",
|
||||||
"i18n:govoplan-ops.modules.04e9462c": "Modules",
|
"i18n:govoplan-ops.modules.04e9462c": "Modules",
|
||||||
|
"i18n:govoplan-ops.module.b8ff0289": "Module",
|
||||||
"i18n:govoplan-ops.no_database_url.51a2db0c": "No database URL",
|
"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_deployment_profiles_reported.7c3af1db": "No deployment profiles reported.",
|
||||||
"i18n:govoplan-ops.no_health_checks_reported.03c067c4": "No health checks reported.",
|
"i18n:govoplan-ops.no_health_checks_reported.03c067c4": "No health checks reported.",
|
||||||
|
"i18n:govoplan-ops.no_modules_reported.847f06d9": "No modules reported.",
|
||||||
"i18n:govoplan-ops.no_sizing_assumptions_reported.17515959": "No sizing assumptions reported.",
|
"i18n:govoplan-ops.no_sizing_assumptions_reported.17515959": "No sizing assumptions reported.",
|
||||||
"i18n:govoplan-ops.operator_note.1dc58f7b": "Operator note",
|
"i18n:govoplan-ops.operator_note.1dc58f7b": "Operator note",
|
||||||
"i18n:govoplan-ops.ops.907a54c2": "Ops",
|
"i18n:govoplan-ops.ops.907a54c2": "Ops",
|
||||||
|
"i18n:govoplan-ops.permissions.842c35eb": "Permissions",
|
||||||
|
"i18n:govoplan-ops.policies.e7800f56": "Policies",
|
||||||
"i18n:govoplan-ops.profile.ff4fc027": "Profile",
|
"i18n:govoplan-ops.profile.ff4fc027": "Profile",
|
||||||
"i18n:govoplan-ops.readiness.1db9d6fb": "Readiness",
|
"i18n:govoplan-ops.readiness.1db9d6fb": "Readiness",
|
||||||
"i18n:govoplan-ops.reload.cce71553": "Reload",
|
"i18n:govoplan-ops.reload.cce71553": "Reload",
|
||||||
"i18n:govoplan-ops.redis.5eaa1f2f": "Redis",
|
"i18n:govoplan-ops.redis.5eaa1f2f": "Redis",
|
||||||
|
"i18n:govoplan-ops.registered_policy_capabilities.112a2b64": "Registered policy capabilities",
|
||||||
"i18n:govoplan-ops.no_readiness_blockers.0df259bd": "No readiness blockers",
|
"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.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.scale_trigger.1c85e10e": "Scale trigger",
|
||||||
@@ -32,27 +78,73 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-ops.workers.b6ef3acd": "Workers"
|
"i18n:govoplan-ops.workers.b6ef3acd": "Workers"
|
||||||
},
|
},
|
||||||
"de": {
|
"de": {
|
||||||
|
"i18n:govoplan-ops.surface.navigation": "Betriebsnavigation",
|
||||||
|
"i18n:govoplan-ops.surface.page": "Betriebsarbeitsbereich",
|
||||||
|
"i18n:govoplan-ops.surface.summary": "Betriebsübersicht",
|
||||||
|
"i18n:govoplan-ops.surface.health": "Systemprüfungen",
|
||||||
|
"i18n:govoplan-ops.surface.runtime": "Laufzeitcluster",
|
||||||
|
"i18n:govoplan-ops.surface.recovery": "Wiederherstellungsnachweise",
|
||||||
|
"i18n:govoplan-ops.surface.governance": "Governance-Inventar",
|
||||||
|
"i18n:govoplan-ops.surface.deployment": "Bereitstellungsprofile",
|
||||||
|
"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.drain_node": "Laufzeitknoten leeren",
|
||||||
|
"i18n:govoplan-ops.reason.loading": "Der Betriebsstatus wird geladen.",
|
||||||
|
"i18n:govoplan-ops.reason.running_probes": "Betriebsprüfungen werden bereits ausgeführt.",
|
||||||
|
"i18n:govoplan-ops.reason.run_permission_required": "Die Berechtigung zum Ausführen von Betriebsprüfungen ist erforderlich.",
|
||||||
|
"i18n:govoplan-ops.reason.node_action_active": "Für diesen Knoten wird bereits eine Lebenszyklusaktion ausgeführt.",
|
||||||
|
"i18n:govoplan-ops.reason.stale_node": "Ein veralteter Knoten kann nicht geleert werden, weil seine aktuelle Instanz keine Statusmeldungen sendet.",
|
||||||
|
"i18n:govoplan-ops.reason.stopped_node": "Ein gestoppter Knoten kann keine Lebenszyklusaktionen annehmen.",
|
||||||
|
"i18n:govoplan-ops.readiness_blocked_summary": "Die Laufzeitumgebung ist nicht für normalen Datenverkehr bereit.",
|
||||||
|
"i18n:govoplan-ops.readiness_blocked_details": "{value0} Bereitschaftsblocker sind aktiv.",
|
||||||
|
"i18n:govoplan-ops.readiness_blocked_action": "Prüfen Sie Systemprüfungen, Laufzeitcluster und Wiederherstellungsnachweise der zuständigen Module, bevor der Datenverkehr wieder freigegeben wird.",
|
||||||
|
"i18n:govoplan-ops.operations_operator": "Betriebsverantwortliche Person",
|
||||||
|
"i18n:govoplan-ops.readiness_blocked_target": "Systemprüfungen, Laufzeitcluster und Wiederherstellungsnachweise weiter unten",
|
||||||
|
"i18n:govoplan-ops.lifecycle_actions_for_value": "Lebenszyklusaktionen für {value0}",
|
||||||
|
"i18n:govoplan-ops.cancel_drain_for_value": "Leeren von {value0} abbrechen",
|
||||||
|
"i18n:govoplan-ops.drain_value": "{value0} leeren",
|
||||||
|
"i18n:govoplan-ops.drain_runtime_node_message": "Keine neue Arbeit mehr an {value0} weiterleiten. Laufende Arbeit darf abgeschlossen werden.",
|
||||||
|
"i18n:govoplan-ops.this_node": "diesen Knoten",
|
||||||
|
"i18n:govoplan-ops.drain_node": "Knoten leeren",
|
||||||
"i18n:govoplan-ops.area.2745deba": "Area",
|
"i18n:govoplan-ops.area.2745deba": "Area",
|
||||||
"i18n:govoplan-ops.baseline.e6ab7982": "Baseline",
|
"i18n:govoplan-ops.baseline.e6ab7982": "Baseline",
|
||||||
|
"i18n:govoplan-ops.authority.8802e425": "Berechtigungen",
|
||||||
"i18n:govoplan-ops.celery_worker_setting.323d7737": "Celery worker setting",
|
"i18n:govoplan-ops.celery_worker_setting.323d7737": "Celery worker setting",
|
||||||
"i18n:govoplan-ops.components.9289473e": "Components",
|
"i18n:govoplan-ops.components.9289473e": "Components",
|
||||||
"i18n:govoplan-ops.current_health_checks.7830bccf": "Current health checks",
|
"i18n:govoplan-ops.current_health_checks.7830bccf": "Current health checks",
|
||||||
|
"i18n:govoplan-ops.contracts.57d80902": "Verträge",
|
||||||
|
"i18n:govoplan-ops.controls.0cdb80fb": "Kontrollen",
|
||||||
|
"i18n:govoplan-ops.declared_governance_permissions.d08d3bf1": "Deklarierte Governance-Berechtigungen",
|
||||||
"i18n:govoplan-ops.deployment_profiles.b0caa179": "Deployment Profiles",
|
"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.enabled_in_the_runtime_registry.d2c6142d": "Enabled in the runtime registry",
|
||||||
|
"i18n:govoplan-ops.evidence.7ea014de": "Nachweise",
|
||||||
"i18n:govoplan-ops.fit.dab564d8": "Fit",
|
"i18n:govoplan-ops.fit.dab564d8": "Fit",
|
||||||
"i18n:govoplan-ops.health_checks.201c869f": "Health Checks",
|
"i18n:govoplan-ops.health_checks.201c869f": "Health Checks",
|
||||||
|
"i18n:govoplan-ops.governance_inventory.835d8e57": "Governance-Inventar",
|
||||||
"i18n:govoplan-ops.loading_operations_status.6890fe6e": "Loading operations status...",
|
"i18n:govoplan-ops.loading_operations_status.6890fe6e": "Loading operations status...",
|
||||||
"i18n:govoplan-ops.modules.04e9462c": "Module",
|
"i18n:govoplan-ops.modules.04e9462c": "Module",
|
||||||
|
"i18n:govoplan-ops.module.b8ff0289": "Modul",
|
||||||
"i18n:govoplan-ops.no_database_url.51a2db0c": "No database URL",
|
"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_deployment_profiles_reported.7c3af1db": "No deployment profiles reported.",
|
||||||
"i18n:govoplan-ops.no_health_checks_reported.03c067c4": "No health checks reported.",
|
"i18n:govoplan-ops.no_health_checks_reported.03c067c4": "No health checks reported.",
|
||||||
|
"i18n:govoplan-ops.no_modules_reported.847f06d9": "Keine Module gemeldet.",
|
||||||
"i18n:govoplan-ops.no_sizing_assumptions_reported.17515959": "No sizing assumptions reported.",
|
"i18n:govoplan-ops.no_sizing_assumptions_reported.17515959": "No sizing assumptions reported.",
|
||||||
"i18n:govoplan-ops.operator_note.1dc58f7b": "Operator note",
|
"i18n:govoplan-ops.operator_note.1dc58f7b": "Operator note",
|
||||||
"i18n:govoplan-ops.ops.907a54c2": "Betrieb",
|
"i18n:govoplan-ops.ops.907a54c2": "Betrieb",
|
||||||
|
"i18n:govoplan-ops.permissions.842c35eb": "Berechtigungen",
|
||||||
|
"i18n:govoplan-ops.policies.e7800f56": "Richtlinien",
|
||||||
"i18n:govoplan-ops.profile.ff4fc027": "Profil",
|
"i18n:govoplan-ops.profile.ff4fc027": "Profil",
|
||||||
"i18n:govoplan-ops.readiness.1db9d6fb": "Bereitschaft",
|
"i18n:govoplan-ops.readiness.1db9d6fb": "Bereitschaft",
|
||||||
"i18n:govoplan-ops.reload.cce71553": "Neu laden",
|
"i18n:govoplan-ops.reload.cce71553": "Neu laden",
|
||||||
"i18n:govoplan-ops.redis.5eaa1f2f": "Redis",
|
"i18n:govoplan-ops.redis.5eaa1f2f": "Redis",
|
||||||
|
"i18n:govoplan-ops.registered_policy_capabilities.112a2b64": "Registrierte Richtlinien-Fähigkeiten",
|
||||||
"i18n:govoplan-ops.no_readiness_blockers.0df259bd": "Keine Bereitschaftsblocker",
|
"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.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.scale_trigger.1c85e10e": "Scale trigger",
|
||||||
|
|||||||
+17
-2
@@ -14,6 +14,7 @@ const dashboardWidgets: DashboardWidgetsUiCapability = {
|
|||||||
widgets: [
|
widgets: [
|
||||||
{
|
{
|
||||||
id: "ops.health",
|
id: "ops.health",
|
||||||
|
surfaceId: "ops.widget.health",
|
||||||
title: "Operations health",
|
title: "Operations health",
|
||||||
description: "Readiness, worker mode, and current warning count.",
|
description: "Readiness, worker mode, and current warning count.",
|
||||||
moduleId: "ops",
|
moduleId: "ops",
|
||||||
@@ -34,9 +35,23 @@ export const opsModule: PlatformWebModule = {
|
|||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["audit", "docs", "notifications"],
|
optionalDependencies: ["audit", "docs", "notifications"],
|
||||||
translations,
|
translations,
|
||||||
navItems: [{ to: "/ops", label: "i18n:govoplan-ops.ops.907a54c2", iconName: "activity", anyOf: opsReadScopes, order: 890 }],
|
viewSurfaces: [
|
||||||
|
{ id: "ops.navigation", moduleId: "ops", kind: "navigation", label: "i18n:govoplan-ops.surface.navigation", order: 10 },
|
||||||
|
{ id: "ops.page", moduleId: "ops", kind: "route", label: "i18n:govoplan-ops.surface.page", order: 20 },
|
||||||
|
{ id: "ops.page.summary", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.summary", parentId: "ops.page", order: 10 },
|
||||||
|
{ id: "ops.page.health", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.health", parentId: "ops.page", order: 20 },
|
||||||
|
{ id: "ops.page.runtime", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.runtime", parentId: "ops.page", order: 30 },
|
||||||
|
{ id: "ops.page.recovery", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.recovery", parentId: "ops.page", order: 40 },
|
||||||
|
{ id: "ops.page.governance", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.governance", parentId: "ops.page", order: 50 },
|
||||||
|
{ id: "ops.page.deployment", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.deployment", parentId: "ops.page", order: 60 },
|
||||||
|
{ id: "ops.page.sizing", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.sizing", parentId: "ops.page", order: 70 },
|
||||||
|
{ id: "ops.action.run-probes", moduleId: "ops", kind: "action", label: "i18n:govoplan-ops.surface.run_probes", parentId: "ops.page.health", order: 80 },
|
||||||
|
{ id: "ops.action.drain-node", moduleId: "ops", kind: "action", label: "i18n:govoplan-ops.surface.drain_node", parentId: "ops.page.runtime", order: 90 },
|
||||||
|
{ id: "ops.widget.health", moduleId: "ops", kind: "section", label: "Operations health widget", order: 100 }
|
||||||
|
],
|
||||||
|
navItems: [{ to: "/ops", label: "i18n:govoplan-ops.ops.907a54c2", iconName: "activity", anyOf: opsReadScopes, order: 890, surfaceId: "ops.navigation" }],
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/ops", anyOf: opsReadScopes, order: 890, render: ({ settings }) => createElement(OpsPage, { settings }) }],
|
{ path: "/ops", anyOf: opsReadScopes, order: 890, surfaceId: "ops.page", render: ({ settings, auth }) => createElement(OpsPage, { settings, auth }) }],
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"dashboard.widgets": dashboardWidgets
|
"dashboard.widgets": dashboardWidgets
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
heartbeatAgeLabel,
|
||||||
|
heartbeatAgeSeconds,
|
||||||
|
knownMetric,
|
||||||
|
knownQueueDepthTotal,
|
||||||
|
runtimeWorkTone,
|
||||||
|
shouldPollRuntimeStatus
|
||||||
|
} from "../src/features/ops/runtimeStatus";
|
||||||
|
|
||||||
|
assert.equal(knownMetric(null), "unavailable");
|
||||||
|
assert.equal(knownMetric(0), 0);
|
||||||
|
assert.equal(knownQueueDepthTotal({ mail: null }), null);
|
||||||
|
assert.equal(knownQueueDepthTotal({ mail: 0, calendar: 2 }), 2);
|
||||||
|
assert.equal(runtimeWorkTone("stale"), "danger");
|
||||||
|
assert.equal(runtimeWorkTone("disabled"), "neutral");
|
||||||
|
assert.equal(heartbeatAgeSeconds(Date.parse("2026-08-19T12:01:00Z"), "2026-08-19T12:00:00Z"), 60);
|
||||||
|
assert.equal(heartbeatAgeLabel(60), "1m ago");
|
||||||
|
assert.equal(heartbeatAgeLabel(null), "unavailable");
|
||||||
|
assert.equal(shouldPollRuntimeStatus(true, false), false);
|
||||||
|
assert.equal(shouldPollRuntimeStatus(false, true), false);
|
||||||
|
assert.equal(shouldPollRuntimeStatus(false, false), true);
|
||||||
|
|
||||||
|
console.log("Ops runtime status and polling model tests passed.");
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"target": "ES2022",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"typeRoots": ["../../govoplan-core/webui/node_modules/@types"],
|
||||||
|
"types": ["node"],
|
||||||
|
"outDir": ".runtime-status-test-build"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/features/ops/runtimeStatus.ts",
|
||||||
|
"tests/runtime-status.test.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user