Release v0.1.5
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: "Bug"
|
||||||
|
about: "Report a reproducible defect, regression, or incorrect behavior"
|
||||||
|
title: "[Bug] "
|
||||||
|
labels:
|
||||||
|
- type/bug
|
||||||
|
- status/triage
|
||||||
|
- module/admin
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Area/module:
|
||||||
|
- Affected version or commit:
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
Actual:
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Logs, screenshots, traces, or failing test output:
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or workflow that should pass when fixed:
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Docs / workflow"
|
||||||
|
about: "Request documentation, process, or developer workflow changes"
|
||||||
|
title: "[Docs] "
|
||||||
|
labels:
|
||||||
|
- type/docs
|
||||||
|
- status/triage
|
||||||
|
- module/admin
|
||||||
|
- area/docs
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Document or workflow:
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
What is missing, unclear, duplicated, or stale?
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
What should the docs or workflow make clear?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
How should this be checked?
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: "Feature"
|
||||||
|
about: "Propose new user-visible behavior or platform capability"
|
||||||
|
title: "[Feature] "
|
||||||
|
labels:
|
||||||
|
- type/feature
|
||||||
|
- status/triage
|
||||||
|
- module/admin
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
What user, operator, or developer problem should this solve?
|
||||||
|
|
||||||
|
## Proposed Capability
|
||||||
|
|
||||||
|
What should exist when this is done?
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- Related module repositories:
|
||||||
|
- Extension point or integration boundary:
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command, scenario, or UI flow that should prove completion:
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: "Task"
|
||||||
|
about: "Track implementation, maintenance, or migration work"
|
||||||
|
title: "[Task] "
|
||||||
|
labels:
|
||||||
|
- type/task
|
||||||
|
- status/triage
|
||||||
|
- module/admin
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
What needs to be completed?
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- In-scope:
|
||||||
|
- Out-of-scope:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or manual check:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: "Tech debt"
|
||||||
|
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
|
||||||
|
title: "[Debt] "
|
||||||
|
labels:
|
||||||
|
- type/debt
|
||||||
|
- status/triage
|
||||||
|
- module/admin
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Cost
|
||||||
|
|
||||||
|
What does this make harder, riskier, slower, or more fragile?
|
||||||
|
|
||||||
|
## Desired Shape
|
||||||
|
|
||||||
|
What should the code, tests, or architecture look like afterwards?
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
What behavior, compatibility, or module boundary must be preserved?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Focused checks that should pass:
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Follow-up issues:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# GovOPlaN Admin
|
||||||
|
|
||||||
|
`govoplan-admin` owns generic system administration API and WebUI contributions
|
||||||
|
during the GovOPlaN module split.
|
||||||
|
|
||||||
|
This repository owns the live `governance_templates` and
|
||||||
|
`governance_template_assignments` tables while preserving their historical
|
||||||
|
table names. It contributes the stable
|
||||||
|
`/api/v1/admin/system/governance-templates` routes and owns governance-template
|
||||||
|
CRUD plus materialization into access-owned tenant groups and roles.
|
||||||
|
|
||||||
|
## WebUI Package
|
||||||
|
|
||||||
|
The repository root and `webui/` directory both expose the package
|
||||||
|
`@govoplan/admin-webui`. The package does not contribute the `/admin` route
|
||||||
|
itself; `govoplan-access` owns the route shell. Instead, admin contributes
|
||||||
|
section entries through core's `admin.sections` UI capability:
|
||||||
|
|
||||||
|
- overview
|
||||||
|
- system settings
|
||||||
|
- governance template tenant roles
|
||||||
|
- governance template groups
|
||||||
|
|
||||||
|
The route shell in access collects those sections at runtime, applies their
|
||||||
|
scope requirements, and renders them without importing admin package internals.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/admin-webui",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"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",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.5",
|
||||||
|
"lucide-react": "^0.555.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-admin"
|
||||||
|
version = "0.1.5"
|
||||||
|
description = "GovOPlaN generic administration module."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = [
|
||||||
|
"govoplan-core>=0.1.5",
|
||||||
|
"govoplan-access>=0.1.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
govoplan_admin = ["py.typed"]
|
||||||
|
|
||||||
|
[project.entry-points."govoplan.modules"]
|
||||||
|
admin = "govoplan_admin.backend.manifest:get_manifest"
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: govoplan-admin
|
||||||
|
Version: 0.1.4
|
||||||
|
Summary: GovOPlaN generic administration module.
|
||||||
|
Author: GovOPlaN
|
||||||
|
Requires-Python: >=3.12
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
Requires-Dist: govoplan-core>=0.1.4
|
||||||
|
Requires-Dist: govoplan-access>=0.1.4
|
||||||
|
|
||||||
|
# GovOPlaN Admin
|
||||||
|
|
||||||
|
`govoplan-admin` owns generic system administration API and WebUI contributions
|
||||||
|
during the GovOPlaN module split.
|
||||||
|
|
||||||
|
This repository owns the live `governance_templates` and
|
||||||
|
`governance_template_assignments` tables while preserving their historical
|
||||||
|
table names. It contributes the stable
|
||||||
|
`/api/v1/admin/system/governance-templates` routes and owns governance-template
|
||||||
|
CRUD plus materialization into access-owned tenant groups and roles.
|
||||||
|
|
||||||
|
## WebUI Package
|
||||||
|
|
||||||
|
The repository root and `webui/` directory both expose the package
|
||||||
|
`@govoplan/admin-webui`. The package does not contribute the `/admin` route
|
||||||
|
itself; `govoplan-access` owns the route shell. Instead, admin contributes
|
||||||
|
section entries through core's `admin.sections` UI capability:
|
||||||
|
|
||||||
|
- overview
|
||||||
|
- system settings
|
||||||
|
- governance template tenant roles
|
||||||
|
- governance template groups
|
||||||
|
|
||||||
|
The route shell in access collects those sections at runtime, applies their
|
||||||
|
scope requirements, and renders them without importing admin package internals.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
README.md
|
||||||
|
pyproject.toml
|
||||||
|
src/govoplan_admin/__init__.py
|
||||||
|
src/govoplan_admin/py.typed
|
||||||
|
src/govoplan_admin.egg-info/PKG-INFO
|
||||||
|
src/govoplan_admin.egg-info/SOURCES.txt
|
||||||
|
src/govoplan_admin.egg-info/dependency_links.txt
|
||||||
|
src/govoplan_admin.egg-info/entry_points.txt
|
||||||
|
src/govoplan_admin.egg-info/requires.txt
|
||||||
|
src/govoplan_admin.egg-info/top_level.txt
|
||||||
|
src/govoplan_admin/backend/__init__.py
|
||||||
|
src/govoplan_admin/backend/governance.py
|
||||||
|
src/govoplan_admin/backend/manifest.py
|
||||||
|
src/govoplan_admin/backend/api/__init__.py
|
||||||
|
src/govoplan_admin/backend/api/v1/__init__.py
|
||||||
|
src/govoplan_admin/backend/api/v1/routes.py
|
||||||
|
src/govoplan_admin/backend/api/v1/schemas.py
|
||||||
|
src/govoplan_admin/backend/db/__init__.py
|
||||||
|
src/govoplan_admin/backend/db/models.py
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[govoplan.modules]
|
||||||
|
admin = govoplan_admin.backend.manifest:get_manifest
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
govoplan-core>=0.1.4
|
||||||
|
govoplan-access>=0.1.4
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
govoplan_admin
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""GovOPlaN generic administration module."""
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
"""Backend integration for the GovOPlaN admin module."""
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,840 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_admin.backend.governance import create_template, delete_template, update_template
|
||||||
|
from govoplan_core.admin.settings import get_system_settings
|
||||||
|
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_any_scope, require_scope
|
||||||
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
|
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
||||||
|
from govoplan_core.admin.common import AdminConflictError, AdminValidationError
|
||||||
|
from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration
|
||||||
|
from govoplan_core.core.module_management import (
|
||||||
|
PROTECTED_MODULES,
|
||||||
|
ModuleInstallPlanItem,
|
||||||
|
ModuleManagementError,
|
||||||
|
configured_enabled_modules,
|
||||||
|
module_install_plan_commands,
|
||||||
|
module_dependents,
|
||||||
|
module_uninstall_plan_item,
|
||||||
|
plan_desired_enabled_modules,
|
||||||
|
save_desired_enabled_modules,
|
||||||
|
save_module_install_plan,
|
||||||
|
saved_desired_enabled_modules,
|
||||||
|
saved_module_install_plan,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_installer import (
|
||||||
|
ModuleInstallerError,
|
||||||
|
cancel_module_installer_request,
|
||||||
|
default_installer_runtime_dir,
|
||||||
|
list_module_installer_runs,
|
||||||
|
list_module_installer_requests,
|
||||||
|
module_installer_daemon_status,
|
||||||
|
module_install_preflight,
|
||||||
|
module_installer_lock_status,
|
||||||
|
queue_module_installer_request,
|
||||||
|
read_module_installer_run,
|
||||||
|
read_module_installer_request,
|
||||||
|
retry_module_installer_request,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_license import module_license_decision
|
||||||
|
from govoplan_core.core.module_package_catalog import record_module_package_catalog_acceptance, validate_module_package_catalog
|
||||||
|
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, MaintenanceMode, saved_maintenance_mode, save_maintenance_mode
|
||||||
|
from govoplan_core.core.lifecycle import ModuleLifecycleManager
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_core.privacy.retention import privacy_policy_from_settings, set_privacy_policy
|
||||||
|
from govoplan_core.security.permissions import ALL_PERMISSIONS, effective_permission_count
|
||||||
|
from govoplan_core.server.registry import available_module_manifests
|
||||||
|
from govoplan_core.settings import settings as core_settings
|
||||||
|
from govoplan_core.tenancy.service import tenant_counts
|
||||||
|
from govoplan_tenancy.backend.db.models import Tenant
|
||||||
|
|
||||||
|
from .schemas import (
|
||||||
|
AdminOverviewResponse,
|
||||||
|
GovernanceTemplateCreateRequest,
|
||||||
|
GovernanceTemplateItem,
|
||||||
|
GovernanceTemplateListResponse,
|
||||||
|
GovernanceTemplateUpdateRequest,
|
||||||
|
MaintenanceModeItem,
|
||||||
|
ModuleCatalogItem,
|
||||||
|
ModuleCatalogResponse,
|
||||||
|
ModuleInstallPreflightResponse,
|
||||||
|
ModuleInstallerLockStatus,
|
||||||
|
ModuleInstallerDaemonStatus,
|
||||||
|
ModuleInstallerRequestCreateRequest,
|
||||||
|
ModuleInstallerRequestItem,
|
||||||
|
ModuleInstallerRequestListResponse,
|
||||||
|
ModuleInstallerRunListResponse,
|
||||||
|
ModuleInstallerRunSummary,
|
||||||
|
ModuleInstallPlanResponse,
|
||||||
|
ModuleInstallPlanUpdateRequest,
|
||||||
|
ModulePackageCatalogItem,
|
||||||
|
ModulePackageCatalogResponse,
|
||||||
|
ModuleStateUpdateRequest,
|
||||||
|
PrivacyRetentionPolicyItem,
|
||||||
|
SystemSettingsItem,
|
||||||
|
SystemSettingsUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
def _access_administration() -> AccessAdministration:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_ADMINISTRATION):
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is not configured")
|
||||||
|
capability = registry.require_capability(CAPABILITY_ACCESS_ADMINISTRATION)
|
||||||
|
if not isinstance(capability, AccessAdministration):
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _request_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 _request_lifecycle(request: Request) -> ModuleLifecycleManager:
|
||||||
|
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None)
|
||||||
|
if not isinstance(lifecycle, ModuleLifecycleManager):
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module lifecycle manager is not configured")
|
||||||
|
return lifecycle
|
||||||
|
|
||||||
|
|
||||||
|
def _http_admin_error(exc: Exception) -> HTTPException:
|
||||||
|
if isinstance(exc, AdminConflictError):
|
||||||
|
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||||
|
if isinstance(exc, AdminValidationError):
|
||||||
|
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||||
|
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem:
|
||||||
|
assignments = session.query(GovernanceTemplateAssignment).filter(
|
||||||
|
GovernanceTemplateAssignment.template_id == item.id
|
||||||
|
).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all()
|
||||||
|
return GovernanceTemplateItem(
|
||||||
|
id=item.id,
|
||||||
|
kind=item.kind,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
permissions=item.permissions or [],
|
||||||
|
effective_permission_count=effective_permission_count(item.permissions or [], level="tenant") if item.kind == "role" else 0,
|
||||||
|
is_active=item.is_active,
|
||||||
|
assignments=[{"tenant_id": row.tenant_id, "mode": row.mode} for row in assignments],
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _module_catalog_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleCatalogResponse:
|
||||||
|
registry = _request_registry(request)
|
||||||
|
current_enabled = [manifest.id for manifest in registry.manifests()]
|
||||||
|
configured_enabled = list(configured_enabled_modules(core_settings.enabled_modules))
|
||||||
|
desired_enabled = list(saved_desired_enabled_modules(session, configured_enabled))
|
||||||
|
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None)
|
||||||
|
available = dict(lifecycle.available_modules) if isinstance(lifecycle, ModuleLifecycleManager) else available_module_manifests(ignore_load_errors=True)
|
||||||
|
dependents = module_dependents(available)
|
||||||
|
current_set = set(current_enabled)
|
||||||
|
desired_set = set(desired_enabled)
|
||||||
|
protected_set = set(PROTECTED_MODULES)
|
||||||
|
items: list[ModuleCatalogItem] = []
|
||||||
|
for module_id, manifest in sorted(available.items(), key=lambda item: item[0]):
|
||||||
|
migration = manifest.migration_spec
|
||||||
|
frontend = manifest.frontend
|
||||||
|
items.append(ModuleCatalogItem(
|
||||||
|
id=manifest.id,
|
||||||
|
name=manifest.name,
|
||||||
|
version=manifest.version,
|
||||||
|
installed=True,
|
||||||
|
current_enabled=manifest.id in current_set,
|
||||||
|
desired_enabled=manifest.id in desired_set,
|
||||||
|
protected=manifest.id in protected_set,
|
||||||
|
restart_required=(manifest.id in current_set) != (manifest.id in desired_set),
|
||||||
|
dependencies=list(manifest.dependencies),
|
||||||
|
optional_dependencies=list(manifest.optional_dependencies),
|
||||||
|
dependents=list(dependents.get(manifest.id, ())),
|
||||||
|
permissions_count=len(manifest.permissions),
|
||||||
|
role_templates_count=len(manifest.role_templates),
|
||||||
|
route_contributed=manifest.route_factory is not None,
|
||||||
|
nav_count=len(manifest.nav_items),
|
||||||
|
frontend_package=frontend.package_name if frontend else None,
|
||||||
|
migration_module_id=migration.module_id if migration else None,
|
||||||
|
migration_script_location=migration.script_location if migration else None,
|
||||||
|
runtime_toggle_supported=True,
|
||||||
|
install_uninstall_supported=manifest.id not in protected_set,
|
||||||
|
))
|
||||||
|
restart_required = current_set != desired_set
|
||||||
|
maintenance_mode = saved_maintenance_mode(session)
|
||||||
|
return ModuleCatalogResponse(
|
||||||
|
modules=items,
|
||||||
|
current_enabled=current_enabled,
|
||||||
|
desired_enabled=desired_enabled,
|
||||||
|
configured_enabled=configured_enabled,
|
||||||
|
protected_modules=list(PROTECTED_MODULES),
|
||||||
|
restart_required=restart_required,
|
||||||
|
runtime_toggle_supported=True,
|
||||||
|
install_uninstall_supported=True,
|
||||||
|
install_plan_supported=True,
|
||||||
|
package_mutation_supported=False,
|
||||||
|
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
|
||||||
|
notes=notes or [
|
||||||
|
"Enable/disable changes are applied to the running server and saved as startup state.",
|
||||||
|
"Installing or uninstalling Python/WebUI packages is planned here and applied by the separate installer daemon during maintenance mode.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _module_install_plan_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleInstallPlanResponse:
|
||||||
|
plan = saved_module_install_plan(session)
|
||||||
|
maintenance_mode = saved_maintenance_mode(session)
|
||||||
|
registry = _request_registry(request)
|
||||||
|
current_enabled = [manifest.id for manifest in registry.manifests()]
|
||||||
|
configured_enabled = list(configured_enabled_modules(core_settings.enabled_modules))
|
||||||
|
desired_enabled = list(saved_desired_enabled_modules(session, configured_enabled))
|
||||||
|
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None)
|
||||||
|
available = dict(lifecycle.available_modules) if isinstance(lifecycle, ModuleLifecycleManager) else available_module_manifests(ignore_load_errors=True)
|
||||||
|
preflight = module_install_preflight(
|
||||||
|
plan=plan,
|
||||||
|
available=available,
|
||||||
|
current_enabled=current_enabled,
|
||||||
|
desired_enabled=desired_enabled,
|
||||||
|
maintenance_mode=maintenance_mode.enabled,
|
||||||
|
session=session,
|
||||||
|
webui_root=_webui_root(),
|
||||||
|
runtime_dir=default_installer_runtime_dir(core_settings.database_url),
|
||||||
|
)
|
||||||
|
default_notes = [
|
||||||
|
"The running server does not install or remove packages inside the FastAPI request.",
|
||||||
|
"Use govoplan-module-installer from an operator shell while maintenance mode is active.",
|
||||||
|
]
|
||||||
|
if not maintenance_mode.enabled:
|
||||||
|
default_notes.append("Maintenance mode is currently off.")
|
||||||
|
return ModuleInstallPlanResponse(
|
||||||
|
items=[item.as_dict() for item in plan.items],
|
||||||
|
updated_at=plan.updated_at,
|
||||||
|
commands=list(module_install_plan_commands(plan)),
|
||||||
|
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
|
||||||
|
preflight=ModuleInstallPreflightResponse.model_validate(preflight.as_dict()),
|
||||||
|
notes=notes or default_notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_root() -> Path | None:
|
||||||
|
configured = os.getenv("GOVOPLAN_WEBUI_ROOT")
|
||||||
|
if configured:
|
||||||
|
return Path(configured).expanduser().resolve()
|
||||||
|
candidate = Path.cwd() / "webui"
|
||||||
|
return candidate if candidate.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_install_plan_item(session: Session, item: ModuleInstallPlanItem):
|
||||||
|
existing = saved_module_install_plan(session)
|
||||||
|
retained = [
|
||||||
|
current
|
||||||
|
for current in existing.items
|
||||||
|
if not (current.status == "planned" and current.module_id == item.module_id)
|
||||||
|
]
|
||||||
|
return save_module_install_plan(session, [*retained, item])
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str, object]]:
|
||||||
|
result = validate_module_package_catalog()
|
||||||
|
if not result.get("valid"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(result.get("error") or "Module package catalog is invalid."),
|
||||||
|
)
|
||||||
|
for raw_item in result.get("modules", []):
|
||||||
|
if not isinstance(raw_item, dict):
|
||||||
|
continue
|
||||||
|
if raw_item.get("module_id") != module_id or raw_item.get("action") != "install":
|
||||||
|
continue
|
||||||
|
license_decision = module_license_decision(_catalog_license_features(raw_item))
|
||||||
|
if not license_decision.get("allowed"):
|
||||||
|
missing = license_decision.get("missing_features")
|
||||||
|
missing_text = ", ".join(str(item) for item in missing) if isinstance(missing, list) else "required feature"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"License does not allow installing {module_id}: {missing_text}.",
|
||||||
|
)
|
||||||
|
notes = raw_item.get("notes") if isinstance(raw_item.get("notes"), str) else None
|
||||||
|
if license_decision.get("reason") and license_decision.get("missing_features"):
|
||||||
|
prefix = f"{notes}\n" if notes else ""
|
||||||
|
notes = f"{prefix}License warning: {license_decision['reason']}"
|
||||||
|
return ModuleInstallPlanItem(
|
||||||
|
module_id=str(raw_item["module_id"]),
|
||||||
|
action="install",
|
||||||
|
python_package=raw_item.get("python_package") if isinstance(raw_item.get("python_package"), str) else None,
|
||||||
|
python_ref=raw_item.get("python_ref") if isinstance(raw_item.get("python_ref"), str) else None,
|
||||||
|
webui_package=raw_item.get("webui_package") if isinstance(raw_item.get("webui_package"), str) else None,
|
||||||
|
webui_ref=raw_item.get("webui_ref") if isinstance(raw_item.get("webui_ref"), str) else None,
|
||||||
|
notes=notes,
|
||||||
|
), result
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install entry not found: {module_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_license_features(raw_item: dict[str, object]) -> list[str]:
|
||||||
|
value = raw_item.get("license_features")
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCatalogItem:
|
||||||
|
payload = dict(raw_item)
|
||||||
|
decision = module_license_decision(_catalog_license_features(raw_item))
|
||||||
|
payload["license_allowed"] = bool(decision.get("allowed"))
|
||||||
|
payload["license_enforced"] = bool(decision.get("enforced"))
|
||||||
|
missing = decision.get("missing_features")
|
||||||
|
payload["license_missing_features"] = [str(item) for item in missing] if isinstance(missing, list) else []
|
||||||
|
reason = decision.get("reason")
|
||||||
|
payload["license_reason"] = reason if isinstance(reason, str) else None
|
||||||
|
return ModulePackageCatalogItem.model_validate(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overview", response_model=AdminOverviewResponse)
|
||||||
|
def admin_overview(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(
|
||||||
|
"admin:users:read", "admin:groups:read", "admin:roles:read", "admin:settings:read",
|
||||||
|
"admin:api_keys:read", "system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read",
|
||||||
|
"system:settings:read", "system:governance:read", "system:audit:read",
|
||||||
|
)),
|
||||||
|
):
|
||||||
|
tenant = session.get(Tenant, principal.tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||||
|
counts = tenant_counts(session, tenant.id)
|
||||||
|
access_admin = _access_administration()
|
||||||
|
capabilities = [item.scope for item in ALL_PERMISSIONS if has_scope(principal, item.scope)]
|
||||||
|
return AdminOverviewResponse(
|
||||||
|
active_tenant_id=tenant.id,
|
||||||
|
active_tenant_name=tenant.name,
|
||||||
|
tenant_count=session.query(Tenant).count() if has_scope(principal, "system:tenants:read") else None,
|
||||||
|
system_account_count=access_admin.system_account_count(session) if has_scope(principal, "system:accounts:read") or has_scope(principal, "system:access:read") else None,
|
||||||
|
system_group_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "group").count() if has_scope(principal, "system:governance:read") else None,
|
||||||
|
system_role_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "role").count() if has_scope(principal, "system:governance:read") else None,
|
||||||
|
user_count=counts["users"],
|
||||||
|
active_user_count=counts["active_users"],
|
||||||
|
group_count=counts["groups"],
|
||||||
|
role_count=access_admin.role_count_for_tenant(session, tenant.id),
|
||||||
|
active_api_key_count=access_admin.active_api_key_count_for_tenant(session, tenant.id),
|
||||||
|
capabilities=capabilities,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/modules", response_model=ModuleCatalogResponse)
|
||||||
|
def list_system_modules(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
return _module_catalog_response(session, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
|
||||||
|
def read_module_install_plan(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
return _module_install_plan_response(session, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/modules/install-runs", response_model=ModuleInstallerRunListResponse)
|
||||||
|
def list_module_install_runs(
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||||
|
return ModuleInstallerRunListResponse(
|
||||||
|
runs=[
|
||||||
|
ModuleInstallerRunSummary.model_validate(item)
|
||||||
|
for item in list_module_installer_runs(runtime_dir=runtime_dir)
|
||||||
|
],
|
||||||
|
lock=ModuleInstallerLockStatus.model_validate(module_installer_lock_status(runtime_dir=runtime_dir)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/modules/install-runs/{run_id}")
|
||||||
|
def read_module_install_run_detail(
|
||||||
|
run_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||||
|
try:
|
||||||
|
return read_module_installer_run(runtime_dir=runtime_dir, run_id=run_id)
|
||||||
|
except ModuleInstallerError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/modules/install-requests", response_model=ModuleInstallerRequestListResponse)
|
||||||
|
def list_module_install_requests(
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||||
|
return ModuleInstallerRequestListResponse(
|
||||||
|
requests=[
|
||||||
|
ModuleInstallerRequestItem.model_validate(item)
|
||||||
|
for item in list_module_installer_requests(runtime_dir=runtime_dir)
|
||||||
|
],
|
||||||
|
daemon=ModuleInstallerDaemonStatus.model_validate(module_installer_daemon_status(runtime_dir=runtime_dir)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/modules/install-requests/{request_id}")
|
||||||
|
def read_module_install_request_detail(
|
||||||
|
request_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||||
|
try:
|
||||||
|
return read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)
|
||||||
|
except ModuleInstallerError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/system/modules/install-requests", response_model=ModuleInstallerRequestItem)
|
||||||
|
def create_module_install_request(
|
||||||
|
payload: ModuleInstallerRequestCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||||
|
):
|
||||||
|
maintenance_mode = saved_maintenance_mode(session)
|
||||||
|
if not maintenance_mode.enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before queuing installer requests.")
|
||||||
|
if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Queuing installer requests requires maintenance access.")
|
||||||
|
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||||
|
request = queue_module_installer_request(
|
||||||
|
runtime_dir=runtime_dir,
|
||||||
|
requested_by=principal.user.id,
|
||||||
|
options=payload.options.model_dump(exclude_none=True),
|
||||||
|
)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="system_modules.install_request_queued",
|
||||||
|
scope="system",
|
||||||
|
object_type="module_install_request",
|
||||||
|
object_id=str(request["request_id"]),
|
||||||
|
details={"options": payload.options.model_dump(exclude_none=True)},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return ModuleInstallerRequestItem.model_validate(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/system/modules/install-requests/{request_id}/cancel", response_model=ModuleInstallerRequestItem)
|
||||||
|
def cancel_module_install_request(
|
||||||
|
request_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||||
|
):
|
||||||
|
maintenance_mode = saved_maintenance_mode(session)
|
||||||
|
if not maintenance_mode.enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before changing installer requests.")
|
||||||
|
if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Changing installer requests requires maintenance access.")
|
||||||
|
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||||
|
try:
|
||||||
|
request = cancel_module_installer_request(
|
||||||
|
runtime_dir=runtime_dir,
|
||||||
|
request_id=request_id,
|
||||||
|
cancelled_by=principal.user.id,
|
||||||
|
)
|
||||||
|
except ModuleInstallerError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="system_modules.install_request_cancelled",
|
||||||
|
scope="system",
|
||||||
|
object_type="module_install_request",
|
||||||
|
object_id=request_id,
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return ModuleInstallerRequestItem.model_validate(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/system/modules/install-requests/{request_id}/retry", response_model=ModuleInstallerRequestItem)
|
||||||
|
def retry_module_install_request(
|
||||||
|
request_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||||
|
):
|
||||||
|
maintenance_mode = saved_maintenance_mode(session)
|
||||||
|
if not maintenance_mode.enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before changing installer requests.")
|
||||||
|
if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Changing installer requests requires maintenance access.")
|
||||||
|
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||||
|
try:
|
||||||
|
request = retry_module_installer_request(
|
||||||
|
runtime_dir=runtime_dir,
|
||||||
|
request_id=request_id,
|
||||||
|
requested_by=principal.user.id,
|
||||||
|
)
|
||||||
|
except ModuleInstallerError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="system_modules.install_request_retried",
|
||||||
|
scope="system",
|
||||||
|
object_type="module_install_request",
|
||||||
|
object_id=request_id,
|
||||||
|
details={"new_request_id": request["request_id"]},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return ModuleInstallerRequestItem.model_validate(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/modules/package-catalog", response_model=ModulePackageCatalogResponse)
|
||||||
|
def read_module_package_catalog(
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
result = validate_module_package_catalog()
|
||||||
|
return ModulePackageCatalogResponse(
|
||||||
|
modules=[_module_package_catalog_item(item) for item in result["modules"] if isinstance(item, dict)],
|
||||||
|
configured=bool(result["configured"]),
|
||||||
|
valid=bool(result["valid"]),
|
||||||
|
path=result["path"] if isinstance(result["path"], str) else None,
|
||||||
|
source=result["source"] if isinstance(result.get("source"), str) else None,
|
||||||
|
source_type=result["source_type"] if isinstance(result.get("source_type"), str) else None,
|
||||||
|
channel=result["channel"] if isinstance(result["channel"], str) else None,
|
||||||
|
sequence=result["sequence"] if isinstance(result.get("sequence"), int) else None,
|
||||||
|
generated_at=result["generated_at"] if isinstance(result.get("generated_at"), str) else None,
|
||||||
|
expires_at=result["expires_at"] if isinstance(result.get("expires_at"), str) else None,
|
||||||
|
signed=bool(result["signed"]),
|
||||||
|
trusted=bool(result["trusted"]),
|
||||||
|
key_id=result["key_id"] if isinstance(result["key_id"], str) else None,
|
||||||
|
warnings=[str(item) for item in result["warnings"]] if isinstance(result["warnings"], list) else [],
|
||||||
|
error=result["error"] if isinstance(result["error"], str) else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/system/modules/install-plan/catalog/{module_id}", response_model=ModuleInstallPlanResponse)
|
||||||
|
def plan_module_install_from_catalog(
|
||||||
|
module_id: str,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
item, validation = _catalog_plan_item(module_id)
|
||||||
|
plan = _upsert_install_plan_item(session, item)
|
||||||
|
record_module_package_catalog_acceptance(validation)
|
||||||
|
except ModuleManagementError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="system_modules.install_plan_catalog_added",
|
||||||
|
scope="system",
|
||||||
|
object_type="module_install_plan",
|
||||||
|
object_id=module_id,
|
||||||
|
details={"items": [item.as_dict() for item in plan.items]},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _module_install_plan_response(session, request, notes=[f"Catalog install entry planned for {module_id}."])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/system/modules/{module_id}/uninstall-plan", response_model=ModuleInstallPlanResponse)
|
||||||
|
def plan_module_uninstall(
|
||||||
|
module_id: str,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||||
|
):
|
||||||
|
registry = _request_registry(request)
|
||||||
|
lifecycle = _request_lifecycle(request)
|
||||||
|
current_enabled = {manifest.id for manifest in registry.manifests()}
|
||||||
|
configured_enabled = configured_enabled_modules(core_settings.enabled_modules)
|
||||||
|
desired_enabled = set(saved_desired_enabled_modules(session, configured_enabled))
|
||||||
|
if module_id in PROTECTED_MODULES:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Protected platform modules cannot be uninstalled.")
|
||||||
|
if module_id in current_enabled or module_id in desired_enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Disable the module before planning package uninstall.")
|
||||||
|
try:
|
||||||
|
item = module_uninstall_plan_item(module_id, lifecycle.available_modules)
|
||||||
|
plan = _upsert_install_plan_item(session, item)
|
||||||
|
except ModuleManagementError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="system_modules.uninstall_plan_added",
|
||||||
|
scope="system",
|
||||||
|
object_type="module_install_plan",
|
||||||
|
object_id=module_id,
|
||||||
|
details={"items": [item.as_dict() for item in plan.items]},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _module_install_plan_response(session, request, notes=[f"Package uninstall planned for {module_id}."])
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/system/modules", response_model=ModuleCatalogResponse)
|
||||||
|
def update_system_modules(
|
||||||
|
request: Request,
|
||||||
|
payload: ModuleStateUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||||
|
):
|
||||||
|
lifecycle = _request_lifecycle(request)
|
||||||
|
available = lifecycle.available_modules
|
||||||
|
try:
|
||||||
|
plan = plan_desired_enabled_modules(payload.enabled_modules, available)
|
||||||
|
except ModuleManagementError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
try:
|
||||||
|
result = lifecycle.apply_enabled_modules(plan.enabled_modules, protected_modules=PROTECTED_MODULES)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
desired = save_desired_enabled_modules(session, result.enabled_modules)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="system_modules.desired_state_updated",
|
||||||
|
scope="system",
|
||||||
|
object_type="module_state",
|
||||||
|
object_id="global",
|
||||||
|
details={
|
||||||
|
"desired_enabled": list(desired),
|
||||||
|
"added_dependencies": list(plan.added_dependencies),
|
||||||
|
"activated": list(result.activated_modules),
|
||||||
|
"deactivated": list(result.deactivated_modules),
|
||||||
|
"mounted": list(result.mounted_modules),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
notes = ["Module state saved and applied to the running server."]
|
||||||
|
if plan.added_dependencies:
|
||||||
|
notes.append("Required dependencies added automatically: " + ", ".join(plan.added_dependencies))
|
||||||
|
if result.activated_modules:
|
||||||
|
notes.append("Activated: " + ", ".join(result.activated_modules))
|
||||||
|
if result.deactivated_modules:
|
||||||
|
notes.append("Deactivated: " + ", ".join(result.deactivated_modules))
|
||||||
|
return _module_catalog_response(session, request, notes=notes)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
|
||||||
|
def update_module_install_plan(
|
||||||
|
request: Request,
|
||||||
|
payload: ModuleInstallPlanUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
plan = save_module_install_plan(session, [item.model_dump() for item in payload.items])
|
||||||
|
except ModuleManagementError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="system_modules.install_plan_updated",
|
||||||
|
scope="system",
|
||||||
|
object_type="module_install_plan",
|
||||||
|
object_id="global",
|
||||||
|
details={"items": [item.as_dict() for item in plan.items]},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _module_install_plan_response(session, request, notes=["Module package install plan saved."])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
|
||||||
|
def clear_module_install_plan(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||||
|
):
|
||||||
|
plan = save_module_install_plan(session, ())
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="system_modules.install_plan_cleared",
|
||||||
|
scope="system",
|
||||||
|
object_type="module_install_plan",
|
||||||
|
object_id="global",
|
||||||
|
details={"items": [item.as_dict() for item in plan.items]},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _module_install_plan_response(session, request, notes=["Module package install plan cleared."])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/settings", response_model=SystemSettingsItem)
|
||||||
|
def read_system_settings(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||||
|
):
|
||||||
|
item = get_system_settings(session)
|
||||||
|
session.commit()
|
||||||
|
policy = privacy_policy_from_settings(item)
|
||||||
|
maintenance_mode = saved_maintenance_mode(session)
|
||||||
|
return SystemSettingsItem(
|
||||||
|
default_locale=item.default_locale,
|
||||||
|
allow_tenant_custom_groups=item.allow_tenant_custom_groups,
|
||||||
|
allow_tenant_custom_roles=item.allow_tenant_custom_roles,
|
||||||
|
allow_tenant_api_keys=item.allow_tenant_api_keys,
|
||||||
|
privacy_retention_policy=PrivacyRetentionPolicyItem.model_validate(policy.model_dump(mode="json")),
|
||||||
|
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
|
||||||
|
settings=item.settings or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/system/settings", response_model=SystemSettingsItem)
|
||||||
|
def write_system_settings(
|
||||||
|
payload: SystemSettingsUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||||
|
):
|
||||||
|
item = get_system_settings(session)
|
||||||
|
item.default_locale = payload.default_locale.strip() or "en"
|
||||||
|
item.allow_tenant_custom_groups = payload.allow_tenant_custom_groups
|
||||||
|
item.allow_tenant_custom_roles = payload.allow_tenant_custom_roles
|
||||||
|
item.allow_tenant_api_keys = payload.allow_tenant_api_keys
|
||||||
|
if payload.privacy_retention_policy is not None:
|
||||||
|
set_privacy_policy(item, payload.privacy_retention_policy.model_dump(mode="json"))
|
||||||
|
if payload.maintenance_mode is not None:
|
||||||
|
current = saved_maintenance_mode(session)
|
||||||
|
next_mode = MaintenanceMode(
|
||||||
|
enabled=payload.maintenance_mode.enabled,
|
||||||
|
message=payload.maintenance_mode.message.strip() if payload.maintenance_mode.message else None,
|
||||||
|
)
|
||||||
|
if current.enabled != next_mode.enabled and not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Changing maintenance mode requires {MAINTENANCE_ACCESS_SCOPE}.",
|
||||||
|
)
|
||||||
|
save_maintenance_mode(session, next_mode)
|
||||||
|
session.add(item)
|
||||||
|
audit_from_principal(
|
||||||
|
session, principal, action="system_settings.updated", scope="system", object_type="system_settings", object_id=item.id,
|
||||||
|
details=payload.model_dump(),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return read_system_settings(session=session, principal=principal)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse)
|
||||||
|
def list_governance_templates(
|
||||||
|
kind: str | None = Query(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:governance:read")),
|
||||||
|
):
|
||||||
|
del principal
|
||||||
|
query = session.query(GovernanceTemplate)
|
||||||
|
if kind:
|
||||||
|
query = query.filter(GovernanceTemplate.kind == kind)
|
||||||
|
items = query.order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all()
|
||||||
|
return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/system/governance-templates", response_model=GovernanceTemplateItem, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_governance_template(
|
||||||
|
payload: GovernanceTemplateCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
item = create_template(
|
||||||
|
session,
|
||||||
|
kind=payload.kind,
|
||||||
|
slug=payload.slug,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
permissions=payload.permissions,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
assignments=[assignment.model_dump() for assignment in payload.assignments],
|
||||||
|
)
|
||||||
|
except (AdminConflictError, AdminValidationError) as exc:
|
||||||
|
raise _http_admin_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="governance_template.created",
|
||||||
|
scope="system",
|
||||||
|
object_type=f"{payload.kind}_template",
|
||||||
|
object_id=item.id,
|
||||||
|
details={"slug": item.slug, "tenant_ids": [assignment.tenant_id for assignment in payload.assignments]},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _governance_template_item(session, item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/system/governance-templates/{template_id}", response_model=GovernanceTemplateItem)
|
||||||
|
def update_governance_template(
|
||||||
|
template_id: str,
|
||||||
|
payload: GovernanceTemplateUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
|
||||||
|
):
|
||||||
|
item = session.get(GovernanceTemplate, template_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found")
|
||||||
|
try:
|
||||||
|
update_template(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
permissions=payload.permissions,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
assignments=[assignment.model_dump() for assignment in payload.assignments],
|
||||||
|
)
|
||||||
|
except (AdminConflictError, AdminValidationError) as exc:
|
||||||
|
raise _http_admin_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="governance_template.updated",
|
||||||
|
scope="system",
|
||||||
|
object_type=f"{item.kind}_template",
|
||||||
|
object_id=item.id,
|
||||||
|
details={"tenant_ids": [assignment.tenant_id for assignment in payload.assignments]},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _governance_template_item(session, item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/system/governance-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def remove_governance_template(
|
||||||
|
template_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
|
||||||
|
):
|
||||||
|
item = session.get(GovernanceTemplate, template_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found")
|
||||||
|
kind = item.kind
|
||||||
|
try:
|
||||||
|
delete_template(session, item)
|
||||||
|
except (AdminConflictError, AdminValidationError) as exc:
|
||||||
|
raise _http_admin_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="governance_template.deleted",
|
||||||
|
scope="system",
|
||||||
|
object_type=f"{kind}_template",
|
||||||
|
object_id=template_id,
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return None
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
RETENTION_DAY_KEYS = (
|
||||||
|
"raw_campaign_json_retention_days",
|
||||||
|
"generated_eml_retention_days",
|
||||||
|
"stored_report_detail_retention_days",
|
||||||
|
"mock_mailbox_retention_days",
|
||||||
|
"audit_detail_retention_days",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
RETENTION_POLICY_FIELD_KEYS = (
|
||||||
|
"store_raw_campaign_json",
|
||||||
|
*RETENTION_DAY_KEYS,
|
||||||
|
"audit_detail_level",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_allow_lower_level_limits() -> dict[str, bool]:
|
||||||
|
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return default_allow_lower_level_limits() if fill_defaults else None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("allow_lower_level_limits must be an object")
|
||||||
|
normalized = default_allow_lower_level_limits() if fill_defaults else {}
|
||||||
|
for key, allowed in value.items():
|
||||||
|
clean_key = str(key)
|
||||||
|
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
|
||||||
|
raise ValueError(f"Unknown retention policy field: {clean_key}")
|
||||||
|
normalized[clean_key] = bool(allowed)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacyRetentionPolicyItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
store_raw_campaign_json: bool = True
|
||||||
|
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
|
||||||
|
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
|
||||||
|
|
||||||
|
@field_validator("allow_lower_level_limits", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||||
|
return normalize_allow_lower_level_limits(value, fill_defaults=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceModeItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
message: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminOverviewResponse(BaseModel):
|
||||||
|
active_tenant_id: str
|
||||||
|
active_tenant_name: str
|
||||||
|
tenant_count: int | None = None
|
||||||
|
system_account_count: int | None = None
|
||||||
|
system_group_template_count: int | None = None
|
||||||
|
system_role_template_count: int | None = None
|
||||||
|
user_count: int
|
||||||
|
active_user_count: int
|
||||||
|
group_count: int
|
||||||
|
role_count: int
|
||||||
|
active_api_key_count: int
|
||||||
|
capabilities: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSettingsItem(BaseModel):
|
||||||
|
default_locale: str = "en"
|
||||||
|
allow_tenant_custom_groups: bool = True
|
||||||
|
allow_tenant_custom_roles: bool = True
|
||||||
|
allow_tenant_api_keys: bool = True
|
||||||
|
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
|
||||||
|
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
|
||||||
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSettingsUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
default_locale: str = Field(min_length=1, max_length=20)
|
||||||
|
allow_tenant_custom_groups: bool
|
||||||
|
allow_tenant_custom_roles: bool
|
||||||
|
allow_tenant_api_keys: bool
|
||||||
|
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
|
||||||
|
maintenance_mode: MaintenanceModeItem | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleCatalogItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
installed: bool = True
|
||||||
|
current_enabled: bool = False
|
||||||
|
desired_enabled: bool = False
|
||||||
|
protected: bool = False
|
||||||
|
restart_required: bool = False
|
||||||
|
dependencies: list[str] = Field(default_factory=list)
|
||||||
|
optional_dependencies: list[str] = Field(default_factory=list)
|
||||||
|
dependents: list[str] = Field(default_factory=list)
|
||||||
|
permissions_count: int = 0
|
||||||
|
role_templates_count: int = 0
|
||||||
|
route_contributed: bool = False
|
||||||
|
nav_count: int = 0
|
||||||
|
frontend_package: str | None = None
|
||||||
|
migration_module_id: str | None = None
|
||||||
|
migration_script_location: str | None = None
|
||||||
|
runtime_toggle_supported: bool = False
|
||||||
|
install_uninstall_supported: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleCatalogResponse(BaseModel):
|
||||||
|
modules: list[ModuleCatalogItem]
|
||||||
|
current_enabled: list[str]
|
||||||
|
desired_enabled: list[str]
|
||||||
|
configured_enabled: list[str]
|
||||||
|
protected_modules: list[str]
|
||||||
|
restart_required: bool = False
|
||||||
|
runtime_toggle_supported: bool = False
|
||||||
|
install_uninstall_supported: bool = False
|
||||||
|
install_plan_supported: bool = False
|
||||||
|
package_mutation_supported: bool = False
|
||||||
|
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
|
||||||
|
notes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleStateUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
enabled_modules: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallPlanItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
module_id: str = Field(min_length=1, max_length=120)
|
||||||
|
action: Literal["install", "uninstall"]
|
||||||
|
python_package: str | None = Field(default=None, max_length=200)
|
||||||
|
python_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
webui_package: str | None = Field(default=None, max_length=200)
|
||||||
|
webui_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
destroy_data: bool = False
|
||||||
|
status: Literal["planned", "applied", "blocked"] = "planned"
|
||||||
|
notes: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallPreflightIssue(BaseModel):
|
||||||
|
severity: Literal["blocker", "warning", "info"]
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
module_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallChecklistItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
status: Literal["done", "pending", "blocked", "warning"]
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallPreflightResponse(BaseModel):
|
||||||
|
allowed: bool
|
||||||
|
maintenance_mode: bool
|
||||||
|
frontend_rebuild_required: bool
|
||||||
|
restart_required: bool
|
||||||
|
commands: list[str] = Field(default_factory=list)
|
||||||
|
rollback_commands: list[str] = Field(default_factory=list)
|
||||||
|
issues: list[ModuleInstallPreflightIssue] = Field(default_factory=list)
|
||||||
|
checklist: list[ModuleInstallChecklistItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallPlanResponse(BaseModel):
|
||||||
|
items: list[ModuleInstallPlanItem] = Field(default_factory=list)
|
||||||
|
updated_at: str | None = None
|
||||||
|
commands: list[str] = Field(default_factory=list)
|
||||||
|
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
|
||||||
|
preflight: ModuleInstallPreflightResponse | None = None
|
||||||
|
notes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallerLockStatus(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
locked: bool = False
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallerDaemonStatus(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
running: bool = False
|
||||||
|
status: str = "unknown"
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallerRunSummary(BaseModel):
|
||||||
|
run_id: str
|
||||||
|
status: str
|
||||||
|
started_at: str | None = None
|
||||||
|
finished_at: str | None = None
|
||||||
|
rollback_status: str | None = None
|
||||||
|
supervisor_status: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
record_path: str
|
||||||
|
commands_count: int = 0
|
||||||
|
planned_modules: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallerRunListResponse(BaseModel):
|
||||||
|
runs: list[ModuleInstallerRunSummary] = Field(default_factory=list)
|
||||||
|
lock: ModuleInstallerLockStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallerRequestOptions(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
build_webui: bool = False
|
||||||
|
migrate_database: bool = True
|
||||||
|
webui_root: str | None = Field(default=None, max_length=1000)
|
||||||
|
npm_bin: str | None = Field(default=None, max_length=500)
|
||||||
|
database_backup_command: str | None = Field(default=None, max_length=4000)
|
||||||
|
database_restore_command: str | None = Field(default=None, max_length=4000)
|
||||||
|
database_restore_check_command: str | None = Field(default=None, max_length=4000)
|
||||||
|
activate_installed_modules: bool = True
|
||||||
|
remove_uninstalled_modules_from_desired: bool = True
|
||||||
|
restart_commands: list[str] = Field(default_factory=list)
|
||||||
|
health_urls: list[str] = Field(default_factory=list)
|
||||||
|
health_timeout_seconds: float = Field(default=60.0, ge=1, le=3600)
|
||||||
|
health_interval_seconds: float = Field(default=2.0, ge=0.2, le=120)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallerRequestItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
request_id: str
|
||||||
|
status: str
|
||||||
|
created_at: str
|
||||||
|
requested_by: str | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
finished_at: str | None = None
|
||||||
|
cancelled_at: str | None = None
|
||||||
|
cancelled_by: str | None = None
|
||||||
|
retry_of: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
record_path: str | None = None
|
||||||
|
options: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallerRequestListResponse(BaseModel):
|
||||||
|
requests: list[ModuleInstallerRequestItem] = Field(default_factory=list)
|
||||||
|
daemon: ModuleInstallerDaemonStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallerRequestCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
options: ModuleInstallerRequestOptions = Field(default_factory=ModuleInstallerRequestOptions)
|
||||||
|
|
||||||
|
|
||||||
|
class ModulePackageCatalogItem(BaseModel):
|
||||||
|
module_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
action: Literal["install", "uninstall"] = "install"
|
||||||
|
python_package: str | None = None
|
||||||
|
python_ref: str | None = None
|
||||||
|
webui_package: str | None = None
|
||||||
|
webui_ref: str | None = None
|
||||||
|
license_features: list[str] = Field(default_factory=list)
|
||||||
|
license_allowed: bool = True
|
||||||
|
license_enforced: bool = False
|
||||||
|
license_missing_features: list[str] = Field(default_factory=list)
|
||||||
|
license_reason: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ModulePackageCatalogResponse(BaseModel):
|
||||||
|
modules: list[ModulePackageCatalogItem] = Field(default_factory=list)
|
||||||
|
configured: bool = False
|
||||||
|
valid: bool = True
|
||||||
|
path: str | None = None
|
||||||
|
source: str | None = None
|
||||||
|
source_type: str | None = None
|
||||||
|
channel: str | None = None
|
||||||
|
sequence: int | None = None
|
||||||
|
generated_at: str | None = None
|
||||||
|
expires_at: str | None = None
|
||||||
|
signed: bool = False
|
||||||
|
trusted: bool = False
|
||||||
|
key_id: str | None = None
|
||||||
|
warnings: list[str] = Field(default_factory=list)
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallPlanUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
items: list[ModuleInstallPlanItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceAssignment(BaseModel):
|
||||||
|
tenant_id: str
|
||||||
|
mode: Literal["available", "required"] = "available"
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceTemplateItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
kind: Literal["group", "role"]
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
effective_permission_count: int = 0
|
||||||
|
is_active: bool = True
|
||||||
|
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceTemplateListResponse(BaseModel):
|
||||||
|
templates: list[GovernanceTemplateItem]
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceTemplateCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
kind: Literal["group", "role"]
|
||||||
|
slug: str
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
is_active: bool = True
|
||||||
|
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceTemplateUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
is_active: bool = True
|
||||||
|
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Admin-owned SQLAlchemy metadata and models."""
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceTemplate(Base, TimestampMixin):
|
||||||
|
__tablename__ = "governance_templates"
|
||||||
|
__table_args__ = (UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceTemplateAssignment(Base, TimestampMixin):
|
||||||
|
__tablename__ = "governance_template_assignments"
|
||||||
|
__table_args__ = (UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
template_id: Mapped[str] = mapped_column(ForeignKey("governance_templates.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["GovernanceTemplate", "GovernanceTemplateAssignment", "new_uuid"]
|
||||||
|
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
||||||
|
from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||||
|
AccessGovernanceMaterializer,
|
||||||
|
GovernanceTemplateMaterialization,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
from govoplan_core.security.permissions import validate_tenant_permissions
|
||||||
|
from govoplan_tenancy.backend.db.models import Tenant
|
||||||
|
|
||||||
|
TEMPLATE_KINDS = {"group", "role"}
|
||||||
|
ASSIGNMENT_MODES = {"available", "required"}
|
||||||
|
|
||||||
|
|
||||||
|
def _governance_materializer() -> AccessGovernanceMaterializer:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER):
|
||||||
|
raise AdminValidationError("Access governance materializer capability is not configured.")
|
||||||
|
capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
|
||||||
|
if not isinstance(capability, AccessGovernanceMaterializer):
|
||||||
|
raise AdminValidationError("Access governance materializer capability is invalid.")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _materialization(
|
||||||
|
item: GovernanceTemplate,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
required: bool = False,
|
||||||
|
) -> GovernanceTemplateMaterialization:
|
||||||
|
return GovernanceTemplateMaterialization(
|
||||||
|
template_id=item.id,
|
||||||
|
kind=item.kind, # type: ignore[arg-type]
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
permissions=tuple(item.permissions or []),
|
||||||
|
is_active=item.is_active,
|
||||||
|
required=required,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_template(kind: str, permissions: list[str]) -> list[str]:
|
||||||
|
if kind not in TEMPLATE_KINDS:
|
||||||
|
raise AdminValidationError("Template kind must be group or role.")
|
||||||
|
if kind == "group":
|
||||||
|
if permissions:
|
||||||
|
raise AdminValidationError("Group templates do not contain permissions; assign roles to groups inside each tenant.")
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return validate_tenant_permissions(permissions)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AdminValidationError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def create_template(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
slug: str,
|
||||||
|
name: str,
|
||||||
|
description: str | None,
|
||||||
|
permissions: list[str],
|
||||||
|
is_active: bool,
|
||||||
|
assignments: list[dict[str, str]],
|
||||||
|
) -> GovernanceTemplate:
|
||||||
|
normalized_slug = slugify(slug)
|
||||||
|
permissions = validate_template(kind, permissions)
|
||||||
|
exists = session.query(GovernanceTemplate).filter(
|
||||||
|
GovernanceTemplate.kind == kind,
|
||||||
|
GovernanceTemplate.slug == normalized_slug,
|
||||||
|
).first()
|
||||||
|
if exists:
|
||||||
|
raise AdminConflictError(f"A {kind} template with slug {normalized_slug!r} already exists.")
|
||||||
|
item = GovernanceTemplate(
|
||||||
|
kind=kind,
|
||||||
|
slug=normalized_slug,
|
||||||
|
name=name.strip(),
|
||||||
|
description=description,
|
||||||
|
permissions=permissions,
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
session.flush()
|
||||||
|
set_template_assignments(session, item, assignments)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def update_template(
|
||||||
|
session: Session,
|
||||||
|
item: GovernanceTemplate,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
description: str | None,
|
||||||
|
permissions: list[str],
|
||||||
|
is_active: bool,
|
||||||
|
assignments: list[dict[str, str]],
|
||||||
|
) -> GovernanceTemplate:
|
||||||
|
item.name = name.strip()
|
||||||
|
item.description = description
|
||||||
|
item.permissions = validate_template(item.kind, permissions)
|
||||||
|
item.is_active = is_active
|
||||||
|
set_template_assignments(session, item, assignments)
|
||||||
|
sync_template(session, item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def set_template_assignments(
|
||||||
|
session: Session,
|
||||||
|
item: GovernanceTemplate,
|
||||||
|
assignments: list[dict[str, str]],
|
||||||
|
) -> None:
|
||||||
|
desired: dict[str, str] = {}
|
||||||
|
for assignment in assignments:
|
||||||
|
tenant_id = assignment.get("tenant_id", "")
|
||||||
|
mode = assignment.get("mode", "available")
|
||||||
|
if mode not in ASSIGNMENT_MODES:
|
||||||
|
raise AdminValidationError("Template assignment mode must be available or required.")
|
||||||
|
tenant = session.get(Tenant, tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise AdminValidationError(f"Unknown tenant: {tenant_id}")
|
||||||
|
desired[tenant_id] = mode
|
||||||
|
|
||||||
|
existing = {
|
||||||
|
row.tenant_id: row
|
||||||
|
for row in session.query(GovernanceTemplateAssignment)
|
||||||
|
.filter(GovernanceTemplateAssignment.template_id == item.id)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
for tenant_id, row in list(existing.items()):
|
||||||
|
if tenant_id in desired:
|
||||||
|
row.mode = desired[tenant_id]
|
||||||
|
continue
|
||||||
|
_governance_materializer().remove_template(session, _materialization(item, tenant_id=tenant_id))
|
||||||
|
session.delete(row)
|
||||||
|
|
||||||
|
for tenant_id, mode in desired.items():
|
||||||
|
if tenant_id not in existing:
|
||||||
|
session.add(GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode))
|
||||||
|
session.flush()
|
||||||
|
sync_template(session, item)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_template(session: Session, item: GovernanceTemplate) -> None:
|
||||||
|
assignments = session.query(GovernanceTemplateAssignment).filter(
|
||||||
|
GovernanceTemplateAssignment.template_id == item.id
|
||||||
|
).all()
|
||||||
|
for assignment in assignments:
|
||||||
|
required = assignment.mode == "required"
|
||||||
|
_governance_materializer().sync_template(
|
||||||
|
session,
|
||||||
|
_materialization(item, tenant_id=assignment.tenant_id, required=required),
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_template(session: Session, item: GovernanceTemplate) -> None:
|
||||||
|
assignments = session.query(GovernanceTemplateAssignment).filter(
|
||||||
|
GovernanceTemplateAssignment.template_id == item.id
|
||||||
|
).all()
|
||||||
|
for assignment in assignments:
|
||||||
|
_governance_materializer().remove_template(session, _materialization(item, tenant_id=assignment.tenant_id))
|
||||||
|
session.delete(item)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata
|
||||||
|
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||||
|
from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _route_factory(context: ModuleContext):
|
||||||
|
del context
|
||||||
|
from govoplan_admin.backend.api.v1.routes import router
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id="admin",
|
||||||
|
name="Admin",
|
||||||
|
version="0.1.5",
|
||||||
|
dependencies=("access",),
|
||||||
|
route_factory=_route_factory,
|
||||||
|
migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
admin_models.GovernanceTemplate,
|
||||||
|
admin_models.GovernanceTemplateAssignment,
|
||||||
|
label="Admin",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/admin-webui",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"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.5",
|
||||||
|
"lucide-react": "^0.555.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { apiFetch } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type PermissionItem = {
|
||||||
|
scope: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
level: "tenant" | "system";
|
||||||
|
system_template_id?: string | null;
|
||||||
|
system_required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminOverview = {
|
||||||
|
active_tenant_id: string;
|
||||||
|
active_tenant_name: string;
|
||||||
|
tenant_count?: number | null;
|
||||||
|
system_account_count?: number | null;
|
||||||
|
system_group_template_count?: number | null;
|
||||||
|
system_role_template_count?: number | null;
|
||||||
|
user_count: number;
|
||||||
|
active_user_count: number;
|
||||||
|
group_count: number;
|
||||||
|
role_count: number;
|
||||||
|
active_api_key_count: number;
|
||||||
|
capabilities: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantAdminItem = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
default_locale: string;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
allow_custom_groups?: boolean | null;
|
||||||
|
allow_custom_roles?: boolean | null;
|
||||||
|
allow_api_keys?: boolean | null;
|
||||||
|
effective_governance: Record<string, boolean>;
|
||||||
|
is_active: boolean;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivacyRetentionPolicyFieldKey =
|
||||||
|
| "store_raw_campaign_json"
|
||||||
|
| "raw_campaign_json_retention_days"
|
||||||
|
| "generated_eml_retention_days"
|
||||||
|
| "stored_report_detail_retention_days"
|
||||||
|
| "mock_mailbox_retention_days"
|
||||||
|
| "audit_detail_retention_days"
|
||||||
|
| "audit_detail_level";
|
||||||
|
|
||||||
|
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
|
||||||
|
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
|
||||||
|
|
||||||
|
export type PrivacyRetentionPolicy = {
|
||||||
|
store_raw_campaign_json: boolean;
|
||||||
|
raw_campaign_json_retention_days?: number | null;
|
||||||
|
generated_eml_retention_days?: number | null;
|
||||||
|
stored_report_detail_retention_days?: number | null;
|
||||||
|
mock_mailbox_retention_days?: number | null;
|
||||||
|
audit_detail_retention_days?: number | null;
|
||||||
|
audit_detail_level: "full" | "redacted" | "minimal";
|
||||||
|
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
|
||||||
|
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MaintenanceMode = {
|
||||||
|
enabled: boolean;
|
||||||
|
message?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SystemSettingsItem = {
|
||||||
|
default_locale: string;
|
||||||
|
allow_tenant_custom_groups: boolean;
|
||||||
|
allow_tenant_custom_roles: boolean;
|
||||||
|
allow_tenant_api_keys: boolean;
|
||||||
|
privacy_retention_policy: PrivacyRetentionPolicy;
|
||||||
|
maintenance_mode: MaintenanceMode;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SystemSettingsUpdatePayload = {
|
||||||
|
default_locale: string;
|
||||||
|
allow_tenant_custom_groups: boolean;
|
||||||
|
allow_tenant_custom_roles: boolean;
|
||||||
|
allow_tenant_api_keys: boolean;
|
||||||
|
privacy_retention_policy?: PrivacyRetentionPolicy | null;
|
||||||
|
maintenance_mode?: MaintenanceMode | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleCatalogItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
installed: boolean;
|
||||||
|
current_enabled: boolean;
|
||||||
|
desired_enabled: boolean;
|
||||||
|
protected: boolean;
|
||||||
|
restart_required: boolean;
|
||||||
|
dependencies: string[];
|
||||||
|
optional_dependencies: string[];
|
||||||
|
dependents: string[];
|
||||||
|
permissions_count: number;
|
||||||
|
role_templates_count: number;
|
||||||
|
route_contributed: boolean;
|
||||||
|
nav_count: number;
|
||||||
|
frontend_package?: string | null;
|
||||||
|
migration_module_id?: string | null;
|
||||||
|
migration_script_location?: string | null;
|
||||||
|
runtime_toggle_supported: boolean;
|
||||||
|
install_uninstall_supported: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleCatalogResponse = {
|
||||||
|
modules: ModuleCatalogItem[];
|
||||||
|
current_enabled: string[];
|
||||||
|
desired_enabled: string[];
|
||||||
|
configured_enabled: string[];
|
||||||
|
protected_modules: string[];
|
||||||
|
restart_required: boolean;
|
||||||
|
runtime_toggle_supported: boolean;
|
||||||
|
install_uninstall_supported: boolean;
|
||||||
|
install_plan_supported: boolean;
|
||||||
|
package_mutation_supported: boolean;
|
||||||
|
maintenance_mode: MaintenanceMode;
|
||||||
|
notes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallPlanItem = {
|
||||||
|
module_id: string;
|
||||||
|
action: "install" | "uninstall";
|
||||||
|
python_package?: string | null;
|
||||||
|
python_ref?: string | null;
|
||||||
|
webui_package?: string | null;
|
||||||
|
webui_ref?: string | null;
|
||||||
|
destroy_data: boolean;
|
||||||
|
status: "planned" | "applied" | "blocked";
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallPreflightIssue = {
|
||||||
|
severity: "blocker" | "warning" | "info";
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
module_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallChecklistItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: "done" | "pending" | "blocked" | "warning";
|
||||||
|
detail?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallPreflight = {
|
||||||
|
allowed: boolean;
|
||||||
|
maintenance_mode: boolean;
|
||||||
|
frontend_rebuild_required: boolean;
|
||||||
|
restart_required: boolean;
|
||||||
|
commands: string[];
|
||||||
|
rollback_commands: string[];
|
||||||
|
issues: ModuleInstallPreflightIssue[];
|
||||||
|
checklist: ModuleInstallChecklistItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallPlanResponse = {
|
||||||
|
items: ModuleInstallPlanItem[];
|
||||||
|
updated_at?: string | null;
|
||||||
|
commands: string[];
|
||||||
|
maintenance_mode: MaintenanceMode;
|
||||||
|
preflight?: ModuleInstallPreflight | null;
|
||||||
|
notes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallerLockStatus = {
|
||||||
|
locked: boolean;
|
||||||
|
path: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallerDaemonStatus = {
|
||||||
|
running: boolean;
|
||||||
|
status: string;
|
||||||
|
path: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallerRunSummary = {
|
||||||
|
run_id: string;
|
||||||
|
status: string;
|
||||||
|
started_at?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
rollback_status?: string | null;
|
||||||
|
supervisor_status?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
record_path: string;
|
||||||
|
commands_count: number;
|
||||||
|
planned_modules: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallerRunListResponse = {
|
||||||
|
runs: ModuleInstallerRunSummary[];
|
||||||
|
lock: ModuleInstallerLockStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallerRequestOptions = {
|
||||||
|
build_webui: boolean;
|
||||||
|
migrate_database: boolean;
|
||||||
|
webui_root?: string | null;
|
||||||
|
npm_bin?: string | null;
|
||||||
|
database_backup_command?: string | null;
|
||||||
|
database_restore_command?: string | null;
|
||||||
|
database_restore_check_command?: string | null;
|
||||||
|
activate_installed_modules: boolean;
|
||||||
|
remove_uninstalled_modules_from_desired: boolean;
|
||||||
|
restart_commands: string[];
|
||||||
|
health_urls: string[];
|
||||||
|
health_timeout_seconds: number;
|
||||||
|
health_interval_seconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallerRequestItem = {
|
||||||
|
request_id: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
requested_by?: string | null;
|
||||||
|
started_at?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
cancelled_at?: string | null;
|
||||||
|
cancelled_by?: string | null;
|
||||||
|
retry_of?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
record_path?: string | null;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallerRequestListResponse = {
|
||||||
|
requests: ModuleInstallerRequestItem[];
|
||||||
|
daemon: ModuleInstallerDaemonStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModulePackageCatalogItem = {
|
||||||
|
module_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
version?: string | null;
|
||||||
|
action: "install" | "uninstall";
|
||||||
|
python_package?: string | null;
|
||||||
|
python_ref?: string | null;
|
||||||
|
webui_package?: string | null;
|
||||||
|
webui_ref?: string | null;
|
||||||
|
license_features: string[];
|
||||||
|
license_allowed: boolean;
|
||||||
|
license_enforced: boolean;
|
||||||
|
license_missing_features: string[];
|
||||||
|
license_reason?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModulePackageCatalogResponse = {
|
||||||
|
modules: ModulePackageCatalogItem[];
|
||||||
|
configured: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
path?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
source_type?: string | null;
|
||||||
|
channel?: string | null;
|
||||||
|
sequence?: number | null;
|
||||||
|
generated_at?: string | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
signed: boolean;
|
||||||
|
trusted: boolean;
|
||||||
|
key_id?: string | null;
|
||||||
|
warnings: string[];
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GovernanceAssignment = {
|
||||||
|
tenant_id: string;
|
||||||
|
mode: "available" | "required";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GovernanceTemplateItem = {
|
||||||
|
id: string;
|
||||||
|
kind: "group" | "role";
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
permissions: string[];
|
||||||
|
effective_permission_count: number;
|
||||||
|
is_active: boolean;
|
||||||
|
assignments: GovernanceAssignment[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/overview");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
|
||||||
|
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
|
||||||
|
return response.permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
|
||||||
|
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
|
||||||
|
return response.tenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchModuleCatalog(settings: ApiSettings): Promise<ModuleCatalogResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/modules");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateModuleState(settings: ApiSettings, enabledModules: string[]): Promise<ModuleCatalogResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/modules", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ enabled_modules: enabledModules })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchModuleInstallPlan(settings: ApiSettings): Promise<ModuleInstallPlanResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchModuleInstallerRuns(settings: ApiSettings): Promise<ModuleInstallerRunListResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/modules/install-runs");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchModuleInstallerRequests(settings: ApiSettings): Promise<ModuleInstallerRequestListResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/modules/install-requests");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModuleInstallerRequest(settings: ApiSettings, options: ModuleInstallerRequestOptions): Promise<ModuleInstallerRequestItem> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/modules/install-requests", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ options })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelModuleInstallerRequest(settings: ApiSettings, requestId: string): Promise<ModuleInstallerRequestItem> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/modules/install-requests/${requestId}/cancel`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retryModuleInstallerRequest(settings: ApiSettings, requestId: string): Promise<ModuleInstallerRequestItem> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/modules/install-requests/${requestId}/retry`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchModulePackageCatalog(settings: ApiSettings): Promise<ModulePackageCatalogResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/modules/package-catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planModuleInstallFromCatalog(settings: ApiSettings, moduleId: string): Promise<ModuleInstallPlanResponse> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/modules/install-plan/catalog/${encodeURIComponent(moduleId)}`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planModuleUninstall(settings: ApiSettings, moduleId: string): Promise<ModuleInstallPlanResponse> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/modules/${encodeURIComponent(moduleId)}/uninstall-plan`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateModuleInstallPlan(settings: ApiSettings, items: ModuleInstallPlanItem[]): Promise<ModuleInstallPlanResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ items })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearModuleInstallPlan(settings: ApiSettings): Promise<ModuleInstallPlanResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
||||||
|
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
||||||
|
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
|
||||||
|
return response.templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { fetchAdminOverview, type AdminOverview } from "../../api/admin";
|
||||||
|
import { Card } from "@govoplan/core-webui";
|
||||||
|
import { Button } from "@govoplan/core-webui";
|
||||||
|
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export default function AdminOverviewPanel({ settings, onSelect, availableSections }: { settings: ApiSettings; onSelect: (section: string) => void; availableSections: ReadonlySet<string> }) {
|
||||||
|
const [overview, setOverview] = useState<AdminOverview | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try { setOverview(await fetchAdminOverview(settings)); }
|
||||||
|
catch (err) { setError(adminErrorMessage(err)); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||||
|
|
||||||
|
const hasSystemMetrics = Boolean(overview && [
|
||||||
|
overview.tenant_count,
|
||||||
|
overview.system_account_count,
|
||||||
|
overview.system_group_template_count,
|
||||||
|
overview.system_role_template_count
|
||||||
|
].some((value) => value !== null && value !== undefined));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageLayout title="Administration" description="System-wide governance and tenant-local access management, separated by scope and enforced by the backend." loading={loading} error={error} actions={<Button onClick={() => void load()} disabled={loading}>Reload</Button>}>
|
||||||
|
{overview && <>
|
||||||
|
{hasSystemMetrics && <>
|
||||||
|
<div className="admin-overview-section-label">System</div>
|
||||||
|
<div className="metric-grid">
|
||||||
|
<Metric title="Tenants" value={overview.tenant_count ?? "—"} text="Registered tenant spaces." />
|
||||||
|
<Metric title="Users" value={overview.system_account_count ?? "—"} text="Global login accounts across all tenants." />
|
||||||
|
<Metric title="Central groups" value={overview.system_group_template_count ?? "—"} text="System-governed group definitions." />
|
||||||
|
<Metric title="Tenant roles" value={overview.system_role_template_count ?? "—"} text="Centrally governed tenant roles." />
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{hasSystemArea(availableSections) && <Card title="System administration">
|
||||||
|
<div className="admin-overview-grid">
|
||||||
|
{availableSections.has("system-settings") && <AreaLink title="General" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
|
||||||
|
{availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />}
|
||||||
|
{availableSections.has("system-roles") && <AreaLink title="System roles" text="Instance-wide roles assigned directly to global accounts." onClick={() => onSelect("system-roles")} />}
|
||||||
|
{availableSections.has("system-role-templates") && <AreaLink title="Tenant roles" text="Centrally governed tenant roles and their availability across tenants." onClick={() => onSelect("system-role-templates")} />}
|
||||||
|
{availableSections.has("system-groups") && <AreaLink title="Groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
|
||||||
|
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
|
||||||
|
{availableSections.has("system-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("system-mail-servers")} />}
|
||||||
|
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
|
||||||
|
{availableSections.has("system-modules") && <AreaLink title="Modules" text="Installed modules, runtime state and startup state." onClick={() => onSelect("system-modules")} />}
|
||||||
|
{availableSections.has("system-audit") && <AreaLink title="Audit" text="System-level administrative history." onClick={() => onSelect("system-audit")} />}
|
||||||
|
</div>
|
||||||
|
</Card>}
|
||||||
|
|
||||||
|
<div className="admin-overview-section-label">Active tenant: {overview.active_tenant_name}</div>
|
||||||
|
<div className="metric-grid">
|
||||||
|
<Metric title="Tenant users" value={`${overview.active_user_count}/${overview.user_count}`} text="Active and total memberships." />
|
||||||
|
<Metric title="Groups" value={overview.group_count} text="Tenant-local and centrally managed groups." />
|
||||||
|
<Metric title="Roles" value={overview.role_count} text="Tenant-local and centrally managed roles." />
|
||||||
|
<Metric title="API keys" value={overview.active_api_key_count} text="Active tenant automation credentials." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card title="Tenant administration">
|
||||||
|
<div className="admin-overview-grid">
|
||||||
|
{availableSections.has("tenant-settings") && <AreaLink title="General" text="Tenant locale and tenant-specific settings." onClick={() => onSelect("tenant-settings")} />}
|
||||||
|
{availableSections.has("tenant-roles") && <AreaLink title="Roles" text="Tenant permission bundles and system-managed role copies." onClick={() => onSelect("tenant-roles")} />}
|
||||||
|
{availableSections.has("tenant-groups") && <AreaLink title="Groups" text="Tenant memberships and inherited roles." onClick={() => onSelect("tenant-groups")} />}
|
||||||
|
{availableSections.has("tenant-users") && <AreaLink title="Users" text="Membership status, groups and direct roles in the active tenant." onClick={() => onSelect("tenant-users")} />}
|
||||||
|
{availableSections.has("tenant-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("tenant-mail-servers")} />}
|
||||||
|
{availableSections.has("tenant-retention") && <AreaLink title="Retention" text="Tenant-level privacy retention limits inherited by owned objects." onClick={() => onSelect("tenant-retention")} />}
|
||||||
|
{availableSections.has("tenant-api-keys") && <AreaLink title="API keys" text="Scoped automation credentials capped by owner permissions." onClick={() => onSelect("tenant-api-keys")} />}
|
||||||
|
{availableSections.has("tenant-audit") && <AreaLink title="Audit" text="Tenant-level administrative history only." onClick={() => onSelect("tenant-audit")} />}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>}
|
||||||
|
</AdminPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSystemArea(sections: ReadonlySet<string>): boolean {
|
||||||
|
return ["system-settings", "system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-mail-servers", "system-retention", "system-modules", "system-audit"].some((section) => sections.has(section));
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ title, value, text }: { title: string; value: string | number; text: string }) {
|
||||||
|
return <Card title={title}><strong className="module-big-number">{value}</strong><p className="muted">{text}</p></Card>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AreaLink({ title, text, onClick }: { title: string; text: string; onClick: () => void }) {
|
||||||
|
return <button className="admin-overview-link" onClick={onClick}><strong>{title}</strong><span>{text}</span></button>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { Button } from "@govoplan/core-webui";
|
||||||
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
|
import { FormField } from "@govoplan/core-webui";
|
||||||
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
createGovernanceTemplate,
|
||||||
|
deleteGovernanceTemplate,
|
||||||
|
fetchGovernanceTemplates,
|
||||||
|
fetchPermissionCatalog,
|
||||||
|
fetchTenants,
|
||||||
|
updateGovernanceTemplate,
|
||||||
|
type GovernanceAssignment,
|
||||||
|
type GovernanceTemplateItem,
|
||||||
|
type PermissionItem,
|
||||||
|
type TenantAdminItem
|
||||||
|
} from "../../api/admin";
|
||||||
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const emptyDraft = {
|
||||||
|
slug: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
isActive: true,
|
||||||
|
permissions: [] as string[],
|
||||||
|
assignments: [] as GovernanceAssignment[]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GovernanceTemplatesPanel({
|
||||||
|
settings,
|
||||||
|
kind,
|
||||||
|
canWrite,
|
||||||
|
onAuthRefresh
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
kind: "group" | "role";
|
||||||
|
canWrite: boolean;
|
||||||
|
onAuthRefresh: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState<GovernanceTemplateItem[]>([]);
|
||||||
|
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||||
|
const [editing, setEditing] = useState<GovernanceTemplateItem | "new" | null>(null);
|
||||||
|
const [viewing, setViewing] = useState<GovernanceTemplateItem | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<GovernanceTemplateItem | null>(null);
|
||||||
|
const [draft, setDraft] = useState(emptyDraft);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [nextItems, nextTenants, nextPermissions] = await Promise.all([
|
||||||
|
fetchGovernanceTemplates(settings, kind),
|
||||||
|
fetchTenants(settings),
|
||||||
|
kind === "role" ? fetchPermissionCatalog(settings) : Promise.resolve([])
|
||||||
|
]);
|
||||||
|
setItems(nextItems);
|
||||||
|
setTenants(nextTenants);
|
||||||
|
setPermissions(nextPermissions.filter((item) => item.level === "tenant"));
|
||||||
|
} catch (err) { setError(adminErrorMessage(err)); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, kind]);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setDraft(emptyDraft);
|
||||||
|
setEditing("new");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: GovernanceTemplateItem) {
|
||||||
|
setDraft({
|
||||||
|
slug: item.slug,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description || "",
|
||||||
|
isActive: item.is_active,
|
||||||
|
permissions: item.permissions,
|
||||||
|
assignments: item.assignments
|
||||||
|
});
|
||||||
|
setEditing(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignmentMode(tenantId: string): "none" | "available" | "required" {
|
||||||
|
return draft.assignments.find((item) => item.tenant_id === tenantId)?.mode ?? "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAssignment(tenantId: string, mode: "none" | "available" | "required") {
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
assignments: mode === "none"
|
||||||
|
? current.assignments.filter((item) => item.tenant_id !== tenantId)
|
||||||
|
: [...current.assignments.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, mode }]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
if (editing === "new") {
|
||||||
|
await createGovernanceTemplate(settings, {
|
||||||
|
kind,
|
||||||
|
slug: draft.slug,
|
||||||
|
name: draft.name,
|
||||||
|
description: draft.description || null,
|
||||||
|
permissions: kind === "role" ? draft.permissions : [],
|
||||||
|
is_active: draft.isActive,
|
||||||
|
assignments: draft.assignments
|
||||||
|
});
|
||||||
|
setSuccess(`${kind === "group" ? "Group" : "Role"} template created.`);
|
||||||
|
} else if (editing) {
|
||||||
|
await updateGovernanceTemplate(settings, editing.id, {
|
||||||
|
name: draft.name,
|
||||||
|
description: draft.description || null,
|
||||||
|
permissions: kind === "role" ? draft.permissions : [],
|
||||||
|
is_active: draft.isActive,
|
||||||
|
assignments: draft.assignments
|
||||||
|
});
|
||||||
|
setSuccess(`${draft.name} updated and synchronized to assigned tenants.`);
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
await load();
|
||||||
|
await onAuthRefresh();
|
||||||
|
} catch (err) { setError(adminErrorMessage(err)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!deleting) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await deleteGovernanceTemplate(settings, deleting.id);
|
||||||
|
setSuccess(`${deleting.name} deleted.`);
|
||||||
|
setDeleting(null);
|
||||||
|
await load();
|
||||||
|
await onAuthRefresh();
|
||||||
|
} catch (err) { setError(adminErrorMessage(err)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumn<GovernanceTemplateItem>[]>(() => [
|
||||||
|
{
|
||||||
|
id: "template", header: kind === "group" ? "Group template" : "Tenant role", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true,
|
||||||
|
value: (row) => `${row.name} ${row.slug}`,
|
||||||
|
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenants", header: "Tenant availability", width: 320, minWidth: 210, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true,
|
||||||
|
value: (row) => row.assignments.map((assignment) => tenants.find((tenant) => tenant.id === assignment.tenant_id)?.name || assignment.tenant_id).join(", ") || "—",
|
||||||
|
render: (row) => row.assignments.length ? row.assignments.map((assignment) => {
|
||||||
|
const tenant = tenants.find((item) => item.id === assignment.tenant_id);
|
||||||
|
return `${tenant?.name || assignment.tenant_id} (${assignment.mode})`;
|
||||||
|
}).join(", ") : "—"
|
||||||
|
},
|
||||||
|
...(kind === "role" ? [{
|
||||||
|
id: "permissions", header: "Permissions", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer" as const,
|
||||||
|
value: (row: GovernanceTemplateItem) => row.effective_permission_count
|
||||||
|
}] : []),
|
||||||
|
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||||
|
{
|
||||||
|
id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right",
|
||||||
|
render: (row) => <div className="admin-icon-actions">
|
||||||
|
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||||
|
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
|
||||||
|
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
], [canWrite, kind, tenants]);
|
||||||
|
|
||||||
|
const title = kind === "group" ? "Central groups" : "Tenant roles";
|
||||||
|
const description = kind === "group"
|
||||||
|
? "Centrally defined group identities that are provisioned into selected tenants as available or required definitions. Membership remains tenant-local."
|
||||||
|
: "Centrally governed tenant roles that are provisioned into selected tenants as available or required definitions.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminPageLayout
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label={kind === "group" ? "Add group template" : "Add tenant role"} icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}
|
||||||
|
>
|
||||||
|
<div className="admin-table-surface">
|
||||||
|
<DataGrid id={`admin-system-${kind}-templates-v3`} rows={items} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText={`No central ${kind} templates found.`} />
|
||||||
|
</div>
|
||||||
|
</AdminPageLayout>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={editing !== null}
|
||||||
|
title={editing === "new" ? (kind === "group" ? "Create group template" : "Create tenant role") : (kind === "group" ? "Edit group template" : "Edit tenant role")}
|
||||||
|
onClose={() => !busy && setEditing(null)}
|
||||||
|
className="admin-dialog admin-dialog-wide"
|
||||||
|
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : kind === "group" ? "Save template" : "Save tenant role"}</Button></>}
|
||||||
|
>
|
||||||
|
<div className="admin-form-grid two-columns">
|
||||||
|
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||||
|
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
|
</div>
|
||||||
|
{kind === "role" && <div className="form-field"><span className="form-label">Tenant permissions</span><AdminSelectionList options={permissions.map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))} selected={draft.permissions} onChange={(next) => setDraft({ ...draft, permissions: next })} /></div>}
|
||||||
|
<div className="form-field">
|
||||||
|
<span className="form-label">Tenant availability</span>
|
||||||
|
<div className="admin-selection-list admin-governance-mode">
|
||||||
|
{tenants.map((tenant) => <div className="admin-tenant-assignment-row" key={tenant.id}><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span><select value={assignmentMode(tenant.id)} onChange={(event) => setAssignment(tenant.id, event.target.value as "none" | "available" | "required")}><option value="none">Not available</option><option value="available">Available</option><option value="required">Required</option></select></div>)}
|
||||||
|
</div>
|
||||||
|
<p className="muted small-note">Required means the definition must remain present and system-controlled. It does not automatically assign users or grant permissions.</p>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(viewing)} title={viewing?.name || "Template details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||||
|
{viewing && <dl className="admin-details-grid"><div><dt>Kind</dt><dd>{viewing.kind}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Tenants</dt><dd>{viewing.assignments.length || "None"}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Permissions</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog open={Boolean(deleting)} title={kind === "group" ? "Delete group template" : "Delete tenant role"} message={`Delete ${deleting?.name}? Removal is blocked while a materialized tenant definition still has members or assignments.`} confirmLabel={kind === "group" ? "Delete template" : "Delete tenant role"} tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { AdminPageLayout, adminErrorMessage, Button, dispatchPlatformModulesChanged, StatusBadge, ToggleSwitch } from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
cancelModuleInstallerRequest,
|
||||||
|
clearModuleInstallPlan,
|
||||||
|
createModuleInstallerRequest,
|
||||||
|
fetchModuleCatalog,
|
||||||
|
fetchModuleInstallPlan,
|
||||||
|
fetchModuleInstallerRequests,
|
||||||
|
fetchModuleInstallerRuns,
|
||||||
|
fetchModulePackageCatalog,
|
||||||
|
planModuleInstallFromCatalog,
|
||||||
|
planModuleUninstall,
|
||||||
|
retryModuleInstallerRequest,
|
||||||
|
updateModuleInstallPlan,
|
||||||
|
updateModuleState,
|
||||||
|
type ModuleCatalogItem,
|
||||||
|
type ModuleCatalogResponse,
|
||||||
|
type ModuleInstallerRequestListResponse,
|
||||||
|
type ModuleInstallerRequestOptions,
|
||||||
|
type ModuleInstallerRunListResponse,
|
||||||
|
type ModuleInstallPlanItem,
|
||||||
|
type ModuleInstallPlanResponse,
|
||||||
|
type ModulePackageCatalogResponse,
|
||||||
|
type ModulePackageCatalogItem
|
||||||
|
} from "../../api/admin";
|
||||||
|
|
||||||
|
export default function ModuleManagementPanel({ settings, canWrite, canAccessMaintenance }: { settings: ApiSettings; canWrite: boolean; canAccessMaintenance: boolean }) {
|
||||||
|
const [catalog, setCatalog] = useState<ModuleCatalogResponse | null>(null);
|
||||||
|
const [installPlan, setInstallPlan] = useState<ModuleInstallPlanResponse | null>(null);
|
||||||
|
const [installerRuns, setInstallerRuns] = useState<ModuleInstallerRunListResponse | null>(null);
|
||||||
|
const [installerRequests, setInstallerRequests] = useState<ModuleInstallerRequestListResponse | null>(null);
|
||||||
|
const [packageCatalog, setPackageCatalog] = useState<ModulePackageCatalogResponse | null>(null);
|
||||||
|
const [draftEnabled, setDraftEnabled] = useState<Set<string> | null>(null);
|
||||||
|
const [draftPlanItems, setDraftPlanItems] = useState<ModuleInstallPlanItem[]>([]);
|
||||||
|
const [requestOptions, setRequestOptions] = useState<InstallerRequestFormOptions>(() => defaultInstallerRequestOptions());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [planBusy, setPlanBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [loaded, loadedPlan, loadedRuns, loadedRequests, loadedPackageCatalog] = await Promise.all([
|
||||||
|
fetchModuleCatalog(settings),
|
||||||
|
fetchModuleInstallPlan(settings),
|
||||||
|
fetchModuleInstallerRuns(settings),
|
||||||
|
fetchModuleInstallerRequests(settings),
|
||||||
|
fetchModulePackageCatalog(settings)
|
||||||
|
]);
|
||||||
|
setCatalog(loaded);
|
||||||
|
setInstallPlan(loadedPlan);
|
||||||
|
setInstallerRuns(loadedRuns);
|
||||||
|
setInstallerRequests(loadedRequests);
|
||||||
|
setPackageCatalog(loadedPackageCatalog);
|
||||||
|
setDraftEnabled(new Set(loaded.desired_enabled));
|
||||||
|
setDraftPlanItems(loadedPlan.items);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||||
|
|
||||||
|
const desired = draftEnabled ?? new Set(catalog?.desired_enabled ?? []);
|
||||||
|
const dirty = Boolean(catalog && !sameSet(desired, new Set(catalog.desired_enabled)));
|
||||||
|
const pendingCount = catalog?.modules.filter((module) => module.current_enabled !== desired.has(module.id)).length ?? 0;
|
||||||
|
const activeCount = catalog?.modules.filter((module) => module.current_enabled).length ?? 0;
|
||||||
|
const desiredCount = desired.size;
|
||||||
|
const maintenanceEnabled = Boolean(catalog?.maintenance_mode.enabled || installPlan?.maintenance_mode.enabled);
|
||||||
|
const planDirty = Boolean(installPlan && JSON.stringify(normalizePlanItems(draftPlanItems)) !== JSON.stringify(normalizePlanItems(installPlan.items)));
|
||||||
|
const planValid = planValidationError(draftPlanItems) === "";
|
||||||
|
|
||||||
|
function setModuleDesired(module: ModuleCatalogItem, enabled: boolean) {
|
||||||
|
if (module.protected) return;
|
||||||
|
setSuccess("");
|
||||||
|
setDraftEnabled((current) => {
|
||||||
|
const next = new Set(current ?? catalog?.desired_enabled ?? []);
|
||||||
|
if (enabled) next.add(module.id);
|
||||||
|
else next.delete(module.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!draftEnabled) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const updated = await updateModuleState(settings, Array.from(draftEnabled).sort());
|
||||||
|
setCatalog(updated);
|
||||||
|
setDraftEnabled(new Set(updated.desired_enabled));
|
||||||
|
setSuccess(updated.notes[0] ?? "Module state saved.");
|
||||||
|
dispatchPlatformModulesChanged();
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePlan() {
|
||||||
|
setPlanBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const updated = await updateModuleInstallPlan(settings, normalizePlanItems(draftPlanItems));
|
||||||
|
setInstallPlan(updated);
|
||||||
|
setDraftPlanItems(updated.items);
|
||||||
|
setSuccess(updated.notes[0] ?? "Module install plan saved.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setPlanBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPlan() {
|
||||||
|
setPlanBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const updated = await clearModuleInstallPlan(settings);
|
||||||
|
setInstallPlan(updated);
|
||||||
|
setDraftPlanItems([]);
|
||||||
|
setSuccess(updated.notes[0] ?? "Module install plan cleared.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setPlanBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlanItem(index: number, patch: Partial<ModuleInstallPlanItem>) {
|
||||||
|
setSuccess("");
|
||||||
|
setDraftPlanItems((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPlanItem() {
|
||||||
|
setDraftPlanItems((items) => [...items, emptyPlanItem()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCatalogItem(item: ModulePackageCatalogItem) {
|
||||||
|
setPlanBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const updated = await planModuleInstallFromCatalog(settings, item.module_id);
|
||||||
|
setInstallPlan(updated);
|
||||||
|
setDraftPlanItems(updated.items);
|
||||||
|
setSuccess(updated.notes[0] ?? "Catalog install entry planned.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setPlanBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUninstallPlan(module: ModuleCatalogItem) {
|
||||||
|
setPlanBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const updated = await planModuleUninstall(settings, module.id);
|
||||||
|
setInstallPlan(updated);
|
||||||
|
setDraftPlanItems(updated.items);
|
||||||
|
setSuccess(updated.notes[0] ?? "Package uninstall planned.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setPlanBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueInstallerRequest() {
|
||||||
|
if (!installPlan) return;
|
||||||
|
setPlanBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const request = await createModuleInstallerRequest(settings, normalizeRequestOptions(requestOptions, installPlan));
|
||||||
|
setSuccess(`Installer request queued: ${request.request_id}`);
|
||||||
|
const [loadedRuns, loadedRequests] = await Promise.all([
|
||||||
|
fetchModuleInstallerRuns(settings),
|
||||||
|
fetchModuleInstallerRequests(settings)
|
||||||
|
]);
|
||||||
|
setInstallerRuns(loadedRuns);
|
||||||
|
setInstallerRequests(loadedRequests);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setPlanBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelInstallerRequest(requestId: string) {
|
||||||
|
setPlanBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const request = await cancelModuleInstallerRequest(settings, requestId);
|
||||||
|
setSuccess(`Installer request cancelled: ${request.request_id}`);
|
||||||
|
setInstallerRequests(await fetchModuleInstallerRequests(settings));
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setPlanBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryInstallerRequest(requestId: string) {
|
||||||
|
setPlanBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const request = await retryModuleInstallerRequest(settings, requestId);
|
||||||
|
setSuccess(`Installer request queued: ${request.request_id}`);
|
||||||
|
setInstallerRequests(await fetchModuleInstallerRequests(settings));
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setPlanBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageLayout
|
||||||
|
title="System modules"
|
||||||
|
description="Installed modules, active runtime state, and saved startup state."
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
actions={<><Button onClick={() => void load()} disabled={loading || busy}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}>{busy ? "Applying..." : "Save and apply"}</Button></>}
|
||||||
|
>
|
||||||
|
{catalog && <>
|
||||||
|
<div className="metric-grid module-management-metrics">
|
||||||
|
<ModuleMetric label="Installed" value={catalog.modules.length} detail="Discovered packages" />
|
||||||
|
<ModuleMetric label="Active" value={activeCount} detail="Running registry" />
|
||||||
|
<ModuleMetric label="Saved" value={desiredCount} detail="Startup state" />
|
||||||
|
<ModuleMetric label="Drift" value={pendingCount} detail={pendingCount ? "Runtime differs" : "Runtime matches"} tone={pendingCount ? "warning" : "good"} />
|
||||||
|
<ModuleMetric label="Maintenance" value={maintenanceEnabled ? "On" : "Off"} detail={canAccessMaintenance ? "Bypass allowed" : "Bypass denied"} tone={maintenanceEnabled ? "warning" : "info"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="module-management-list">
|
||||||
|
{catalog.modules.map((module) => {
|
||||||
|
const desiredEnabled = desired.has(module.id);
|
||||||
|
return (
|
||||||
|
<div key={module.id} className={`module-management-row${module.restart_required || module.current_enabled !== desiredEnabled ? " pending" : ""}`}>
|
||||||
|
<div className="module-management-main">
|
||||||
|
<div className="module-management-title">
|
||||||
|
<strong>{module.name}</strong>
|
||||||
|
<code>{module.id}</code>
|
||||||
|
<span>v{module.version}</span>
|
||||||
|
</div>
|
||||||
|
<div className="module-management-meta">
|
||||||
|
<ModuleStatus module={module} desiredEnabled={desiredEnabled} />
|
||||||
|
{module.protected && <StatusBadge status="locked" label="Locked" />}
|
||||||
|
{module.frontend_package && <span>{module.frontend_package}</span>}
|
||||||
|
{module.migration_module_id && <span>DB: {module.migration_module_id}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="module-management-details">
|
||||||
|
<span>Requires: {module.dependencies.length ? module.dependencies.join(", ") : "none"}</span>
|
||||||
|
<span>Optional: {module.optional_dependencies.length ? module.optional_dependencies.join(", ") : "none"}</span>
|
||||||
|
<span>Dependents: {module.dependents.length ? module.dependents.join(", ") : "none"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="module-management-toggle">
|
||||||
|
<ToggleSwitch
|
||||||
|
label={desiredEnabled ? "Enabled" : "Disabled"}
|
||||||
|
checked={desiredEnabled}
|
||||||
|
disabled={!canWrite || module.protected || busy}
|
||||||
|
onChange={(checked) => setModuleDesired(module, checked)}
|
||||||
|
/>
|
||||||
|
{module.install_uninstall_supported && <Button onClick={() => void addUninstallPlan(module)} disabled={!canWrite || planBusy || module.current_enabled || module.desired_enabled}>Plan uninstall</Button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{catalog.notes.length > 0 && <div className="module-management-notes">
|
||||||
|
{catalog.notes.map((note) => <p key={note}>{note}</p>)}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{packageCatalog && <div className="module-package-catalog">
|
||||||
|
<div className="module-install-plan-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Package catalog</h2>
|
||||||
|
<p>Approved package references that can be added to the operator install plan.</p>
|
||||||
|
</div>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
{packageCatalog.channel && <StatusBadge status="inactive" label={`Channel: ${packageCatalog.channel}`} />}
|
||||||
|
{packageCatalog.sequence !== null && packageCatalog.sequence !== undefined && <StatusBadge status="inactive" label={`Seq ${packageCatalog.sequence}`} />}
|
||||||
|
<StatusBadge
|
||||||
|
status={packageCatalog.trusted ? "success" : packageCatalog.signed ? "warning" : "inactive"}
|
||||||
|
label={packageCatalog.trusted ? "Signed and trusted" : packageCatalog.signed ? "Signed, untrusted" : "Unsigned"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(packageCatalog.source || packageCatalog.path) && <p className="module-package-catalog-description">Catalog {packageCatalog.source_type ?? "source"}: <code>{packageCatalog.source ?? packageCatalog.path}</code></p>}
|
||||||
|
{(packageCatalog.generated_at || packageCatalog.expires_at) && <p className="module-package-catalog-description">Generated: {packageCatalog.generated_at ?? "unknown"} · Expires: {packageCatalog.expires_at ?? "unknown"}</p>}
|
||||||
|
{packageCatalog.error && <p className="alert warning">{packageCatalog.error}</p>}
|
||||||
|
{packageCatalog.warnings.map((warning) => <p key={warning} className="alert warning">{warning}</p>)}
|
||||||
|
{!packageCatalog.configured && <div className="module-install-plan-empty">No package catalog configured. Set GOVOPLAN_MODULE_PACKAGE_CATALOG or GOVOPLAN_MODULE_PACKAGE_CATALOG_URL to enable curated install entries.</div>}
|
||||||
|
{packageCatalog.configured && packageCatalog.modules.length === 0 && !packageCatalog.error && <div className="module-install-plan-empty">Package catalog is configured but contains no entries.</div>}
|
||||||
|
{packageCatalog.modules.length > 0 && <div className="module-package-catalog-list">
|
||||||
|
{packageCatalog.modules.map((item) => (
|
||||||
|
<div key={`${item.module_id}-${item.version ?? "latest"}`} className="module-package-catalog-row">
|
||||||
|
<div className="module-management-main">
|
||||||
|
<div className="module-management-title">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<code>{item.module_id}</code>
|
||||||
|
{item.version && <span>v{item.version}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="module-management-details">
|
||||||
|
{item.python_package && <span>{item.python_package}</span>}
|
||||||
|
{item.webui_package && <span>{item.webui_package}</span>}
|
||||||
|
{item.tags.length > 0 && <span>{item.tags.join(", ")}</span>}
|
||||||
|
{item.license_features.length > 0 && <span>License: {item.license_features.join(", ")}</span>}
|
||||||
|
{item.license_missing_features.length > 0 && <span>Missing: {item.license_missing_features.join(", ")}</span>}
|
||||||
|
</div>
|
||||||
|
{item.description && <p className="module-package-catalog-description">{item.description}</p>}
|
||||||
|
{item.license_reason && <p className={`module-package-catalog-description${item.license_allowed ? "" : " alert warning"}`}>{item.license_reason}</p>}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => void addCatalogItem(item)} disabled={!canWrite || planBusy || !packageCatalog.valid || !item.license_allowed || item.action !== "install"}>Plan install</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{installPlan && <div className="module-install-plan-panel">
|
||||||
|
<div className="module-install-plan-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Operator install plan</h2>
|
||||||
|
<p>Plan package installs and removals here, then apply the rendered commands from an operator shell during maintenance mode.</p>
|
||||||
|
</div>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={addPlanItem} disabled={!canWrite || planBusy}>Add plan item</Button>
|
||||||
|
<Button onClick={() => void clearPlan()} disabled={!canWrite || planBusy || draftPlanItems.length === 0}>Clear</Button>
|
||||||
|
<Button variant="primary" onClick={() => void savePlan()} disabled={!canWrite || planBusy || !planDirty || !planValid}>{planBusy ? "Saving..." : "Save plan"}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{planValidationError(draftPlanItems) && <p className="alert warning">{planValidationError(draftPlanItems)}</p>}
|
||||||
|
{!maintenanceEnabled && <p className="alert warning">Maintenance mode is off. Package changes should be applied only after enabling maintenance mode.</p>}
|
||||||
|
|
||||||
|
{installPlan.preflight && <div className={`module-install-preflight ${installPlan.preflight.allowed ? "is-allowed" : "is-blocked"}`}>
|
||||||
|
<div className="module-install-preflight-title">
|
||||||
|
<strong>{installPlan.preflight.allowed ? "Installer preflight passed" : "Installer preflight blocked"}</strong>
|
||||||
|
<span>{installPlan.preflight.restart_required ? "Restart/reload required after package changes" : "No package restart pending"}</span>
|
||||||
|
{installPlan.preflight.frontend_rebuild_required && <span>WebUI rebuild required</span>}
|
||||||
|
</div>
|
||||||
|
{installPlan.preflight.issues.length > 0 && <div className="module-install-preflight-issues">
|
||||||
|
{installPlan.preflight.issues.map((issue, index) => (
|
||||||
|
<div key={`${issue.code}-${issue.module_id ?? "global"}-${index}`} className={`module-install-preflight-issue severity-${issue.severity}`}>
|
||||||
|
<strong>{issue.severity}</strong>
|
||||||
|
<span>{issue.module_id ? `${issue.module_id}: ` : ""}{issue.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>}
|
||||||
|
{installPlan.preflight.checklist.length > 0 && <div className="module-install-checklist">
|
||||||
|
{installPlan.preflight.checklist.map((item) => (
|
||||||
|
<div key={item.id} className={`module-install-checklist-item status-${item.status}`}>
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
<span>{item.status}</span>
|
||||||
|
{item.detail && <p>{item.detail}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<div className="module-install-plan-list">
|
||||||
|
{draftPlanItems.length === 0 && <div className="module-install-plan-empty">No package changes planned.</div>}
|
||||||
|
{draftPlanItems.map((item, index) => (
|
||||||
|
<div className="module-install-plan-row" key={`${item.module_id}-${index}`}>
|
||||||
|
<label><span>Action</span><select value={item.action} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { action: event.target.value as ModuleInstallPlanItem["action"] })}><option value="install">Install</option><option value="uninstall">Uninstall</option></select></label>
|
||||||
|
<label><span>Module</span><input value={item.module_id} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { module_id: event.target.value })} placeholder="files" /></label>
|
||||||
|
<label><span>Python package</span><input value={item.python_package ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { python_package: event.target.value })} placeholder="govoplan-files" /></label>
|
||||||
|
<ToggleSwitch label="Destroy data" checked={Boolean(item.destroy_data)} onChange={(checked) => updatePlanItem(index, { destroy_data: checked })} disabled={!canWrite || planBusy || item.action !== "uninstall"} />
|
||||||
|
<label className="wide"><span>Python ref</span><input value={item.python_ref ?? ""} disabled={!canWrite || planBusy || item.action === "uninstall"} onChange={(event) => updatePlanItem(index, { python_ref: event.target.value })} placeholder="govoplan-files @ git+ssh://...@v0.1.4" /></label>
|
||||||
|
<label><span>WebUI package</span><input value={item.webui_package ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { webui_package: event.target.value })} placeholder="@govoplan/files-webui" /></label>
|
||||||
|
<label className="wide"><span>WebUI ref</span><input value={item.webui_ref ?? ""} disabled={!canWrite || planBusy || item.action === "uninstall"} onChange={(event) => updatePlanItem(index, { webui_ref: event.target.value })} placeholder="git+ssh://...#v0.1.4" /></label>
|
||||||
|
<label><span>Status</span><select value={item.status} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { status: event.target.value as ModuleInstallPlanItem["status"] })}><option value="planned">Planned</option><option value="applied">Applied</option><option value="blocked">Blocked</option></select></label>
|
||||||
|
<label className="wide"><span>Notes</span><input value={item.notes ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { notes: event.target.value })} /></label>
|
||||||
|
<div className="module-install-plan-actions"><Button onClick={() => setDraftPlanItems((items) => items.filter((_, itemIndex) => itemIndex !== index))} disabled={!canWrite || planBusy}>Remove</Button></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre className="code-panel module-install-plan-commands">{installerCommand(installPlan.preflight?.frontend_rebuild_required ?? false)}</pre>
|
||||||
|
{installPlan.preflight?.commands.length ? <pre className="code-panel module-install-plan-commands">{installPlan.preflight.commands.join("\n")}</pre> : installPlan.commands.length > 0 && <pre className="code-panel module-install-plan-commands">{installPlan.commands.join("\n")}</pre>}
|
||||||
|
{installPlan.notes.length > 0 && <div className="module-management-notes">{installPlan.notes.map((note) => <p key={note}>{note}</p>)}</div>}
|
||||||
|
|
||||||
|
<div className="module-installer-request-panel">
|
||||||
|
<div className="module-install-plan-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Daemon execution</h2>
|
||||||
|
<p>Queue the saved plan for a separate installer daemon process.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={() => void queueInstallerRequest()} disabled={!canWrite || !canAccessMaintenance || planBusy || planDirty || !maintenanceEnabled || !installPlan.preflight?.allowed}>
|
||||||
|
Queue supervised run
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{planDirty && <p className="alert warning">Save the install plan before queueing a daemon request.</p>}
|
||||||
|
{!maintenanceEnabled && <p className="alert warning">Maintenance mode must be enabled before queueing a daemon request.</p>}
|
||||||
|
{!canAccessMaintenance && <p className="alert warning">Queueing installer requests requires maintenance access.</p>}
|
||||||
|
<div className="module-installer-request-grid">
|
||||||
|
<ToggleSwitch label="Run migrations" checked={requestOptions.migrateDatabase} onChange={(checked) => setRequestOptions((current) => ({ ...current, migrateDatabase: checked }))} disabled={!canWrite || planBusy} />
|
||||||
|
<ToggleSwitch label="Build WebUI" checked={requestOptions.buildWebui} onChange={(checked) => setRequestOptions((current) => ({ ...current, buildWebui: checked }))} disabled={!canWrite || planBusy} />
|
||||||
|
<ToggleSwitch label="Activate installs" checked={requestOptions.activateInstalledModules} onChange={(checked) => setRequestOptions((current) => ({ ...current, activateInstalledModules: checked }))} disabled={!canWrite || planBusy} />
|
||||||
|
<ToggleSwitch label="Drop uninstalls from startup" checked={requestOptions.removeUninstalledModulesFromDesired} onChange={(checked) => setRequestOptions((current) => ({ ...current, removeUninstalledModulesFromDesired: checked }))} disabled={!canWrite || planBusy} />
|
||||||
|
<label><span>Health URLs</span><textarea value={requestOptions.healthUrls} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, healthUrls: event.target.value }))} placeholder="http://127.0.0.1:8000/health" /></label>
|
||||||
|
<label><span>Restart commands</span><textarea value={requestOptions.restartCommands} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, restartCommands: event.target.value }))} placeholder="systemctl restart govoplan.service" /></label>
|
||||||
|
<label><span>DB backup command</span><textarea value={requestOptions.databaseBackupCommand} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, databaseBackupCommand: event.target.value }))} placeholder={"pg_dump --format=custom \"$GOVOPLAN_DATABASE_URL\" > \"$GOVOPLAN_DATABASE_BACKUP_PATH\""} /></label>
|
||||||
|
<label><span>DB restore command</span><textarea value={requestOptions.databaseRestoreCommand} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, databaseRestoreCommand: event.target.value }))} placeholder={"pg_restore --clean --if-exists --dbname \"$GOVOPLAN_DATABASE_URL\" \"$GOVOPLAN_DATABASE_BACKUP_PATH\""} /></label>
|
||||||
|
<label><span>DB restore-check command</span><textarea value={requestOptions.databaseRestoreCheckCommand} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, databaseRestoreCheckCommand: event.target.value }))} placeholder={"pg_restore --list \"$GOVOPLAN_DATABASE_BACKUP_PATH\" >/dev/null"} /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{installerRequests && <div className="module-installer-runs">
|
||||||
|
<div className="module-installer-runs-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Installer requests</h2>
|
||||||
|
<p>Queued daemon handoffs created from the admin UI or CLI.</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge
|
||||||
|
status={installerRequests.daemon.running ? statusTone(installerRequests.daemon.status) : "inactive"}
|
||||||
|
label={installerRequests.daemon.running ? `Daemon: ${installerRequests.daemon.status}` : "Daemon offline"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{installerRequests.requests.length === 0 && <div className="module-install-plan-empty">No installer requests recorded yet.</div>}
|
||||||
|
{installerRequests.requests.length > 0 && <div className="module-installer-run-list">
|
||||||
|
{installerRequests.requests.map((request) => (
|
||||||
|
<div key={request.request_id} className="module-installer-run-row">
|
||||||
|
<div className="module-installer-run-main">
|
||||||
|
<div className="module-installer-run-title">
|
||||||
|
<strong>{request.request_id}</strong>
|
||||||
|
<StatusBadge status={statusTone(request.status)} label={request.status} />
|
||||||
|
</div>
|
||||||
|
<div className="module-management-details">
|
||||||
|
<span>Created: {formatDateTime(request.created_at)}</span>
|
||||||
|
<span>Started: {formatDateTime(request.started_at)}</span>
|
||||||
|
<span>Finished: {formatDateTime(request.finished_at)}</span>
|
||||||
|
{request.cancelled_at && <span>Cancelled: {formatDateTime(request.cancelled_at)}</span>}
|
||||||
|
{request.retry_of && <span>Retry of: {request.retry_of}</span>}
|
||||||
|
</div>
|
||||||
|
{request.error && <p className="module-installer-run-error">{request.error}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="module-installer-run-actions">
|
||||||
|
{request.status === "queued" && <Button onClick={() => void cancelInstallerRequest(request.request_id)} disabled={!canWrite || !canAccessMaintenance || !maintenanceEnabled || planBusy}>Cancel</Button>}
|
||||||
|
{(request.status === "failed" || request.status === "cancelled") && <Button onClick={() => void retryInstallerRequest(request.request_id)} disabled={!canWrite || !canAccessMaintenance || !maintenanceEnabled || planBusy}>Retry</Button>}
|
||||||
|
{request.record_path && <code>{request.record_path}</code>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{installerRuns && <div className="module-installer-runs">
|
||||||
|
<div className="module-installer-runs-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Installer runs</h2>
|
||||||
|
<p>Recent supervised installer records and the current package-install lock.</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={installerRuns.lock.locked ? "warning" : "success"} label={installerRuns.lock.locked ? "Locked" : "Unlocked"} />
|
||||||
|
</div>
|
||||||
|
{installerRuns.runs.length === 0 && <div className="module-install-plan-empty">No installer runs recorded yet.</div>}
|
||||||
|
{installerRuns.runs.length > 0 && <div className="module-installer-run-list">
|
||||||
|
{installerRuns.runs.map((run) => (
|
||||||
|
<div key={run.run_id} className="module-installer-run-row">
|
||||||
|
<div className="module-installer-run-main">
|
||||||
|
<div className="module-installer-run-title">
|
||||||
|
<strong>{run.run_id}</strong>
|
||||||
|
<StatusBadge status={statusTone(run.status)} label={run.status} />
|
||||||
|
{run.supervisor_status && <StatusBadge status={statusTone(run.supervisor_status)} label={`Supervisor: ${run.supervisor_status}`} />}
|
||||||
|
{run.rollback_status && <StatusBadge status={statusTone(run.rollback_status)} label={`Rollback: ${run.rollback_status}`} />}
|
||||||
|
</div>
|
||||||
|
<div className="module-management-details">
|
||||||
|
<span>Modules: {run.planned_modules.length ? run.planned_modules.join(", ") : "none"}</span>
|
||||||
|
<span>Commands: {run.commands_count}</span>
|
||||||
|
<span>Started: {formatDateTime(run.started_at)}</span>
|
||||||
|
<span>Finished: {formatDateTime(run.finished_at)}</span>
|
||||||
|
</div>
|
||||||
|
{run.error && <p className="module-installer-run-error">{run.error}</p>}
|
||||||
|
</div>
|
||||||
|
<code>{run.record_path}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>}
|
||||||
|
</div>}
|
||||||
|
</>}
|
||||||
|
</AdminPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleMetric({ label, value, detail, tone = "info" }: { label: string; value: string | number; detail: string; tone?: "info" | "good" | "warning" }) {
|
||||||
|
return <div className={`metric-card metric-${tone}`}><div className="metric-label">{label}</div><div className="metric-value">{value}</div><div className="metric-detail">{detail}</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleStatus({ module, desiredEnabled }: { module: ModuleCatalogItem; desiredEnabled: boolean }) {
|
||||||
|
if (module.current_enabled && !desiredEnabled) return <StatusBadge status="warning" label="Will disable" />;
|
||||||
|
if (!module.current_enabled && desiredEnabled) return <StatusBadge status="warning" label="Will enable" />;
|
||||||
|
if (module.current_enabled) return <StatusBadge status="success" label="Active" />;
|
||||||
|
return <StatusBadge status="inactive" label="Inactive" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSet(left: ReadonlySet<string>, right: ReadonlySet<string>) {
|
||||||
|
if (left.size !== right.size) return false;
|
||||||
|
for (const item of left) {
|
||||||
|
if (!right.has(item)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPlanItem(): ModuleInstallPlanItem {
|
||||||
|
return {
|
||||||
|
module_id: "",
|
||||||
|
action: "install",
|
||||||
|
python_package: "",
|
||||||
|
python_ref: "",
|
||||||
|
webui_package: "",
|
||||||
|
webui_ref: "",
|
||||||
|
destroy_data: false,
|
||||||
|
status: "planned",
|
||||||
|
notes: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlanItems(items: ModuleInstallPlanItem[]): ModuleInstallPlanItem[] {
|
||||||
|
return items
|
||||||
|
.map((item) => ({
|
||||||
|
module_id: item.module_id.trim(),
|
||||||
|
action: item.action,
|
||||||
|
python_package: cleanOptional(item.python_package),
|
||||||
|
python_ref: item.action === "install" ? cleanOptional(item.python_ref) : null,
|
||||||
|
webui_package: cleanOptional(item.webui_package),
|
||||||
|
webui_ref: item.action === "install" ? cleanOptional(item.webui_ref) : null,
|
||||||
|
destroy_data: item.action === "uninstall" && Boolean(item.destroy_data),
|
||||||
|
status: item.status,
|
||||||
|
notes: cleanOptional(item.notes)
|
||||||
|
}))
|
||||||
|
.filter((item) => item.module_id || item.python_package || item.python_ref || item.webui_package || item.webui_ref || item.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanOptional(value?: string | null): string | null {
|
||||||
|
const cleaned = (value ?? "").trim();
|
||||||
|
return cleaned || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function planValidationError(items: ModuleInstallPlanItem[]): string {
|
||||||
|
for (const item of normalizePlanItems(items)) {
|
||||||
|
if (!item.module_id) return "Every plan item needs a module id.";
|
||||||
|
if (item.action === "install" && !item.python_ref) return `${item.module_id} needs a Python package reference.`;
|
||||||
|
if (item.action === "install" && item.python_ref && !item.python_package) return `${item.module_id} needs a Python package name for rollback.`;
|
||||||
|
if (item.action === "uninstall" && !item.python_package) return `${item.module_id} needs a Python package name for uninstall.`;
|
||||||
|
if (item.action === "install" && Boolean(item.webui_package) !== Boolean(item.webui_ref)) return `${item.module_id} needs both WebUI package and WebUI reference, or neither.`;
|
||||||
|
if (item.action === "uninstall" && item.webui_ref && !item.webui_package) return `${item.module_id} has a WebUI reference but no WebUI package.`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: string): string {
|
||||||
|
const normalized = status.toLowerCase();
|
||||||
|
if (normalized.includes("fail") || normalized.includes("blocked")) return "error";
|
||||||
|
if (normalized.includes("rollback") || normalized.includes("running") || normalized.includes("dry")) return "warning";
|
||||||
|
if (normalized.includes("applied") || normalized.includes("ok") || normalized.includes("unlock")) return "success";
|
||||||
|
return "inactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null): string {
|
||||||
|
if (!value) return "not recorded";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function installerCommand(frontendRebuildRequired: boolean): string {
|
||||||
|
const buildFlag = frontendRebuildRequired ? " --build-webui" : "";
|
||||||
|
return [
|
||||||
|
"govoplan-module-installer --format shell",
|
||||||
|
`govoplan-module-installer --daemon --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`,
|
||||||
|
`govoplan-module-installer --enqueue-supervised --migrate${buildFlag} --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstallerRequestFormOptions = {
|
||||||
|
buildWebui: boolean;
|
||||||
|
migrateDatabase: boolean;
|
||||||
|
healthUrls: string;
|
||||||
|
restartCommands: string;
|
||||||
|
databaseBackupCommand: string;
|
||||||
|
databaseRestoreCommand: string;
|
||||||
|
databaseRestoreCheckCommand: string;
|
||||||
|
activateInstalledModules: boolean;
|
||||||
|
removeUninstalledModulesFromDesired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultInstallerRequestOptions(): InstallerRequestFormOptions {
|
||||||
|
return {
|
||||||
|
buildWebui: false,
|
||||||
|
migrateDatabase: true,
|
||||||
|
healthUrls: "http://127.0.0.1:8000/health",
|
||||||
|
restartCommands: "",
|
||||||
|
databaseBackupCommand: "",
|
||||||
|
databaseRestoreCommand: "",
|
||||||
|
databaseRestoreCheckCommand: "",
|
||||||
|
activateInstalledModules: true,
|
||||||
|
removeUninstalledModulesFromDesired: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequestOptions(form: InstallerRequestFormOptions, installPlan: ModuleInstallPlanResponse): ModuleInstallerRequestOptions {
|
||||||
|
return {
|
||||||
|
build_webui: form.buildWebui || Boolean(installPlan.preflight?.frontend_rebuild_required),
|
||||||
|
migrate_database: form.migrateDatabase,
|
||||||
|
restart_commands: splitLines(form.restartCommands),
|
||||||
|
health_urls: splitLines(form.healthUrls),
|
||||||
|
database_backup_command: cleanOptional(form.databaseBackupCommand),
|
||||||
|
database_restore_command: cleanOptional(form.databaseRestoreCommand),
|
||||||
|
database_restore_check_command: cleanOptional(form.databaseRestoreCheckCommand),
|
||||||
|
activate_installed_modules: form.activateInstalledModules,
|
||||||
|
remove_uninstalled_modules_from_desired: form.removeUninstalledModulesFromDesired,
|
||||||
|
health_timeout_seconds: 60,
|
||||||
|
health_interval_seconds: 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLines(value: string): string[] {
|
||||||
|
return value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { Button } from "@govoplan/core-webui";
|
||||||
|
import { Card } from "@govoplan/core-webui";
|
||||||
|
import { FormField } from "@govoplan/core-webui";
|
||||||
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
|
import { fetchSystemSettings, updateSystemSettings, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsItem } from "../../api/admin";
|
||||||
|
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
|
||||||
|
store_raw_campaign_json: true,
|
||||||
|
raw_campaign_json_retention_days: true,
|
||||||
|
generated_eml_retention_days: true,
|
||||||
|
stored_report_detail_retention_days: true,
|
||||||
|
mock_mailbox_retention_days: true,
|
||||||
|
audit_detail_retention_days: true,
|
||||||
|
audit_detail_level: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPrivacyPolicy: PrivacyRetentionPolicy = {
|
||||||
|
store_raw_campaign_json: true,
|
||||||
|
raw_campaign_json_retention_days: null,
|
||||||
|
generated_eml_retention_days: null,
|
||||||
|
stored_report_detail_retention_days: null,
|
||||||
|
mock_mailbox_retention_days: null,
|
||||||
|
audit_detail_retention_days: null,
|
||||||
|
audit_detail_level: "full",
|
||||||
|
allow_lower_level_limits: defaultAllowLowerLevelLimits
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallback: SystemSettingsItem = {
|
||||||
|
default_locale: "en",
|
||||||
|
allow_tenant_custom_groups: true,
|
||||||
|
allow_tenant_custom_roles: true,
|
||||||
|
allow_tenant_api_keys: true,
|
||||||
|
privacy_retention_policy: defaultPrivacyPolicy,
|
||||||
|
maintenance_mode: { enabled: false, message: "" },
|
||||||
|
settings: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SystemSettingsPanel({ settings, canWrite, canAccessMaintenance }: { settings: ApiSettings; canWrite: boolean; canAccessMaintenance: boolean }) {
|
||||||
|
const [draft, setDraft] = useState<SystemSettingsItem>(fallback);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const loaded = await fetchSystemSettings(settings);
|
||||||
|
setDraft(loaded);
|
||||||
|
}
|
||||||
|
catch (err) { setError(adminErrorMessage(err)); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setDraft(await updateSystemSettings(settings, {
|
||||||
|
default_locale: draft.default_locale,
|
||||||
|
allow_tenant_custom_groups: draft.allow_tenant_custom_groups,
|
||||||
|
allow_tenant_custom_roles: draft.allow_tenant_custom_roles,
|
||||||
|
allow_tenant_api_keys: draft.allow_tenant_api_keys,
|
||||||
|
maintenance_mode: draft.maintenance_mode
|
||||||
|
}));
|
||||||
|
setSuccess("System settings saved.");
|
||||||
|
} catch (err) { setError(adminErrorMessage(err)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageLayout
|
||||||
|
title="System general settings"
|
||||||
|
description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section."
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy}>{busy ? "Saving…" : "Save settings"}</Button></>}
|
||||||
|
>
|
||||||
|
<div className="admin-settings-form">
|
||||||
|
<Card title="Defaults for newly created tenants">
|
||||||
|
<FormField label="Default locale"><input value={draft.default_locale} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} /></FormField>
|
||||||
|
</Card>
|
||||||
|
<Card title="Tenant administration capabilities">
|
||||||
|
<div className="settings-list">
|
||||||
|
<ToggleSwitch checked={draft.allow_tenant_custom_groups} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_groups: checked })} label="Allow tenant-defined groups by default" />
|
||||||
|
<ToggleSwitch checked={draft.allow_tenant_custom_roles} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_roles: checked })} label="Allow tenant-defined roles by default" />
|
||||||
|
<ToggleSwitch checked={draft.allow_tenant_api_keys} onChange={(checked) => setDraft({ ...draft, allow_tenant_api_keys: checked })} label="Allow tenant API keys by default" />
|
||||||
|
</div>
|
||||||
|
<p className="muted small-note">These settings are enforced by the backend. Central groups and tenant roles remain available even when local creation is disabled.</p>
|
||||||
|
</Card>
|
||||||
|
<Card title="Maintenance mode">
|
||||||
|
<div className="settings-list">
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={draft.maintenance_mode.enabled}
|
||||||
|
disabled={!canWrite || !canAccessMaintenance}
|
||||||
|
onChange={(checked) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, enabled: checked } })}
|
||||||
|
label="Restrict authenticated API access to maintenance operators"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField label="Maintenance message">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={draft.maintenance_mode.message ?? ""}
|
||||||
|
disabled={!canWrite}
|
||||||
|
onChange={(event) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, message: event.target.value } })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<p className="muted small-note">Changing the maintenance-mode flag requires system:maintenance:access. Login remains available so an operator can sign in during maintenance.</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AdminPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { default } from "./module";
|
||||||
|
export * from "./module";
|
||||||
|
export * from "./api/admin";
|
||||||
|
export { default as AdminOverviewPanel } from "./features/admin/AdminOverviewPanel";
|
||||||
|
export { default as GovernanceTemplatesPanel } from "./features/admin/GovernanceTemplatesPanel";
|
||||||
|
export { default as SystemSettingsPanel } from "./features/admin/SystemSettingsPanel";
|
||||||
|
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { adminReadScopes, hasScope } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const AdminOverviewPanel = lazy(() => import("./features/admin/AdminOverviewPanel"));
|
||||||
|
const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceTemplatesPanel"));
|
||||||
|
const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel"));
|
||||||
|
const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel"));
|
||||||
|
|
||||||
|
const adminSections: AdminSectionsUiCapability = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "overview",
|
||||||
|
label: "Overview",
|
||||||
|
group: "ROOT",
|
||||||
|
order: 0,
|
||||||
|
anyOf: adminReadScopes,
|
||||||
|
render: ({ settings, availableSections, selectSection }) => createElement(AdminOverviewPanel, {
|
||||||
|
settings,
|
||||||
|
availableSections,
|
||||||
|
onSelect: selectSection
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system-settings",
|
||||||
|
label: "General",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 10,
|
||||||
|
allOf: ["system:settings:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(SystemSettingsPanel, {
|
||||||
|
settings,
|
||||||
|
canWrite: hasScope(auth, "system:settings:write"),
|
||||||
|
canAccessMaintenance: hasScope(auth, "system:maintenance:access")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system-role-templates",
|
||||||
|
label: "Tenant roles",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 40,
|
||||||
|
allOf: ["system:governance:read"],
|
||||||
|
render: ({ settings, auth, refreshAuth }) => createElement(GovernanceTemplatesPanel, {
|
||||||
|
settings,
|
||||||
|
kind: "role",
|
||||||
|
canWrite: hasScope(auth, "system:governance:write"),
|
||||||
|
onAuthRefresh: refreshAuth
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system-modules",
|
||||||
|
label: "Modules",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 85,
|
||||||
|
allOf: ["system:settings:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(ModuleManagementPanel, {
|
||||||
|
settings,
|
||||||
|
canWrite: hasScope(auth, "system:settings:write"),
|
||||||
|
canAccessMaintenance: hasScope(auth, "system:maintenance:access")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system-groups",
|
||||||
|
label: "Groups",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 50,
|
||||||
|
allOf: ["system:governance:read"],
|
||||||
|
render: ({ settings, auth, refreshAuth }) => createElement(GovernanceTemplatesPanel, {
|
||||||
|
settings,
|
||||||
|
kind: "group",
|
||||||
|
canWrite: hasScope(auth, "system:governance:write"),
|
||||||
|
onAuthRefresh: refreshAuth
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminModule: PlatformWebModule = {
|
||||||
|
id: "admin",
|
||||||
|
label: "Admin",
|
||||||
|
version: "1.0.0",
|
||||||
|
dependencies: ["access"],
|
||||||
|
uiCapabilities: {
|
||||||
|
"admin.sections": adminSections
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default adminModule;
|
||||||
Reference in New Issue
Block a user