chore: sync GovOPlaN module split state

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

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
build/
dist/
node_modules/
webui/node_modules/
webui/dist/
*.tsbuildinfo
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/

19
AGENTS.md Normal file
View File

@@ -0,0 +1,19 @@
# GovOPlaN Ops Codex Guide
## Scope
This repository owns the GovOPlaN operations module seed: runtime health,
deployment profile visibility, worker split assumptions, sizing assumptions,
maintenance readiness, and operator-facing status surfaces.
## Boundaries
- Depend on kernel contracts from `govoplan-core`.
- Do not import feature-module internals.
- Expose integration through manifests, API routes, permissions, and typed DTOs.
- Keep destructive operator actions in core safety-controlled workflows.
## Local Workflow
Use Gitea issues as the canonical backlog and state log. Focused verification is
still driven from `/mnt/DATA/git/govoplan-core` for cross-repository behavior.

23
README.md Normal file
View File

@@ -0,0 +1,23 @@
# govoplan-ops
GovOPlaN Ops provides the operator surface for deployment health, runtime
profile visibility, worker split assumptions, and sizing guidance.
## Ownership
This repository owns:
- backend module manifest `ops`
- operator-facing status APIs
- deployment profile and sizing assumption summaries
- WebUI route contribution `@govoplan/ops-webui`
- future operational runbooks that describe the configured platform rather than
generic product possibilities
Core owns lifecycle management, module discovery, maintenance mode, package
installation safety, and shared WebUI shell behavior.
## Runbooks
- `docs/SCALABILITY_PROFILES.md` explains how to use the Ops page with the
canonical sizing matrix, readiness model, and profile-selection worksheet.

View File

