From 63ff9a3366013306e1482a3b380ea2b34aa08f25 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 12:51:22 +0200 Subject: [PATCH] chore: sync GovOPlaN module split state --- .gitignore | 23 ++ AGENTS.md | 19 ++ README.md | 23 ++ docs/SCALABILITY_PROFILES.md | 268 ++++++++++++++++++ package.json | 35 +++ pyproject.toml | 24 ++ src/govoplan_ops/__init__.py | 5 + src/govoplan_ops/backend/__init__.py | 1 + src/govoplan_ops/backend/api/__init__.py | 1 + src/govoplan_ops/backend/api/v1/__init__.py | 1 + src/govoplan_ops/backend/api/v1/routes.py | 285 ++++++++++++++++++++ src/govoplan_ops/backend/manifest.py | 64 +++++ src/govoplan_ops/py.typed | 1 + webui/package.json | 30 +++ webui/src/api/ops.ts | 58 ++++ webui/src/features/ops/OpsHealthWidget.tsx | 54 ++++ webui/src/features/ops/OpsPage.tsx | 147 ++++++++++ webui/src/i18n/generatedTranslations.ts | 64 +++++ webui/src/index.ts | 2 + webui/src/module.ts | 46 ++++ 20 files changed, 1151 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 docs/SCALABILITY_PROFILES.md create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 src/govoplan_ops/__init__.py create mode 100644 src/govoplan_ops/backend/__init__.py create mode 100644 src/govoplan_ops/backend/api/__init__.py create mode 100644 src/govoplan_ops/backend/api/v1/__init__.py create mode 100644 src/govoplan_ops/backend/api/v1/routes.py create mode 100644 src/govoplan_ops/backend/manifest.py create mode 100644 src/govoplan_ops/py.typed create mode 100644 webui/package.json create mode 100644 webui/src/api/ops.ts create mode 100644 webui/src/features/ops/OpsHealthWidget.tsx create mode 100644 webui/src/features/ops/OpsPage.tsx create mode 100644 webui/src/i18n/generatedTranslations.ts create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fd4061 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..732491a --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5ee6d3 --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/SCALABILITY_PROFILES.md b/docs/SCALABILITY_PROFILES.md new file mode 100644 index 0000000..d37d981 --- /dev/null +++ b/docs/SCALABILITY_PROFILES.md @@ -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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..063d5e9 --- /dev/null +++ b/package.json @@ -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 + } + } +} + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f408d0d --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/govoplan_ops/__init__.py b/src/govoplan_ops/__init__.py new file mode 100644 index 0000000..8bc3c2e --- /dev/null +++ b/src/govoplan_ops/__init__.py @@ -0,0 +1,5 @@ +"""GovOPlaN operations module.""" + +__all__ = ["__version__"] + +__version__ = "0.1.6" diff --git a/src/govoplan_ops/backend/__init__.py b/src/govoplan_ops/backend/__init__.py new file mode 100644 index 0000000..734c618 --- /dev/null +++ b/src/govoplan_ops/backend/__init__.py @@ -0,0 +1 @@ +"""Backend integration for the GovOPlaN ops module.""" diff --git a/src/govoplan_ops/backend/api/__init__.py b/src/govoplan_ops/backend/api/__init__.py new file mode 100644 index 0000000..0755136 --- /dev/null +++ b/src/govoplan_ops/backend/api/__init__.py @@ -0,0 +1 @@ +"""Ops API package.""" diff --git a/src/govoplan_ops/backend/api/v1/__init__.py b/src/govoplan_ops/backend/api/v1/__init__.py new file mode 100644 index 0000000..d571866 --- /dev/null +++ b/src/govoplan_ops/backend/api/v1/__init__.py @@ -0,0 +1 @@ +"""Ops API v1 package.""" diff --git a/src/govoplan_ops/backend/api/v1/routes.py b/src/govoplan_ops/backend/api/v1/routes.py new file mode 100644 index 0000000..a5743af --- /dev/null +++ b/src/govoplan_ops/backend/api/v1/routes.py @@ -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 "" + if parsed.username or parsed.password: + host = parsed.hostname or "" + port = f":{parsed.port}" if parsed.port else "" + return f"{parsed.scheme}://@{host}{port}{parsed.path}" + return value diff --git a/src/govoplan_ops/backend/manifest.py b/src/govoplan_ops/backend/manifest.py new file mode 100644 index 0000000..f5889b9 --- /dev/null +++ b/src/govoplan_ops/backend/manifest.py @@ -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 diff --git a/src/govoplan_ops/py.typed b/src/govoplan_ops/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_ops/py.typed @@ -0,0 +1 @@ + diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..29e68b9 --- /dev/null +++ b/webui/package.json @@ -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 + } + } +} diff --git a/webui/src/api/ops.ts b/webui/src/api/ops.ts new file mode 100644 index 0000000..e687d7d --- /dev/null +++ b/webui/src/api/ops.ts @@ -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 { + return apiFetch(settings, "/api/v1/ops/status"); +} diff --git a/webui/src/features/ops/OpsHealthWidget.tsx b/webui/src/features/ops/OpsHealthWidget.tsx new file mode 100644 index 0000000..d103ac1 --- /dev/null +++ b/webui/src/features/ops/OpsHealthWidget.tsx @@ -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(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 ( + + {error && {error}} +
+ + + +
+ {status?.readiness.blockers.length ? +
+ {status.readiness.blockers.slice(0, 3).map((blocker) => +
+
+
{blocker.label} · {blocker.detail}
+
+ )} +
: +

No readiness blockers reported.

+ } +
); +} + diff --git a/webui/src/features/ops/OpsPage.tsx b/webui/src/features/ops/OpsPage.tsx new file mode 100644 index 0000000..bd8b5dd --- /dev/null +++ b/webui/src/features/ops/OpsPage.tsx @@ -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(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 ( +
+
+
+ i18n:govoplan-ops.ops.907a54c2 +

i18n:govoplan-ops.runtime_health_deployment_profile_worker_split_a.55340156

+
+
+ +
+
+ + {error && {error}} + + +
+ + + + + + +
+ +
+ + + + + + + + + + + +
+
+
); + +} + +function CheckList({ checks }: {checks: OpsCheck[];}) { + if (!checks.length) return

i18n:govoplan-ops.no_health_checks_reported.03c067c4

; + return ( +
+ {checks.map((check) => +
+
+
{check.label} · {check.detail}
+
+ )} +
); + +} + +function ProfileList({ profiles }: {profiles: OpsDeploymentProfile[];}) { + if (!profiles.length) return

i18n:govoplan-ops.no_deployment_profiles_reported.7c3af1db

; + return ( +
+ + + + + + {profiles.map((profile) => + + + + + + + )} + +
i18n:govoplan-ops.profile.ff4fc027i18n:govoplan-ops.status.bae7d5bei18n:govoplan-ops.components.9289473ei18n:govoplan-ops.fit.dab564d8
{profile.name}{profile.id}{profile.components.join(", ")}{profile.fit}
+
); + +} + +function SizingTable({ items }: {items: OpsSizingAssumption[];}) { + if (!items.length) return

i18n:govoplan-ops.no_sizing_assumptions_reported.17515959

; + return ( +
+ + + + + + {items.map((item) => + + + + + + + )} + +
i18n:govoplan-ops.area.2745debai18n:govoplan-ops.baseline.e6ab7982i18n:govoplan-ops.scale_trigger.1c85e10ei18n:govoplan-ops.operator_note.1dc58f7b
{item.area}{item.baseline}{item.scale_trigger}{item.operator_note}
+
); + +} + +function stateTone(state: string): string { + if (state === "ok") return "success"; + if (state === "warning") return "warning"; + if (state === "error") return "error"; + return "inactive"; +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts new file mode 100644 index 0000000..477d367 --- /dev/null +++ b/webui/src/i18n/generatedTranslations.ts @@ -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" + } +}; diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..96e9f0c --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1,2 @@ +export { opsModule as default, opsModule } from "./module"; +export { default as OpsPage } from "./features/ops/OpsPage"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..d15b87f --- /dev/null +++ b/webui/src/module.ts @@ -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;