Release v0.1.5
This commit is contained in:
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -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:
|
||||||
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
@@ -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?
|
||||||
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -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:
|
||||||
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
@@ -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:
|
||||||
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
@@ -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:
|
||||||
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Follow-up issues:
|
||||||
25
README.md
Normal file
25
README.md
Normal file
@@ -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.
|
||||||
32
package.json
Normal file
32
package.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -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"
|
||||||
|
|
||||||
35
src/govoplan_admin.egg-info/PKG-INFO
Normal file
35
src/govoplan_admin.egg-info/PKG-INFO
Normal file
@@ -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.
|
||||||
19
src/govoplan_admin.egg-info/SOURCES.txt
Normal file
19
src/govoplan_admin.egg-info/SOURCES.txt
Normal file
@@ -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
|
||||||
1
src/govoplan_admin.egg-info/dependency_links.txt
Normal file
1
src/govoplan_admin.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
2
src/govoplan_admin.egg-info/entry_points.txt
Normal file
2
src/govoplan_admin.egg-info/entry_points.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[govoplan.modules]
|
||||||
|
admin = govoplan_admin.backend.manifest:get_manifest
|
||||||
2
src/govoplan_admin.egg-info/requires.txt
Normal file
2
src/govoplan_admin.egg-info/requires.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
govoplan-core>=0.1.4
|
||||||
|
govoplan-access>=0.1.4
|
||||||
1
src/govoplan_admin.egg-info/top_level.txt
Normal file
1
src/govoplan_admin.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
govoplan_admin
|
||||||
2
src/govoplan_admin/__init__.py
Normal file
2
src/govoplan_admin/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""GovOPlaN generic administration module."""
|
||||||
|
|
||||||
BIN
src/govoplan_admin/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/govoplan_admin/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/govoplan_admin/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/govoplan_admin/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
2
src/govoplan_admin/backend/__init__.py
Normal file
2
src/govoplan_admin/backend/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Backend integration for the GovOPlaN admin module."""
|
||||||
|
|
||||||
BIN
src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc
Normal file
BIN
src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc
Normal file
BIN
src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc
Normal file
Binary file not shown.
0
src/govoplan_admin/backend/api/__init__.py
Normal file
0
src/govoplan_admin/backend/api/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
0
src/govoplan_admin/backend/api/v1/__init__.py
Normal file
0
src/govoplan_admin/backend/api/v1/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
840
src/govoplan_admin/backend/api/v1/routes.py
Normal file
840
src/govoplan_admin/backend/api/v1/routes.py
Normal file
@@ -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
|
||||||
360
src/govoplan_admin/backend/api/v1/schemas.py
Normal file
360
src/govoplan_admin/backend/api/v1/schemas.py
Normal file
@@ -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)
|
||||||
2
src/govoplan_admin/backend/db/__init__.py
Normal file
2
src/govoplan_admin/backend/db/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Admin-owned SQLAlchemy metadata and models."""
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
BIN
src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc
Normal file
BIN
src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc
Normal file
BIN
src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
39
src/govoplan_admin/backend/db/models.py
Normal file
39
src/govoplan_admin/backend/db/models.py
Normal file
@@ -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"]
|
||||||
|
|
||||||
169
src/govoplan_admin/backend/governance.py
Normal file
169
src/govoplan_admin/backend/governance.py
Normal file
@@ -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)
|
||||||
34
src/govoplan_admin/backend/manifest.py
Normal file
34
src/govoplan_admin/backend/manifest.py
Normal file
@@ -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
|
||||||
1
src/govoplan_admin/py.typed
Normal file
1
src/govoplan_admin/py.typed
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
30
webui/package.json
Normal file
30
webui/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
403
webui/src/api/admin.ts
Normal file
403
webui/src/api/admin.ts
Normal file
@@ -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" });
|
||||||
|
}
|
||||||
93
webui/src/features/admin/AdminOverviewPanel.tsx
Normal file
93
webui/src/features/admin/AdminOverviewPanel.tsx
Normal file
@@ -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>;
|
||||||
|
}
|
||||||
229
webui/src/features/admin/GovernanceTemplatesPanel.tsx
Normal file
229
webui/src/features/admin/GovernanceTemplatesPanel.tsx
Normal file
@@ -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()} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
638
webui/src/features/admin/ModuleManagementPanel.tsx
Normal file
638
webui/src/features/admin/ModuleManagementPanel.tsx
Normal file
@@ -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);
|
||||||
|
}
|
||||||
120
webui/src/features/admin/SystemSettingsPanel.tsx
Normal file
120
webui/src/features/admin/SystemSettingsPanel.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
webui/src/index.ts
Normal file
7
webui/src/index.ts
Normal file
@@ -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";
|
||||||
87
webui/src/module.ts
Normal file
87
webui/src/module.ts
Normal file
@@ -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