@@ -0,0 +1,268 @@
# GovOPlaN Ops Scalability Profiles
`govoplan-ops` presents the operator-facing view of deployment health,
readiness, worker split, and sizing assumptions. This runbook is the canonical
home for sizing profiles and explains how an operator should use them with the
live Ops page.
## Operator Flow
1. Open Admin > Ops.
2. Confirm the active runtime profile and readiness state.
3. Check whether Redis and worker queues match the intended deployment shape.
4. Compare the installation with the sizing worksheet in the core guide.
5. Resolve readiness blockers before enabling package operations, queue-heavy
campaigns, imports, exports, or workflow automation.
6. Record the current profile and open measurements before moving to a larger
topology.
## Live Profile Signals
The Ops API reports:
- current profile (`local-dev`, `production-like-dev`, `single-process`, or
`split-worker`)
- module count
- redacted database and Redis URLs
- Celery enabled state and queue list
- maintenance mode state
- file-storage backend
- HTTP/certificate deployment posture through the `deployment_security` check
- readiness blockers
- reference deployment profiles and sizing assumptions
These values are intentionally diagnostic. They do not replace deployment
configuration management, backups, monitoring, or restore drills.
`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
production it becomes readiness-critical because TLS certificates, proxy
headers, secure cookies, and explicit WebUI origins must be handled by the
deployment before normal traffic.
## Promotion Rules
Promote from local development to a production-like profile when a feature
depends on PostgreSQL, Redis, Celery, module package lifecycle, or durable file
storage.
Promote from a single-process profile to a split-worker profile when queued
work becomes part of normal operation:
- campaign sending or IMAP append
- connector import/export
- report generation
- workflow/task automation
- scheduled maintenance jobs
- package install/uninstall daemon work
Promote from local storage to object/shared storage before running more than
one API process.
## Measurements To Capture
Before increasing capacity, capture:
- peak concurrent users and API latency
- database size, slow queries, and backup/restore duration
- file count, total bytes, and monthly growth
- queue depth, oldest job age, retry rate, and permanent failure rate
- SMTP/IMAP provider throttling responses
- connector transfer duration and failure rate
- worker CPU/memory saturation
- audit/retention growth
The future requirements calculator should accept these measurements and return
the structured recommendation described below.
## Deployment Topologies
| Profile | Intended Use | Topology |
| --- | --- | --- |
| Development | Local feature work and module tests | One API process, Vite dev server, SQLite or disposable PostgreSQL, local file storage, synchronous workers. |
| Pilot | Single office, non-critical early usage | One API process, one WebUI build, PostgreSQL, local or object storage, optional Redis, one worker process when queues are enabled. |
| Small Production | One tenant or small agency | Two API processes behind a reverse proxy, PostgreSQL with backups, durable storage, Redis/Celery workers, health monitoring. |
| Medium Deployment | Municipal deployment with multiple departments | Separate WebUI, API, worker, scheduler, PostgreSQL, object storage, Redis, backup host, metrics/log collection. |
| Shared Platform | Multiple tenants or high campaign/workflow volume | Horizontally scaled WebUI/API/workers, managed PostgreSQL, object storage, queue/cache HA, central monitoring, controlled maintenance windows. |
## Stateless And Stateful Components
Stateless and horizontally replicable:
- WebUI static assets
- API workers when `MASTER_KEY_B64`, `DATABASE_URL`, storage, queue, and module
configuration are shared
- Background workers when queues and idempotency keys are used
- Scheduler replicas only when leader election or an external lock exists
Stateful or singleton-sensitive:
- PostgreSQL
- local file storage when not replaced by object storage
- Redis/queue state
- module installer daemon and package mutation operations
- migration execution
- scheduler without distributed locking
- outgoing campaign append/send jobs unless claim tokens are enforced
## Readiness And Degraded Modes
| 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. |
| 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. |
| Storage | Configured backend is reachable and writable for write flows | Read-only file views may continue if storage is read-only but reachable. |
| Redis/Celery | Broker reachable and worker queues have heartbeats | Synchronous dev-only workflows may continue; production async send/workflow queues are degraded. |
| Installer daemon | Lock is free or owned by a live daemon; latest status is fresh | Admin UI can plan changes but not execute them. |
| Mail transport | SMTP/IMAP profiles validate for the selected scope | Campaign validation blocks send/append but allows draft editing. |
## Queue And Worker Scaling
Worker pools should be split by queue once load appears:
- `send_email`: SMTP send throughput, rate limits, retries, and outcome
uncertainty.
- `append_sent`: IMAP append latency and mailbox-side throttling.
- `workflow`: process orchestration and case/task state transitions.
- `transform`: datasource extraction, transformation, and export jobs.
- `notifications`: postbox, email notification, calendar, and external
notification fan-out.
- `reporting`: long-running exports, aggregates, and audit/report generation.
Scaling signal examples:
- queue depth and oldest queued job age
- retry rate and permanent failure rate
- worker CPU and memory saturation
- database lock time and query latency
- SMTP/IMAP provider throttling responses
- storage upload/download latency
Autoscaling should have upper bounds per queue. Mail and connector queues often
hit external throttles before CPU is exhausted, so adding workers blindly can
make failures worse.
## Database Path
PostgreSQL is the production database. SQLite remains a local-development and
tiny disposable profile only.
Production migrations should run explicitly before startup or package
activation. Module install/uninstall workflows must use database backup and
restore-check hooks for PostgreSQL before migrations or destructive retirement.
## First Sizing Matrix
These numbers are starting assumptions, not guarantees. Measure and adjust once
real workload metrics exist.
| Profile | CPU | Memory | Database | Storage | Queue/Cache | Backup/Monitoring Assumptions |
| --- | ---: | ---: | --- | --- | --- | --- |
| Development | 2 cores | 4-8 GB | SQLite or local PostgreSQL | local disk | optional | no SLA; manual reset acceptable |
| Pilot | 2-4 cores | 8 GB | PostgreSQL on same host or small managed instance | 100-500 GB durable local/object storage | optional Redis | daily DB backup; basic health checks |
| Small Production | 4-8 cores | 16 GB | dedicated PostgreSQL, 2-4 vCPU, 8-16 GB RAM | 0.5-2 TB object/durable storage | Redis plus 1-2 workers | daily full backup plus WAL/snapshot policy; uptime alerts |
| Medium Deployment | 8-16 API/worker cores total | 32-64 GB total | PostgreSQL 4-8 vCPU, 16-64 GB RAM | 2-10 TB object storage | Redis, separate worker pools | central logs/metrics, tested restore, queue alerts |
| Shared Platform | sized from measured load | 64 GB+ total | managed HA PostgreSQL, read replicas only after profiling | 10 TB+ object storage | HA queue/cache if required | SLOs, restore drills, capacity alerts, maintenance windows |
## Workload Dimensions For A Calculator
A later calculator should ask for:
- tenants and active users
- concurrent sessions and peak request rate
- files per month, average/max file size, and retention window
- cases/tasks/workflows per month
- campaigns per month, recipients per campaign, and send window
- IMAP append and inbound mailbox volume
- datasource/import/export job volume and file sizes
- report/audit query frequency
- retention policy and audit growth
- required recovery point and recovery time objectives
## Profile Selection Worksheet
The first calculator can be rule-based. It should recommend the lowest profile
that satisfies all hard constraints, then show the inputs that pushed the
operator upward.
| Input | Pilot Threshold | Small Production Threshold | Medium Threshold | Shared Platform Threshold |
| --- | ---: | ---: | ---: | ---: |
| Active tenants | 1 | 1-5 | 5-25 | 25+ |
| Concurrent users | up to 10 | up to 50 | up to 250 | measured/contracted |
| Managed files | under 100 GB | 100 GB-2 TB | 2-10 TB | 10 TB+ |
| Campaign recipients/month | under 5,000 | 5,000-100,000 | 100,000-1,000,000 | provider-limited or multi-tenant |
| Workflow/import jobs/day | under 100 | 100-2,000 | 2,000-25,000 | queue-specific scaling |
| Recovery point objective | daily backup | daily plus WAL/snapshots | tested restore, tighter RPO | formal SLO/SLA |
| Process split requirement | optional | API plus worker | API, workers, scheduler split | horizontal replicas |
Hard constraints override the numeric thresholds:
- Multiple API replicas require PostgreSQL and shared storage.
- Cross-node file access requires object storage or a shared durable file
service, not node-local disk.
- Async campaign send, append, imports, exports, or workflows require Redis and
workers outside development.
- Package install/uninstall in production requires maintenance mode, backup,
restore-check hooks, and installer daemon visibility.
- Autoscaling requires idempotent job claims, readiness probes, external
throttling limits, and queue-specific maximum replica counts.
## Calculator Output Contract
The future UI calculator should emit a structured recommendation:
```json
{
"recommended_profile": "small-production",
"minimum_components": [
"PostgreSQL",
"Redis",
"API process",
"worker process",
"durable file storage"
],
"reasons": [
"Campaign recipients/month exceed pilot threshold.",
"Async mail delivery requires worker split."
],
"warnings": [
"Object storage is recommended before adding a second API node."
],
"open_measurements": [
"Peak concurrent users",
"Database backup restore duration"
]
}
```
Until the calculator is implemented, operators should fill the worksheet
manually and compare it with the Ops page's current profile, readiness checks,
worker queues, and sizing assumptions.
## Minimum Production Requirements
A production deployment, even a small one, should have:
- PostgreSQL, explicit migrations, backups, and a tested restore path.
- A stable `MASTER_KEY_B64` stored outside the repository.
- HTTPS, exact CORS origins, secure cookies, and a reverse proxy.
- Durable file storage with backup or object-store lifecycle policy.
- Redis plus at least one worker when any queued module behavior is enabled.
- Health/readiness checks visible in `govoplan-ops`.
- Maintenance-mode access assigned to at least one operator account.
- Module catalog trust roots and license trust roots pinned by deployment
configuration.
## Manual Before Automatic
The first supported production scaling path is manual:
1. Move from SQLite/local storage to PostgreSQL and durable storage.
2. Add workers and Redis for queue-backed operations.
3. Split WebUI/API/worker processes.
4. Add health checks and deployment profile warnings.
5. Add metrics and queue-depth alerts.
6. Scale API and worker replicas with fixed limits.
7. Add autoscaling only after idempotency, readiness, and external throttles are
understood.

35
package.json Normal file
View File

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

24
pyproject.toml Normal file
View File

@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-ops"
version = "0.1.6"
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.6",
"govoplan-access>=0.1.6",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_ops = ["py.typed"]
[project.entry-points."govoplan.modules"]
ops = "govoplan_ops.backend.manifest:get_manifest"

View File

@@ -0,0 +1,5 @@
"""GovOPlaN operations module."""
__all__ = ["__version__"]
__version__ = "0.1.6"

View File

@@ -0,0 +1 @@
"""Backend integration for the GovOPlaN ops module."""

View File

@@ -0,0 +1 @@
"""Ops API package."""

View File

@@ -0,0 +1 @@
"""Ops API v1 package."""

View File

@@ -0,0 +1,285 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_any_scope
from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.registry import PlatformRegistry
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
router = APIRouter(prefix="/ops", tags=["ops"])
@router.get("/status")
def ops_status(
request: Request,
principal: ApiPrincipal = Depends(require_any_scope(*OPS_READ_SCOPES)),
) -> dict[str, Any]:
del principal
return _ops_status_payload(request)
@router.get("/readiness")
def ops_readiness(
request: Request,
principal: ApiPrincipal = Depends(require_any_scope(*OPS_READ_SCOPES)),
) -> dict[str, Any]:
del principal
payload = _ops_status_payload(request)
readiness = payload["readiness"]
if not readiness["ready"]:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=readiness)
return readiness
def _ops_status_payload(request: Request) -> dict[str, Any]:
registry = _registry(request)
database = _database_status()
maintenance_mode = database.get("maintenance_mode") if isinstance(database.get("maintenance_mode"), dict) else {"enabled": False, "message": None}
redis_check = _redis_check()
current_profile = _current_profile()
checks = [
_check("module_registry", "Module registry", "ok", f"{len(registry.manifests())} modules enabled."),
_check("database", "Database", "ok" if database["ok"] else "error", str(database["detail"])),
_maintenance_check(maintenance_mode),
redis_check,
_worker_check(),
_storage_check(),
_deployment_security_check(current_profile),
]
readiness = _readiness(checks, maintenance_mode)
return {
"summary": {
"app_env": core_settings.app_env,
"active_profile": current_profile,
"module_count": len(registry.manifests()),
"celery_enabled": bool(core_settings.celery_enabled),
"celery_queues": _celery_queues(),
"redis_url": _redact_url(core_settings.redis_url),
"maintenance_mode": maintenance_mode,
"database_url": _redact_url(core_settings.database_url),
"file_storage_backend": core_settings.file_storage_backend,
},
"readiness": readiness,
"checks": checks,
"deployment_profiles": _deployment_profiles(current_profile),
"sizing": _sizing_assumptions(),
}
def _registry(request: Request) -> PlatformRegistry:
registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module registry is not configured")
return registry
def _database_status() -> dict[str, Any]:
try:
with get_database().session() as session:
session.execute(text("select 1"))
maintenance = saved_maintenance_mode(session).as_dict()
return {"ok": True, "detail": "Database session succeeded.", "maintenance_mode": maintenance}
except (RuntimeError, SQLAlchemyError) as exc:
return {"ok": False, "detail": f"Database check failed: {exc}", "maintenance_mode": {"enabled": False, "message": None}}
def _maintenance_check(maintenance_mode: dict[str, Any]) -> dict[str, Any]:
if maintenance_mode.get("enabled"):
message = str(maintenance_mode.get("message") or "Maintenance mode is enabled.")
return _check("maintenance_mode", "Maintenance mode", "warning", message, readiness_critical=True)
return _check("maintenance_mode", "Maintenance mode", "ok", "Maintenance mode is off.")
def _redis_check() -> dict[str, Any]:
if not core_settings.celery_enabled:
return _check("redis_broker", "Redis broker", "inactive", "Redis is not required while Celery is disabled.")
try:
from redis import Redis
client = Redis.from_url(core_settings.redis_url, socket_connect_timeout=0.75, socket_timeout=0.75)
client.ping()
return _check("redis_broker", "Redis broker", "ok", f"Redis broker reachable at {_redact_url(core_settings.redis_url)}.")
except Exception as exc: # noqa: BLE001 - diagnostic endpoint should report the concrete failure.
return _check("redis_broker", "Redis broker", "error", f"Redis broker check failed: {exc}", readiness_critical=True)
def _worker_check() -> dict[str, Any]:
if not core_settings.celery_enabled:
return _check("worker_split", "Background workers", "warning", "Celery is disabled; long-running work executes only through synchronous or dev paths.")
try:
from govoplan_core.celery_app import celery
inspector = celery.control.inspect(timeout=0.75)
replies = inspector.ping() or {}
except Exception as exc: # noqa: BLE001 - diagnostic endpoint should report the concrete failure.
return _check("worker_split", "Background workers", "error", f"Worker heartbeat check failed: {exc}", readiness_critical=True)
if replies:
worker_names = ", ".join(sorted(replies))
return _check("worker_split", "Background workers", "ok", f"{len(replies)} worker(s) replied: {worker_names}.")
return _check("worker_split", "Background workers", "error", "Celery is enabled, but no workers replied to heartbeat.", readiness_critical=True)
def _storage_check() -> dict[str, Any]:
backend = str(core_settings.file_storage_backend or "local").lower()
if backend == "s3":
configured = bool(core_settings.file_storage_s3_endpoint_url and core_settings.file_storage_s3_bucket)
return _check("file_storage", "File storage", "ok" if configured else "warning", "S3 file storage is configured." if configured else "S3 file storage needs endpoint and bucket settings.", readiness_critical=not configured)
root = Path(str(core_settings.file_storage_local_root or "runtime/files"))
if root.exists() and root.is_dir():
writable = os.access(root, os.W_OK)
state = "ok" if writable else "warning"
detail = f"Local file storage root: {root}" if writable else f"Local file storage root is not writable: {root}"
return _check("file_storage", "File storage", state, detail, readiness_critical=not writable)
return _check("file_storage", "File storage", "warning", f"Local file storage root does not exist yet: {root}", readiness_critical=False)
def _deployment_security_check(current_profile: str) -> dict[str, Any]:
app_env = str(core_settings.app_env or "").lower()
if app_env in {"dev", "test", "local"}:
return _check("deployment_security", "HTTP and certificates", "inactive", "Local/test profile; HTTPS and secure-cookie checks are deployment-owned.")
blockers: list[str] = []
if not core_settings.auth_cookie_secure:
blockers.append("AUTH_COOKIE_SECURE=false")
cors_origins = _cors_origins()
if not cors_origins:
blockers.append("CORS_ORIGINS is empty")
elif any(origin == "*" or origin.startswith("http://localhost") or origin.startswith("http://127.0.0.1") for origin in cors_origins):
blockers.append("CORS_ORIGINS contains local or wildcard origins")
if not blockers:
return _check("deployment_security", "HTTP and certificates", "ok", f"{current_profile} uses secure cookies and explicit non-local CORS origins.")
profile_label = "production" if app_env in {"prod", "production"} else app_env or current_profile
detail = f"{profile_label} profile needs deployment HTTP/certificate hardening: {', '.join(blockers)}."
return _check(
"deployment_security",
"HTTP and certificates",
"warning",
detail,
readiness_critical=app_env in {"prod", "production"},
)
def _check(check_id: str, label: str, state: str, detail: str, *, readiness_critical: bool = False) -> dict[str, Any]:
return {"id": check_id, "label": label, "state": state, "detail": detail, "readiness_critical": readiness_critical}
def _readiness(checks: list[dict[str, Any]], maintenance_mode: dict[str, Any]) -> dict[str, Any]:
blockers = [
{"id": check["id"], "label": check["label"], "state": check["state"], "detail": check["detail"]}
for check in checks
if check.get("readiness_critical") and check.get("state") != "ok"
]
if maintenance_mode.get("enabled") and not any(item["id"] == "maintenance_mode" for item in blockers):
blockers.append({"id": "maintenance_mode", "label": "Maintenance mode", "state": "warning", "detail": str(maintenance_mode.get("message") or "Maintenance mode is enabled.")})
return {
"ready": not blockers,
"blockers": blockers,
"profile": _current_profile(),
}
def _current_profile() -> str:
app_env = str(core_settings.app_env or "").lower()
if app_env == "dev" and core_settings.celery_enabled:
return "production-like-dev"
if app_env == "dev":
return "local-dev"
if core_settings.celery_enabled:
return "split-worker"
return "single-process"
def _celery_queues() -> list[str]:
return [item.strip() for item in str(core_settings.celery_queues or "").split(",") if item.strip()]
def _cors_origins() -> list[str]:
return [item.strip() for item in str(core_settings.cors_origins or "").split(",") if item.strip()]
def _deployment_profiles(current_profile: str) -> list[dict[str, Any]]:
profiles = [
{
"id": "local-dev",
"name": "Local development",
"current": current_profile == "local-dev",
"components": ["FastAPI", "SQLite or local database", "Vite dev server", "local file storage"],
"fit": "Developer workstation, demos, and module integration work.",
},
{
"id": "production-like-dev",
"name": "Production-like development",
"current": current_profile == "production-like-dev",
"components": ["FastAPI", "Vite dev server", "PostgreSQL", "Redis broker", "Celery worker", "durable local file storage"],
"fit": "Local validation of the production dependency shape without publishing packages.",
},
{
"id": "single-process",
"name": "Small institution",
"current": current_profile == "single-process",
"components": ["FastAPI", "PostgreSQL", "reverse proxy", "scheduled backups", "local or S3-compatible file storage"],
"fit": "Low-volume internal administration without heavy background jobs.",
},
{
"id": "split-worker",
"name": "Institution platform",
"current": current_profile == "split-worker",
"components": ["FastAPI", "worker process", "Redis broker", "PostgreSQL", "object storage", "health probes"],
"fit": "Campaigns, imports, exports, scheduled work, and larger tenant counts.",
},
]
return profiles
def _sizing_assumptions() -> list[dict[str, str]]:
return [
{
"area": "API",
"baseline": "One process behind a reverse proxy.",
"scale_trigger": "Sustained request latency or concurrent admin/portal usage.",
"operator_note": "Scale horizontally only after session, CSRF, and proxy headers are stable.",
},
{
"area": "Workers",
"baseline": "Disabled in local development; one worker for production imports, mail, and package jobs.",
"scale_trigger": "Backlogs in send_email, append_sent, imports, or export queues.",
"operator_note": "Keep package installer execution separate from ordinary worker queues.",
},
{
"area": "Database",
"baseline": "PostgreSQL for shared-tenancy production.",
"scale_trigger": "Tenant growth, audit volume, DMS/file metadata, or reporting workloads.",
"operator_note": "Backups and restore checks are part of the deployment profile, not optional documentation.",
},
{
"area": "Files",
"baseline": "Local storage for development; S3-compatible object storage for production.",
"scale_trigger": "Cross-node deployments, DMS volume, or file retention requirements.",
"operator_note": "Retention policy and legal-hold behavior should be verified before large imports.",
},
]
def _redact_url(value: str) -> str:
try:
parsed = urlsplit(value)
except ValueError:
return "<invalid>"
if parsed.username or parsed.password:
host = parsed.hostname or ""
port = f":{parsed.port}" if parsed.port else ""
return f"{parsed.scheme}://<credentials>@{host}{port}{parsed.path}"
return value

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
OPS_READ_SCOPE = "ops:operations:read"
OPS_READ_SCOPES = (OPS_READ_SCOPE, "system:settings:read", "admin:settings:read")
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Operations",
level="system",
module_id=module_id,
resource=resource,
action=action,
)
def _route_factory(context: ModuleContext):
del context
from govoplan_ops.backend.api.v1.routes import router
return router
manifest = ModuleManifest(
id="ops",
name="Ops",
version="0.1.6",
dependencies=("access",),
optional_dependencies=("audit", "docs", "notifications"),
permissions=(
_permission(
OPS_READ_SCOPE,
"View operations status",
"Read runtime health, deployment profile, and sizing information.",
),
),
role_templates=(
RoleTemplate(
slug="ops_reader",
name="Operations reader",
description="Read platform health and deployment profile information.",
permissions=(OPS_READ_SCOPE,),
level="system",
),
),
route_factory=_route_factory,
nav_items=(NavItem(path="/ops", label="Ops", icon="activity", required_any=OPS_READ_SCOPES, order=890),),
frontend=FrontendModule(
module_id="ops",
package_name="@govoplan/ops-webui",
routes=(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),),
),
)
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -0,0 +1 @@

30
webui/package.json Normal file
View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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