3 Commits
Author SHA1 Message Date
zemion 489d44d18d Release govoplan-ops v0.1.22: unify interface contracts and documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 01:32:48 +02:00
zemion ac6b211827 feat: expose infrastructure dependency inventory
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 14:56:38 +02:00
zemion b175658a0f docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:35 +02:00
12 changed files with 352 additions and 16 deletions
+12
View File
@@ -49,6 +49,18 @@ The Ops API reports:
- rendered PostgreSQL connection peak, declared server limit, and operator reserve
- recovery operation status, mode, checkpoint count, and last update
The separately authorized
`GET /api/v1/ops/infrastructure/dependencies` endpoint collects current,
module-owned infrastructure dependencies through the Core provider contract.
It is intended for the host deployer's destructive-change preflight, not for a
general data export. Results contain stable configuration references, lifecycle
states, scopes, numeric counts and required preparation actions; they never
contain credentials, tenant identifiers, file keys or secret-bearing endpoint
values. Ops itself contributes the current database, Redis coordination,
ingress, and load-balancing runtime bindings; feature modules contribute their
own persisted configuration and data. One unavailable or invalid provider makes the result incomplete, so a
caller must fail closed rather than treating missing provider data as zero.
These values are intentionally diagnostic. They do not replace deployment
configuration management, backups, monitoring, or restore drills.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/ops-webui",
"version": "0.1.19",
"version": "0.1.22",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -17,7 +17,7 @@
"README.md"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-ops"
version = "0.1.19"
version = "0.1.22"
description = "GovOPlaN operations module for health, deployment profile, and sizing visibility."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.18",
"govoplan-core>=0.1.45",
"govoplan-access>=0.1.18",
]
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.19"
__version__ = "0.1.22"
+18 -1
View File
@@ -47,7 +47,10 @@ from govoplan_core.db.session import get_database
from govoplan_core.settings import settings as core_settings
from govoplan_ops.backend.manifest import OPS_READ_SCOPES, OPS_RUN_SCOPES
from govoplan_ops.backend.infrastructure import deployment_capability_status
from govoplan_ops.backend.infrastructure import (
deployment_capability_status,
infrastructure_dependency_inventory,
)
router = APIRouter(prefix="/ops", tags=["ops"])
_module_check_cache: dict[str, tuple[float, dict[str, Any]]] = {}
@@ -93,6 +96,20 @@ def run_ops_checks(
return _ops_status_payload(request, force_module_checks=True)
@router.get("/infrastructure/dependencies")
def infrastructure_dependencies(
request: Request,
principal: ApiPrincipal = Depends(require_any_scope(*OPS_READ_SCOPES)),
) -> dict[str, object]:
"""Return a fresh, non-secret inventory for host deployment preflight."""
del principal
return infrastructure_dependency_inventory(
_registry(request),
installation_id=core_settings.installation_id,
).to_dict()
@router.get("/runtime/nodes")
def runtime_nodes(
request: Request,
@@ -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.']}}
+116 -1
View File
@@ -3,8 +3,96 @@ from __future__ import annotations
from pathlib import Path
from govoplan_core.core.infrastructure_capabilities import (
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX,
InfrastructureDependency,
InfrastructureDependencyInventory,
InfrastructureDependencyProvider,
collect_infrastructure_dependency_inventory,
deployment_capability_status as _deployment_capability_status,
load_infrastructure_capability_receipt,
)
from govoplan_core.settings import settings as core_settings
OPS_INFRASTRUCTURE_DEPENDENCY_CAPABILITY = (
f"{INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX}ops"
)
class OpsInfrastructureDependencyProvider(InfrastructureDependencyProvider):
"""Report Core runtime bindings that optional feature providers do not own."""
module_id = "ops"
capability_ids = (
"database.postgresql",
"coordination.redis",
"network.ingress",
"runtime.load_balancing",
)
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
dependencies = [
InfrastructureDependency(
capability_id="database.postgresql",
module_id=self.module_id,
dependency_type="application_state_binding",
dependency_ref="runtime:database.postgresql",
state="runtime_binding",
scope="system",
summary=(
"GovOPlaN persists application and control-plane state in the active PostgreSQL database."
),
metrics={},
required_action=(
"Create and restore-verify a coordinated database backup before rebinding PostgreSQL."
),
),
InfrastructureDependency(
capability_id="network.ingress",
module_id=self.module_id,
dependency_type="public_runtime_binding",
dependency_ref="runtime:network.ingress",
state="runtime_binding",
scope="system",
summary="Ops and application APIs are reached through the active ingress boundary.",
metrics={},
required_action=(
"Verify trusted proxy headers, TLS, health, and public reachability on the replacement ingress."
),
),
InfrastructureDependency(
capability_id="runtime.load_balancing",
module_id=self.module_id,
dependency_type="runtime_routing_binding",
dependency_ref="runtime:load-balancing",
state="runtime_binding",
scope="system",
summary="API and WebUI replicas are reached through the runtime load balancer.",
metrics={},
required_action=(
"Verify health-aware API and WebUI routing before replacing the load balancer."
),
),
]
if core_settings.redis_url.strip():
dependencies.append(
InfrastructureDependency(
capability_id="coordination.redis",
module_id=self.module_id,
dependency_type="coordination_runtime_binding",
dependency_ref="runtime:coordination.redis",
state="runtime_binding",
scope="system",
summary=(
"The active runtime uses Redis for queues or shared coordination."
),
metrics={"celery_enabled": int(core_settings.celery_enabled)},
required_action=(
"Drain queued and in-flight work, verify idempotent recovery, and provision replacement coordination before rebinding or disabling Redis."
),
)
)
return tuple(dependencies)
def deployment_capability_status(
@@ -15,4 +103,31 @@ def deployment_capability_status(
return _deployment_capability_status(path)
__all__ = ["deployment_capability_status"]
def infrastructure_dependency_inventory(
registry: object,
*,
installation_id: str,
) -> InfrastructureDependencyInventory:
"""Collect module-owned persisted dependencies for an authorized host plan."""
receipt = load_infrastructure_capability_receipt()
if receipt is None:
raise ValueError(
"Infrastructure dependency inventory requires a mounted deployment receipt."
)
if receipt.installation_id != installation_id:
raise ValueError(
"Mounted deployment receipt belongs to a different installation."
)
return collect_infrastructure_dependency_inventory(
registry,
installation_id=installation_id,
)
__all__ = [
"deployment_capability_status",
"infrastructure_dependency_inventory",
"OPS_INFRASTRUCTURE_DEPENDENCY_CAPABILITY",
"OpsInfrastructureDependencyProvider",
]
+37 -5
View File
@@ -1,11 +1,19 @@
from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_ops.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from govoplan_ops.backend.infrastructure import (
OPS_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
OpsInfrastructureDependencyProvider,
)
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
@@ -92,7 +100,7 @@ def _route_factory(context: ModuleContext):
manifest = ModuleManifest(
id="ops",
name="Ops",
version="0.1.19",
version="0.1.22",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -134,15 +142,32 @@ manifest = ModuleManifest(
cache_seconds=15,
),
),
capability_factories={
OPS_INFRASTRUCTURE_DEPENDENCY_CAPABILITY: (
lambda context: OpsInfrastructureDependencyProvider()
),
},
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.",
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. The separately authorized infrastructure-dependency endpoint asks enabled module providers for their current persisted configuration and data dependencies. Each provider returns only stable references, states, scopes, numeric metrics, and required migration actions; one failed provider marks the entire fresh inventory incomplete so the host deployer blocks a capability change. 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"),
links=(
DocumentationLink(
label="Infrastructure dependency inventory API",
href="/api/v1/ops/infrastructure/dependencies",
kind="api",
),
DocumentationLink(
label="Operations profile handbook",
href="govoplan-ops/docs/SCALABILITY_PROFILES.md",
kind="repository",
),
),
translations={
"de": {
"title": "Plattformzustand und Bereitstellungsprofil prüfen",
@@ -156,7 +181,9 @@ manifest = ModuleManifest(
"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. "
"Ausstehende Aufgaben nach einer Installation bleiben mit einem stabilen Fortsetzungsschlüssel sichtbar. Der separat autorisierte Infrastruktur-Abhängigkeitsendpunkt fragt aktivierte Modulprovider nach ihren aktuellen gespeicherten Konfigurations- und Datenabhängigkeiten. "
"Jeder Provider liefert nur stabile Referenzen, Zustände, Geltungsbereiche, numerische Kennzahlen und erforderliche Migrationsmaßnahmen; schlägt ein Provider fehl, ist das gesamte frische Inventar unvollständig und der Host-Deployer blockiert die Fähigkeitsänderung. "
"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."
),
}
@@ -182,7 +209,7 @@ manifest = ModuleManifest(
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.",
summary="Ops projects shared runtime heartbeats, replica gaps, drain controls, and recovery states that require operator attention. Node controls remain pinned at the right edge of horizontally scrolled runtime tables.",
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"),
@@ -196,7 +223,7 @@ manifest = ModuleManifest(
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.",
"summary": "Ops projiziert gemeinsame Laufzeit-Heartbeats, Replikatlücken, Leerungssteuerung und Wiederherstellungszustände, die betriebliche Aufmerksamkeit erfordern. Die Knotensteuerung bleibt am rechten Rand horizontal gescrollter Laufzeittabellen angeheftet.",
"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. "
@@ -373,5 +400,10 @@ manifest = ModuleManifest(
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
+62 -1
View File
@@ -3,12 +3,73 @@ from __future__ import annotations
import json
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from govoplan_ops.backend.infrastructure import deployment_capability_status
from govoplan_core.core.infrastructure_capabilities import InfrastructureDependency
from govoplan_ops.backend.infrastructure import (
OpsInfrastructureDependencyProvider,
deployment_capability_status,
infrastructure_dependency_inventory,
)
class _Provider:
module_id = "mail"
capability_ids = ("mail.smtp",)
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
return (
InfrastructureDependency(
capability_id="mail.smtp",
module_id="mail",
dependency_type="smtp_endpoint",
dependency_ref="mail:server-1",
state="active",
scope="system",
summary="Persisted SMTP endpoint.",
metrics={"credential_binding_count": 1},
required_action="Rebind it before changing SMTP.",
),
)
class _Registry:
def capability_names(self) -> tuple[str, ...]:
return ("infrastructure.dependency_inventory.mail",)
def capability(self, name: str) -> object | None:
return _Provider() if name.endswith(".mail") else None
class InfrastructureCapabilityTests(unittest.TestCase):
def test_collects_authorized_module_dependency_inventory(self) -> None:
with patch(
"govoplan_ops.backend.infrastructure.load_infrastructure_capability_receipt",
return_value=SimpleNamespace(installation_id="govoplan-test"),
):
result = infrastructure_dependency_inventory(
_Registry(),
installation_id="govoplan-test",
).to_dict()
self.assertTrue(result["complete"])
self.assertEqual("mail.smtp", result["dependencies"][0]["capability_id"])
self.assertEqual(1, result["dependencies"][0]["metrics"]["credential_binding_count"])
def test_ops_provider_reports_runtime_bindings_without_endpoint_secrets(self) -> None:
dependencies = OpsInfrastructureDependencyProvider().infrastructure_dependencies()
by_capability = {item.capability_id: item for item in dependencies}
self.assertIn("database.postgresql", by_capability)
self.assertIn("network.ingress", by_capability)
self.assertEqual(
"runtime:database.postgresql",
by_capability["database.postgresql"].dependency_ref,
)
self.assertNotIn("postgresql://", json.dumps([item.to_dict() for item in dependencies]))
def test_reads_bounded_non_secret_capability_receipt(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-ops-capabilities-") as root:
path = Path(root) / "capabilities.json"
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/ops-webui",
"version": "0.1.19",
"version": "0.1.22",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -13,10 +13,11 @@
}
},
"scripts": {
"test:action-layout": "node scripts/test-action-layout.mjs",
"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": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+7
View File
@@ -0,0 +1,7 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const page = readFileSync(new URL("../src/features/ops/OpsPage.tsx", import.meta.url), "utf8");
assert.match(page, /id: "actions",\s*header: "",\s*width: 72,\s*sticky: "end",\s*resizable: false,\s*align: "right"/);
assert.match(page, /<TableActionGroup\s*minimumSlots=\{1\}/);
console.log("Runtime node controls use the shared pinned action layout.");
+4 -1
View File
@@ -399,7 +399,10 @@ function RuntimeNodeTable({
{
id: "actions",
header: "",
width: 56,
width: 72,
sticky: "end",
resizable: false,
align: "right",
value: (node) => node.state,
render: (node) => {
const busy = busyNodeId === node.node_id;