chore: sync GovOPlaN module split state
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
19
AGENTS.md
Normal 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
23
README.md
Normal 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.
|
||||
268
docs/SCALABILITY_PROFILES.md
Normal file
268
docs/SCALABILITY_PROFILES.md
Normal 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
35
package.json
Normal 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
24
pyproject.toml
Normal 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"
|
||||
5
src/govoplan_ops/__init__.py
Normal file
5
src/govoplan_ops/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""GovOPlaN operations module."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.6"
|
||||
1
src/govoplan_ops/backend/__init__.py
Normal file
1
src/govoplan_ops/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend integration for the GovOPlaN ops module."""
|
||||
1
src/govoplan_ops/backend/api/__init__.py
Normal file
1
src/govoplan_ops/backend/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Ops API package."""
|
||||
1
src/govoplan_ops/backend/api/v1/__init__.py
Normal file
1
src/govoplan_ops/backend/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Ops API v1 package."""
|
||||
285
src/govoplan_ops/backend/api/v1/routes.py
Normal file
285
src/govoplan_ops/backend/api/v1/routes.py
Normal 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
|
||||
64
src/govoplan_ops/backend/manifest.py
Normal file
64
src/govoplan_ops/backend/manifest.py
Normal 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
|
||||
1
src/govoplan_ops/py.typed
Normal file
1
src/govoplan_ops/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
30
webui/package.json
Normal file
30
webui/package.json
Normal 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
58
webui/src/api/ops.ts
Normal 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");
|
||||
}
|
||||
54
webui/src/features/ops/OpsHealthWidget.tsx
Normal file
54
webui/src/features/ops/OpsHealthWidget.tsx
Normal 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>);
|
||||
}
|
||||
|
||||
147
webui/src/features/ops/OpsPage.tsx
Normal file
147
webui/src/features/ops/OpsPage.tsx
Normal 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";
|
||||
}
|
||||
64
webui/src/i18n/generatedTranslations.ts
Normal file
64
webui/src/i18n/generatedTranslations.ts
Normal 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
2
webui/src/index.ts
Normal 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
46
webui/src/module.ts
Normal 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;
|
||||
Reference in New Issue
Block a user