22 Commits
Author SHA1 Message Date
zemion 57e2a34c89 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:18:08 +02:00
zemion e93630ab87 Make package publication retries hash-safe 2026-08-04 14:32:18 +02:00
zemion 06a0c0d26c Harden module package publication 2026-08-04 14:02:39 +02:00
zemion e890a90d08 Invalidate tenant entitlements after module changes 2026-08-04 09:29:35 +02:00
zemion 60e2676809 Add tenant module entitlement administration 2026-08-04 08:21:49 +02:00
zemion b71523e364 Add protected package release workflow 2026-08-04 04:14:02 +02:00
zemion d428f3390a Complete Admin interface patterns 2026-08-03 11:30:01 +02:00
zemion 6dbccd5e08 Guide module installer lifecycle 2026-08-03 08:44:51 +02:00
zemion fef4e10afd docs: declare institutional architecture boundary 2026-08-01 17:48:22 +02:00
zemion 8ee12c9aa8 Align WebUI runtime peer dependencies 2026-07-31 02:48:56 +02:00
zemion f245603077 feat: classify admin module settings surfaces 2026-07-30 17:42:06 +02:00
zemion f3e69b97ee feat: add governed configuration reference selectors 2026-07-29 14:16:28 +02:00
zemion 36291a57c1 Harmonize module metadata and expose View surfaces 2026-07-28 21:04:54 +02:00
zemion e8a3e1c18f refactor(webui): use Core date-time formatting 2026-07-21 13:52:59 +02:00
zemion 158b59dfb1 fix(webui): require Core 0.1.9 for table actions 2026-07-21 13:46:20 +02:00
zemion 0fcd4dc06f Use the central admin metric card 2026-07-21 13:19:20 +02:00
zemion e8cb9d4fb2 Refactor module catalog plan construction 2026-07-21 12:54:11 +02:00
zemion fcf93f438d refactor(webui): use central language selection 2026-07-21 12:34:39 +02:00
zemion f0ff4ee51d refactor(webui): use central data grids and actions 2026-07-21 12:01:23 +02:00
zemion c9e1fb287f intermittent commit 2026-07-14 13:22:10 +02:00
zemion 11ecf362a3 Release v0.1.8 2026-07-11 16:49:00 +02:00
zemion 24edb7eb8a Release v0.1.7 2026-07-11 02:34:55 +02:00
29 changed files with 3483 additions and 491 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Admin Codex Guide
## Scope
This repository owns generic administration sections, governance templates, configuration packages, and operator-facing module lifecycle controls.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Admin internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- Consume module metadata and extension points; do not import optional module internals.
- Keep package mutation in the trusted installer process rather than request handlers.
+52
View File
@@ -1,5 +1,13 @@
# GovOPlaN Admin # GovOPlaN Admin
The Admin-owned workspace sections, lifecycle stages, consequence classes,
contextual-help contract, and verification evidence are recorded in
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-admin` owns generic system administration API and WebUI contributions `govoplan-admin` owns generic system administration API and WebUI contributions
during the GovOPlaN module split. during the GovOPlaN module split.
@@ -43,3 +51,47 @@ Package mutation is intentionally not executed inside the FastAPI request. The
admin UI records operator intent and queues or renders commands for the trusted admin UI records operator intent and queues or renders commands for the trusted
installer process described in installer process described in
`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`. `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`.
The WebUI presents the lifecycle as five derived stages: plan, preflight,
installer request, daemon execution, and run evidence. The projection resets
when the saved plan changes and associates evidence only with an installer
request created at or after the current plan revision. It therefore cannot make
an old successful run look like evidence for a new plan. The earliest queue
blocker is shown through Core's actionable blocker pattern with the required
action, responsible operator or administrator, and destination. Contextual help
uses the stable `admin.module-lifecycle-workflow` documentation topic.
## Tenant Module Entitlements
Deployment lifecycle and tenant availability are separate administration
workflows. **System > Tenant modules** lets a system administrator select a
tenant, mark installed modules unavailable, available, or forced, and set the
tenant's current selection. **Tenant > Modules** lets an account with the
`admin:module:write` permission change only the available selection. The
`module_admin` role template grants the narrow read/write pair for that task.
Core closes required dependencies, retains protected administration modules,
uses an optimistic entitlement revision, and records audit and governed
configuration evidence. A selected module that is not globally active remains
configured but unavailable at runtime. Module selection never grants module
permissions.
User and group module visibility is configured through Views, where each WebUI
module is represented by its root module surface. This keeps tenant operational
state distinct from presentation preferences.
## Package Surfaces
The admin UI intentionally exposes two different package concepts:
- **Configuration packages** are import/export bundles for module-owned
configuration data. They support dry-run diagnostics, approval, apply, and
export workflows without installing Python or WebUI packages.
- **Module package catalog** and **operator install plan** live under
**Modules**. They describe approved release artifacts, install/update/remove
plans, preflight blockers, maintenance-mode requirements, installer-daemon
requests, and rollback visibility.
These surfaces should stay separate in navigation and copy. If future UI work
combines them visually, it must still preserve the operator distinction between
configuration mutation and package installation.
+41
View File
@@ -0,0 +1,41 @@
# Admin interface pattern migration
This document records the interface-pattern coverage for the surfaces
contributed by `govoplan-admin`. The Access module hosts the administration
tree; Admin supplies only its declared sections through `admin.sections`.
## Surface inventory
| Surface | Archetype | Authority and state model |
| --- | --- | --- |
| Administration overview | Navigation dashboard | Counts are server projections. Links exist only for sections already admitted by capabilities, permissions, and the active View. |
| System settings | Adaptive configuration | A local draft is compared with the saved server state. Reload preserves an unsaved draft, Save requires system write authority, and maintenance controls also require maintenance authority. |
| Configuration changes | Governed work queue and evidence list | Open approval requests and immutable applied history are separate server-authoritative grids. Approval requires an explicit confirmation. |
| Configuration packages | Guided preflight/apply/export workspace | JSON input is validated locally, dry-run evidence is shown separately, approval is optional where policy allows it, and application is explicitly confirmed. Reference selectors replace raw IDs unless historical manual mode is selected deliberately. |
| Role and group templates | Governed definition directory | Definitions, tenant availability, and role permissions are edited in one draft. Deletion is confirmed and remains blocked by materialized dependencies in the backend. |
| Module management | Guided lifecycle workflow and operations evidence | Desired runtime state, package plan, preflight, maintenance gate, daemon request, and durable run evidence are distinct stages. Disabled controls name the earliest blocker. |
## Consequence classes
- Overview navigation, reload, filtering, inspecting evidence, dry runs, and
exports are reversible.
- Settings, templates, desired module state, package plans, and approval
requests are governed mutations with permission and validation reasons.
- Approving a change, applying a configuration package, enabling maintenance,
clearing a saved module plan, cancelling an installer request, and deleting
a governance template require shared confirmation surfaces.
- Actual package mutation remains outside the API process and is performed by
the supervised installer. Run and rollback evidence remains durable.
## Shared controls and verification
Admin uses Core `AdminPageLayout`, `DataGrid`, `ReferenceSelect`, `StageRail`,
`Dialog`, `ConfirmDialog`, `TableActionGroup`, `ActionBlockerHint`,
`DocumentationHelpLink`, `ToggleSwitch`, and status/alert primitives. This
inherits Core focus restoration, keyboard order, responsive overflow,
disabled-action tooltips, and accessible dialog semantics.
The focused WebUI check rejects browser-native confirmation calls, private
sibling-module imports, untranslated Admin-owned structural headings, missing
contextual help, and missing disabled reasons on consequential controls. The
manifest regression test fixes the help-context and surface declaration.
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/admin-webui", "name": "@govoplan/admin-webui",
"version": "0.1.6", "version": "0.1.15",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,11 +18,11 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.15",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1" "react-router": ">=8.3.0 <9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
+3 -3
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-admin" name = "govoplan-admin"
version = "0.1.6" version = "0.1.15"
description = "GovOPlaN generic administration module." description = "GovOPlaN generic administration module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.6", "govoplan-core>=0.1.15",
"govoplan-access>=0.1.6", "govoplan-access>=0.1.15",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
+613 -69
View File
@@ -31,6 +31,7 @@ from govoplan_core.core.configuration_control import (
) )
from govoplan_core.core.module_management import ( from govoplan_core.core.module_management import (
PROTECTED_MODULES, PROTECTED_MODULES,
ModuleInstallPlan,
ModuleInstallPlanItem, ModuleInstallPlanItem,
ModuleManagementError, ModuleManagementError,
configured_enabled_modules, configured_enabled_modules,
@@ -43,12 +44,21 @@ from govoplan_core.core.module_management import (
saved_desired_enabled_modules, saved_desired_enabled_modules,
saved_module_install_plan, saved_module_install_plan,
) )
from govoplan_core.core.module_entitlements import (
ModuleEntitlementConflict,
ModuleEntitlementError,
module_entitlement_payload,
tenant_module_entitlement_state,
update_system_tenant_module_policy,
update_tenant_module_selection,
)
from govoplan_core.core.module_installer import ( from govoplan_core.core.module_installer import (
ModuleInstallerError, ModuleInstallerError,
cancel_module_installer_request, cancel_module_installer_request,
default_installer_runtime_dir, default_installer_runtime_dir,
list_module_installer_runs, list_module_installer_runs,
list_module_installer_requests, list_module_installer_requests,
module_install_catalog_companion_module_ids,
module_installer_daemon_status, module_installer_daemon_status,
module_install_preflight, module_install_preflight,
module_installer_lock_status, module_installer_lock_status,
@@ -57,6 +67,12 @@ from govoplan_core.core.module_installer import (
read_module_installer_request, read_module_installer_request,
retry_module_installer_request, retry_module_installer_request,
) )
from govoplan_core.core.module_installer_notifications import (
emit_module_installer_notification,
installer_notification_body,
installer_notification_priority,
installer_notification_subject,
)
from govoplan_core.core.module_license import module_license_decision, module_license_diagnostics from govoplan_core.core.module_license import module_license_decision, module_license_diagnostics
from govoplan_core.core.module_package_catalog import record_module_package_catalog_acceptance, validate_module_package_catalog 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.maintenance import MAINTENANCE_ACCESS_SCOPE, MaintenanceMode, saved_maintenance_mode, save_maintenance_mode
@@ -102,6 +118,10 @@ from .schemas import (
ModulePackageCatalogResponse, ModulePackageCatalogResponse,
ModuleLicenseDiagnostics, ModuleLicenseDiagnostics,
ModuleStateUpdateRequest, ModuleStateUpdateRequest,
SystemTenantModulePolicyUpdateRequest,
TenantModuleEntitlementResponse,
TenantModuleSelectionUpdateRequest,
TenantModuleTargetListResponse,
PrivacyRetentionPolicyItem, PrivacyRetentionPolicyItem,
SystemSettingsDeltaResponse, SystemSettingsDeltaResponse,
SystemSettingsItem, SystemSettingsItem,
@@ -115,6 +135,8 @@ SYSTEM_SETTINGS_COLLECTION = "admin.system_settings"
SYSTEM_SETTINGS_RESOURCE = "system_settings_section" SYSTEM_SETTINGS_RESOURCE = "system_settings_section"
GOVERNANCE_TEMPLATES_COLLECTION = "admin.governance_templates" GOVERNANCE_TEMPLATES_COLLECTION = "admin.governance_templates"
GOVERNANCE_TEMPLATE_RESOURCE = "governance_template" GOVERNANCE_TEMPLATE_RESOURCE = "governance_template"
GOVERNANCE_TEMPLATES_FULL_CURSOR_SCOPE = "governance-templates"
ADMIN_FULL_CURSOR_PREFIX = "full:"
INSTALLER_RUNS_CURSOR_SCOPE = "admin.installer.runs.v1" INSTALLER_RUNS_CURSOR_SCOPE = "admin.installer.runs.v1"
INSTALLER_REQUESTS_CURSOR_SCOPE = "admin.installer.requests.v1" INSTALLER_REQUESTS_CURSOR_SCOPE = "admin.installer.requests.v1"
DEFAULT_INSTALLER_HISTORY_PAGE_SIZE = 25 DEFAULT_INSTALLER_HISTORY_PAGE_SIZE = 25
@@ -145,6 +167,11 @@ def _request_registry(request: Request) -> PlatformRegistry:
return registry return registry
def _optional_request_registry(request: Request) -> PlatformRegistry | None:
registry = getattr(request.app.state, "govoplan_registry", None)
return registry if isinstance(registry, PlatformRegistry) else None
def _request_lifecycle(request: Request) -> ModuleLifecycleManager: def _request_lifecycle(request: Request) -> ModuleLifecycleManager:
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None) lifecycle = getattr(request.app.state, "govoplan_lifecycle", None)
if not isinstance(lifecycle, ModuleLifecycleManager): if not isinstance(lifecycle, ModuleLifecycleManager):
@@ -152,6 +179,29 @@ def _request_lifecycle(request: Request) -> ModuleLifecycleManager:
return lifecycle return lifecycle
def _emit_installer_request_notification(
*,
session: Session,
registry: PlatformRegistry | None,
tenant_id: str | None,
request: dict[str, object],
event_kind: str,
recipient_id: str | None = None,
) -> None:
status_value = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
emit_module_installer_notification(
session=session,
registry=registry,
tenant_id=tenant_id,
request=request,
event_kind=event_kind,
subject=installer_notification_subject(event_kind, request),
body_text=installer_notification_body(event_kind, request),
recipient_id=recipient_id,
priority=installer_notification_priority(status_value),
)
def _http_admin_error(exc: Exception) -> HTTPException: def _http_admin_error(exc: Exception) -> HTTPException:
if isinstance(exc, AdminConflictError): if isinstance(exc, AdminConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
@@ -286,10 +336,16 @@ def _record_system_settings_section_changes(
) )
def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem: def _governance_template_item(
assignments = session.query(GovernanceTemplateAssignment).filter( session: Session,
GovernanceTemplateAssignment.template_id == item.id item: GovernanceTemplate,
).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all() *,
assignments: list[GovernanceTemplateAssignment] | None = None,
) -> GovernanceTemplateItem:
if assignments is None:
assignments = session.query(GovernanceTemplateAssignment).filter(
GovernanceTemplateAssignment.template_id == item.id
).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all()
return GovernanceTemplateItem( return GovernanceTemplateItem(
id=item.id, id=item.id,
kind=item.kind, kind=item.kind,
@@ -305,6 +361,77 @@ def _governance_template_item(session: Session, item: GovernanceTemplate) -> Gov
) )
def _governance_template_items(
session: Session,
items: list[GovernanceTemplate],
) -> list[GovernanceTemplateItem]:
if not items:
return []
assignments_by_template: dict[str, list[GovernanceTemplateAssignment]] = {
item.id: [] for item in items
}
assignments = (
session.query(GovernanceTemplateAssignment)
.filter(GovernanceTemplateAssignment.template_id.in_(assignments_by_template))
.order_by(
GovernanceTemplateAssignment.template_id.asc(),
GovernanceTemplateAssignment.tenant_id.asc(),
)
.all()
)
for assignment in assignments:
assignments_by_template[assignment.template_id].append(assignment)
return [
_governance_template_item(
session,
item,
assignments=assignments_by_template[item.id],
)
for item in items
]
def _governance_template_page(query, *, page: int, page_size: int):
total = query.order_by(None).count()
pages = max(1, (total + page_size - 1) // page_size)
items = query.offset((page - 1) * page_size).limit(page_size).all()
return items, {
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
def _encode_admin_full_cursor(scope: str, *, page: int, snapshot_sequence: int) -> str:
return f"{ADMIN_FULL_CURSOR_PREFIX}{scope}:{page}:{snapshot_sequence}"
def _decode_admin_full_cursor(value: str | None, *, scope: str) -> tuple[int, int] | None:
if not value or not value.startswith(ADMIN_FULL_CURSOR_PREFIX):
return None
parts = value.split(":", 3)
if len(parts) != 4 or parts[1] != scope:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid full snapshot cursor",
)
try:
page = int(parts[2])
snapshot_sequence = int(parts[3])
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid full snapshot cursor",
) from exc
if page < 1 or snapshot_sequence < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid full snapshot cursor",
)
return page, snapshot_sequence
def _record_governance_template_change(session: Session, *, item: GovernanceTemplate, operation: str, principal: ApiPrincipal) -> None: def _record_governance_template_change(session: Session, *, item: GovernanceTemplate, operation: str, principal: ApiPrincipal) -> None:
record_change( record_change(
session, session,
@@ -338,6 +465,11 @@ def _module_catalog_response(session: Session, request: Request, *, notes: list[
current_set = set(current_enabled) current_set = set(current_enabled)
desired_set = set(desired_enabled) desired_set = set(desired_enabled)
protected_set = set(PROTECTED_MODULES) protected_set = set(PROTECTED_MODULES)
live_apply_enabled = (
lifecycle.live_apply_enabled()
if isinstance(lifecycle, ModuleLifecycleManager)
else False
)
items: list[ModuleCatalogItem] = [] items: list[ModuleCatalogItem] = []
for module_id, manifest in sorted(available.items(), key=lambda item: item[0]): for module_id, manifest in sorted(available.items(), key=lambda item: item[0]):
migration = manifest.migration_spec migration = manifest.migration_spec
@@ -361,7 +493,7 @@ def _module_catalog_response(session: Session, request: Request, *, notes: list[
frontend_package=frontend.package_name if frontend else None, frontend_package=frontend.package_name if frontend else None,
migration_module_id=migration.module_id if migration else None, migration_module_id=migration.module_id if migration else None,
migration_script_location=migration.script_location if migration else None, migration_script_location=migration.script_location if migration else None,
runtime_toggle_supported=True, runtime_toggle_supported=live_apply_enabled,
install_uninstall_supported=manifest.id not in protected_set, install_uninstall_supported=manifest.id not in protected_set,
)) ))
restart_required = current_set != desired_set restart_required = current_set != desired_set
@@ -373,15 +505,22 @@ def _module_catalog_response(session: Session, request: Request, *, notes: list[
configured_enabled=configured_enabled, configured_enabled=configured_enabled,
protected_modules=list(PROTECTED_MODULES), protected_modules=list(PROTECTED_MODULES),
restart_required=restart_required, restart_required=restart_required,
runtime_toggle_supported=True, runtime_toggle_supported=live_apply_enabled,
install_uninstall_supported=True, install_uninstall_supported=True,
install_plan_supported=True, install_plan_supported=True,
package_mutation_supported=False, package_mutation_supported=False,
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()), maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
notes=notes or [ 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.", "Enable/disable changes are applied to this development 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.",
]
if live_apply_enabled
else [
"Enable/disable changes are saved as startup state. Restart all API and worker processes to apply them consistently.",
"Installing or uninstalling Python/WebUI packages is planned here and applied by the separate installer daemon during maintenance mode.",
]
),
) )
@@ -429,55 +568,96 @@ def _webui_root() -> Path | None:
def _upsert_install_plan_item(session: Session, item: ModuleInstallPlanItem): def _upsert_install_plan_item(session: Session, item: ModuleInstallPlanItem):
return _upsert_install_plan_items(session, [item])
def _upsert_install_plan_items(session: Session, items: list[ModuleInstallPlanItem]):
existing = saved_module_install_plan(session) existing = saved_module_install_plan(session)
replacement_ids = {item.module_id for item in items if item.status == "planned"}
retained = [ retained = [
current current
for current in existing.items for current in existing.items
if not (current.status == "planned" and current.module_id == item.module_id) if not (current.status == "planned" and current.module_id in replacement_ids)
] ]
return save_module_install_plan(session, [*retained, item]) return save_module_install_plan(session, [*retained, *items])
def _catalog_plan_item(module_id: str, available_module_ids: set[str]) -> tuple[ModuleInstallPlanItem, dict[str, object]]: def _catalog_plan_item(
result = validate_module_package_catalog() module_id: str,
available_module_ids: set[str],
*,
validation: dict[str, object] | None = None,
) -> tuple[ModuleInstallPlanItem, dict[str, object]]:
result = validation or validate_module_package_catalog()
_require_valid_module_catalog(result)
raw_item = _catalog_install_or_update_item(result, module_id)
action = _catalog_plan_action(raw_item, module_id, available_module_ids)
license_decision = module_license_decision(_catalog_license_features(raw_item))
_require_catalog_action_license(license_decision, action=action, module_id=module_id)
return ModuleInstallPlanItem(
module_id=str(raw_item["module_id"]),
action=action,
source="catalog",
catalog=_catalog_plan_metadata(result),
python_package=_catalog_string(raw_item, "python_package"),
python_ref=_catalog_string(raw_item, "python_ref"),
webui_package=_catalog_string(raw_item, "webui_package"),
webui_ref=_catalog_string(raw_item, "webui_ref"),
notes=_catalog_plan_notes(raw_item, license_decision),
), result
def _require_valid_module_catalog(result: dict[str, object]) -> None:
if not result.get("valid"): if not result.get("valid"):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(result.get("error") or "Module package catalog is invalid."), detail=str(result.get("error") or "Module package catalog is invalid."),
) )
def _catalog_install_or_update_item(result: dict[str, object], module_id: str) -> dict[str, object]:
for raw_item in result.get("modules", []): for raw_item in result.get("modules", []):
if not isinstance(raw_item, dict): if not isinstance(raw_item, dict):
continue continue
if raw_item.get("module_id") != module_id or raw_item.get("action") not in {"install", "update"}: if raw_item.get("module_id") != module_id or raw_item.get("action") not in {"install", "update"}:
continue continue
raw_action = raw_item.get("action") return raw_item
action = "update" if raw_action == "update" or module_id in available_module_ids else "install"
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 {action} for {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=action,
source="catalog",
catalog=_catalog_plan_metadata(result),
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/update entry not found: {module_id}") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install/update entry not found: {module_id}")
def _catalog_plan_action(raw_item: dict[str, object], module_id: str, available_module_ids: set[str]) -> str:
return "update" if raw_item.get("action") == "update" or module_id in available_module_ids else "install"
def _require_catalog_action_license(
license_decision: dict[str, object],
*,
action: str,
module_id: str,
) -> None:
if license_decision.get("allowed"):
return
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 {action} for {module_id}: {missing_text}.",
)
def _catalog_plan_notes(raw_item: dict[str, object], license_decision: dict[str, object]) -> str | None:
notes = _catalog_string(raw_item, "notes")
if not (license_decision.get("reason") and license_decision.get("missing_features")):
return notes
prefix = f"{notes}\n" if notes else ""
return f"{prefix}License warning: {license_decision['reason']}"
def _catalog_string(raw_item: dict[str, object], field: str) -> str | None:
value = raw_item.get(field)
return value if isinstance(value, str) else None
def _catalog_plan_metadata(validation: dict[str, object]) -> dict[str, object]: def _catalog_plan_metadata(validation: dict[str, object]) -> dict[str, object]:
metadata: dict[str, object] = { metadata: dict[str, object] = {
"source": validation.get("source") or validation.get("path"), "source": validation.get("source") or validation.get("path"),
@@ -531,7 +711,7 @@ def _catalog_required_license_features(result: dict[str, object]) -> list[str]:
def admin_overview( def admin_overview(
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope( principal: ApiPrincipal = Depends(require_any_scope(
"admin:users:read", "admin:groups:read", "admin:roles:read", "admin:settings:read", "admin:users:read", "admin:groups:read", "admin:roles:read", "admin:settings:read", "admin:module:read", "admin:module:write",
"admin:api_keys:read", "system:tenants:read", "system:accounts:read", "system:roles:read", "system:access: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", "system:settings:read", "system:governance:read", "system:audit:read",
)), )),
@@ -539,7 +719,7 @@ def admin_overview(
tenant = session.get(Tenant, principal.tenant_id) tenant = session.get(Tenant, principal.tenant_id)
if tenant is None: if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
counts = tenant_counts(session, tenant.id) counts = tenant_counts(session, tenant.id, module_ids=())
access_admin = _access_administration() access_admin = _access_administration()
capabilities = [item.scope for item in ALL_PERMISSIONS if has_scope(principal, item.scope)] capabilities = [item.scope for item in ALL_PERMISSIONS if has_scope(principal, item.scope)]
return AdminOverviewResponse( return AdminOverviewResponse(
@@ -553,7 +733,7 @@ def admin_overview(
active_user_count=counts["active_users"], active_user_count=counts["active_users"],
group_count=counts["groups"], group_count=counts["groups"],
role_count=access_admin.role_count_for_tenant(session, tenant.id), 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), active_api_key_count=counts["active_api_keys"],
capabilities=capabilities, capabilities=capabilities,
) )
@@ -568,6 +748,253 @@ def list_system_modules(
return _module_catalog_response(session, request) return _module_catalog_response(session, request)
def _tenant_or_404(
session: Session,
tenant_id: str,
*,
for_update: bool = False,
) -> Tenant:
query = session.query(Tenant).filter(Tenant.id == tenant_id)
if for_update:
query = query.populate_existing().with_for_update()
tenant = query.one_or_none()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
return tenant
def _tenant_module_entitlement_response(
request: Request,
tenant: Tenant,
) -> TenantModuleEntitlementResponse:
lifecycle = _request_lifecycle(request)
available = dict(lifecycle.available_modules)
runtime_active = lifecycle.active_module_ids()
state = tenant_module_entitlement_state(
tenant.settings or {},
available,
runtime_active_modules=runtime_active,
)
return TenantModuleEntitlementResponse.model_validate(
module_entitlement_payload(tenant.id, state)
)
@router.get(
"/system/tenant-module-targets",
response_model=TenantModuleTargetListResponse,
)
def list_tenant_module_targets(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
tenants = session.query(Tenant).order_by(Tenant.name.asc(), Tenant.id.asc()).all()
return TenantModuleTargetListResponse(
tenants=[
{
"id": tenant.id,
"slug": tenant.slug,
"name": tenant.name,
"is_active": tenant.is_active,
}
for tenant in tenants
]
)
@router.get(
"/system/tenants/{tenant_id}/modules",
response_model=TenantModuleEntitlementResponse,
)
def read_system_tenant_modules(
tenant_id: str,
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
return _tenant_module_entitlement_response(
request,
_tenant_or_404(session, tenant_id),
)
@router.put(
"/system/tenants/{tenant_id}/modules",
response_model=TenantModuleEntitlementResponse,
)
def update_system_tenant_modules(
tenant_id: str,
request: Request,
payload: SystemTenantModulePolicyUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
tenant = _tenant_or_404(session, tenant_id, for_update=True)
lifecycle = _request_lifecycle(request)
available = dict(lifecycle.available_modules)
runtime_active = lifecycle.active_module_ids()
before = tenant_module_entitlement_state(
tenant.settings or {},
available,
runtime_active_modules=runtime_active,
)
policy_value = {
"available_modules": list(payload.available_modules),
"forced_modules": list(payload.forced_modules),
"enabled_modules": list(payload.enabled_modules),
"expected_revision": payload.expected_revision,
}
try:
approval = ensure_configuration_change_allowed(
session,
key="module_entitlements.system_policy",
value=policy_value,
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=payload.change_request_id,
target={"scope": "tenant", "tenant_id": tenant.id},
)
updated_settings, state = update_system_tenant_module_policy(
tenant.settings or {},
available,
available_modules=payload.available_modules,
forced_modules=payload.forced_modules,
enabled_modules=payload.enabled_modules,
expected_revision=payload.expected_revision,
runtime_active_modules=runtime_active,
)
except ConfigurationControlError as exc:
session.rollback()
raise _configuration_control_http_error(exc) from exc
except ModuleEntitlementConflict as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ModuleEntitlementError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
tenant.settings = updated_settings
session.add(tenant)
audit_from_principal(
session,
principal,
action="tenant_modules.system_policy_updated",
scope="system",
object_type="tenant_module_entitlement",
object_id=tenant.id,
details=audit_operation_context(
outcome="saved",
tenant_id=tenant.id,
revision=state.revision,
available_modules=list(state.available_modules),
forced_modules=list(state.forced_modules),
selected_modules=list(state.selected_modules),
effective_modules=list(state.effective_modules),
),
)
record_configuration_change_applied(
session,
key="module_entitlements.system_policy",
before_value=module_entitlement_payload(tenant.id, before),
after_value=module_entitlement_payload(tenant.id, state),
actor_user_id=principal.user.id,
approval=approval,
target={"scope": "tenant", "tenant_id": tenant.id},
audit_event="tenant_modules.system_policy_updated",
)
session.commit()
lifecycle.registry.invalidate_tenant_entitlement(tenant.id)
return TenantModuleEntitlementResponse.model_validate(
module_entitlement_payload(tenant.id, state)
)
@router.get(
"/tenant/modules",
response_model=TenantModuleEntitlementResponse,
)
def read_tenant_modules(
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope(
"admin:module:read",
"admin:module:write",
"system:settings:read",
)
),
):
return _tenant_module_entitlement_response(
request,
_tenant_or_404(session, principal.tenant_id),
)
@router.put(
"/tenant/modules",
response_model=TenantModuleEntitlementResponse,
)
def update_tenant_modules(
request: Request,
payload: TenantModuleSelectionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope("admin:module:write", "system:settings:write")
),
):
tenant = _tenant_or_404(session, principal.tenant_id, for_update=True)
lifecycle = _request_lifecycle(request)
available = dict(lifecycle.available_modules)
runtime_active = lifecycle.active_module_ids()
try:
updated_settings, state = update_tenant_module_selection(
tenant.settings or {},
available,
enabled_modules=payload.enabled_modules,
expected_revision=payload.expected_revision,
runtime_active_modules=runtime_active,
)
except ModuleEntitlementConflict as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ModuleEntitlementError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
tenant.settings = updated_settings
session.add(tenant)
audit_from_principal(
session,
principal,
action="tenant_modules.selection_updated",
scope="tenant",
object_type="tenant_module_entitlement",
object_id=tenant.id,
details=audit_operation_context(
outcome="saved",
tenant_id=tenant.id,
revision=state.revision,
selected_modules=list(state.selected_modules),
effective_modules=list(state.effective_modules),
),
)
session.commit()
lifecycle.registry.invalidate_tenant_entitlement(tenant.id)
return TenantModuleEntitlementResponse.model_validate(
module_entitlement_payload(tenant.id, state)
)
@router.get("/system/modules/install-plan", response_model=ModuleInstallPlanResponse) @router.get("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
def read_module_install_plan( def read_module_install_plan(
request: Request, request: Request,
@@ -651,6 +1078,7 @@ def read_module_install_request_detail(
@router.post("/system/modules/install-requests", response_model=ModuleInstallerRequestItem) @router.post("/system/modules/install-requests", response_model=ModuleInstallerRequestItem)
def create_module_install_request( def create_module_install_request(
payload: ModuleInstallerRequestCreateRequest, payload: ModuleInstallerRequestCreateRequest,
request_context: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")), principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
): ):
@@ -663,8 +1091,17 @@ def create_module_install_request(
request = queue_module_installer_request( request = queue_module_installer_request(
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
requested_by=principal.user.id, requested_by=principal.user.id,
tenant_id=principal.tenant_id,
options=payload.options.model_dump(exclude_none=True), options=payload.options.model_dump(exclude_none=True),
) )
_emit_installer_request_notification(
session=session,
registry=_optional_request_registry(request_context),
tenant_id=principal.tenant_id,
request=request,
event_kind="module_installer.request.queued",
recipient_id=principal.user.id,
)
audit_from_principal( audit_from_principal(
session, session,
principal, principal,
@@ -686,6 +1123,7 @@ def create_module_install_request(
@router.post("/system/modules/install-requests/{request_id}/cancel", response_model=ModuleInstallerRequestItem) @router.post("/system/modules/install-requests/{request_id}/cancel", response_model=ModuleInstallerRequestItem)
def cancel_module_install_request( def cancel_module_install_request(
request_id: str, request_id: str,
request_context: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")), principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
): ):
@@ -703,6 +1141,14 @@ def cancel_module_install_request(
) )
except ModuleInstallerError as exc: except ModuleInstallerError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
_emit_installer_request_notification(
session=session,
registry=_optional_request_registry(request_context),
tenant_id=str(request.get("tenant_id") or principal.tenant_id),
request=request,
event_kind="module_installer.request.cancelled",
recipient_id=str(request.get("requested_by") or principal.user.id),
)
audit_from_principal( audit_from_principal(
session, session,
principal, principal,
@@ -724,6 +1170,7 @@ def cancel_module_install_request(
@router.post("/system/modules/install-requests/{request_id}/retry", response_model=ModuleInstallerRequestItem) @router.post("/system/modules/install-requests/{request_id}/retry", response_model=ModuleInstallerRequestItem)
def retry_module_install_request( def retry_module_install_request(
request_id: str, request_id: str,
request_context: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")), principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
): ):
@@ -741,6 +1188,14 @@ def retry_module_install_request(
) )
except ModuleInstallerError as exc: except ModuleInstallerError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
_emit_installer_request_notification(
session=session,
registry=_optional_request_registry(request_context),
tenant_id=str(request.get("tenant_id") or principal.tenant_id),
request=request,
event_kind="module_installer.request.queued",
recipient_id=principal.user.id,
)
audit_from_principal( audit_from_principal(
session, session,
principal, principal,
@@ -801,7 +1256,19 @@ def plan_module_install_from_catalog(
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None) 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) available = dict(lifecycle.available_modules) if isinstance(lifecycle, ModuleLifecycleManager) else available_module_manifests(ignore_load_errors=True)
item, validation = _catalog_plan_item(module_id, set(available)) item, validation = _catalog_plan_item(module_id, set(available))
plan = _upsert_install_plan_item(session, item) 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)
]
candidate_plan = ModuleInstallPlan(items=tuple([*retained, item]))
companion_ids = module_install_catalog_companion_module_ids(candidate_plan, available, validation=validation)
companion_items = [
_catalog_plan_item(companion_id, set(available), validation=validation)[0]
for companion_id in companion_ids
]
plan = _upsert_install_plan_items(session, [item, *companion_items])
record_module_package_catalog_acceptance(validation) record_module_package_catalog_acceptance(validation)
except ModuleManagementError as exc: except ModuleManagementError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@@ -816,6 +1283,7 @@ def plan_module_install_from_catalog(
module_id=module_id, module_id=module_id,
outcome="planned", outcome="planned",
items=[item.as_dict() for item in plan.items], items=[item.as_dict() for item in plan.items],
companion_module_ids=list(companion_ids),
catalog_validation={ catalog_validation={
"valid": validation.get("valid"), "valid": validation.get("valid"),
"channel": validation.get("channel"), "channel": validation.get("channel"),
@@ -825,7 +1293,10 @@ def plan_module_install_from_catalog(
) )
session.commit() session.commit()
planned_action = "update" if item.action == "update" else "install" planned_action = "update" if item.action == "update" else "install"
return _module_install_plan_response(session, request, notes=[f"Catalog {planned_action} entry planned for {module_id}."]) notes = [f"Catalog {planned_action} entry planned for {module_id}."]
if companion_ids:
notes.append("Required companion catalog entries added: " + ", ".join(companion_ids))
return _module_install_plan_response(session, request, notes=notes)
@router.post("/system/modules/{module_id}/uninstall-plan", response_model=ModuleInstallPlanResponse) @router.post("/system/modules/{module_id}/uninstall-plan", response_model=ModuleInstallPlanResponse)
@@ -893,11 +1364,23 @@ def update_system_modules(
plan = plan_desired_enabled_modules(payload.enabled_modules, available) plan = plan_desired_enabled_modules(payload.enabled_modules, available)
except ModuleManagementError as exc: except ModuleManagementError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
try: live_apply_enabled = lifecycle.live_apply_enabled()
result = lifecycle.apply_enabled_modules(plan.enabled_modules, protected_modules=PROTECTED_MODULES) result = None
except Exception as exc: if live_apply_enabled:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc try:
desired = save_desired_enabled_modules(session, result.enabled_modules) result = lifecycle.apply_enabled_modules(
plan.enabled_modules,
protected_modules=PROTECTED_MODULES,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
desired = save_desired_enabled_modules(session, plan.enabled_modules)
activated = list(result.activated_modules) if result is not None else []
deactivated = list(result.deactivated_modules) if result is not None else []
mounted = list(result.mounted_modules) if result is not None else []
audit_from_principal( audit_from_principal(
session, session,
principal, principal,
@@ -906,12 +1389,12 @@ def update_system_modules(
object_type="module_state", object_type="module_state",
object_id="global", object_id="global",
details=audit_operation_context( details=audit_operation_context(
outcome="applied", outcome="applied" if live_apply_enabled else "restart_required",
desired_enabled=list(desired), desired_enabled=list(desired),
added_dependencies=list(plan.added_dependencies), added_dependencies=list(plan.added_dependencies),
activated=list(result.activated_modules), activated=activated,
deactivated=list(result.deactivated_modules), deactivated=deactivated,
mounted=list(result.mounted_modules), mounted=mounted,
), ),
) )
record_configuration_change_applied( record_configuration_change_applied(
@@ -925,13 +1408,19 @@ def update_system_modules(
audit_event="module_management.updated", audit_event="module_management.updated",
) )
session.commit() session.commit()
notes = ["Module state saved and applied to the running server."] notes = [
(
"Module state saved and applied to the running development server."
if live_apply_enabled
else "Module state saved. Restart all API and worker processes to apply it consistently."
)
]
if plan.added_dependencies: if plan.added_dependencies:
notes.append("Required dependencies added automatically: " + ", ".join(plan.added_dependencies)) notes.append("Required dependencies added automatically: " + ", ".join(plan.added_dependencies))
if result.activated_modules: if activated:
notes.append("Activated: " + ", ".join(result.activated_modules)) notes.append("Activated: " + ", ".join(activated))
if result.deactivated_modules: if deactivated:
notes.append("Deactivated: " + ", ".join(result.deactivated_modules)) notes.append("Deactivated: " + ", ".join(deactivated))
return _module_catalog_response(session, request, notes=notes) return _module_catalog_response(session, request, notes=notes)
@@ -1180,6 +1669,8 @@ def write_system_settings(
@router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse) @router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse)
def list_governance_templates( def list_governance_templates(
kind: str | None = Query(default=None), kind: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:governance:read")), principal: ApiPrincipal = Depends(require_scope("system:governance:read")),
): ):
@@ -1187,18 +1678,63 @@ def list_governance_templates(
query = session.query(GovernanceTemplate) query = session.query(GovernanceTemplate)
if kind: if kind:
query = query.filter(GovernanceTemplate.kind == kind) query = query.filter(GovernanceTemplate.kind == kind)
items = query.order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() items, pagination = _governance_template_page(
return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items]) query.order_by(
GovernanceTemplate.kind.asc(),
GovernanceTemplate.name.asc(),
GovernanceTemplate.id.asc(),
),
page=page,
page_size=page_size,
)
return GovernanceTemplateListResponse(
templates=_governance_template_items(session, items),
**pagination,
)
def _full_governance_template_delta_response(session: Session) -> GovernanceTemplateListDeltaResponse: def _full_governance_template_delta_response(
items = session.query(GovernanceTemplate).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() session: Session,
*,
cursor: tuple[int, int] | None = None,
limit: int = 500,
) -> GovernanceTemplateListDeltaResponse:
snapshot_sequence = (
cursor[1]
if cursor is not None
else max_sequence_id(
session,
module_id=ADMIN_MODULE_ID,
collections=(GOVERNANCE_TEMPLATES_COLLECTION,),
)
)
page = cursor[0] if cursor is not None else 1
items, pagination = _governance_template_page(
session.query(GovernanceTemplate).order_by(
GovernanceTemplate.kind.asc(),
GovernanceTemplate.name.asc(),
GovernanceTemplate.id.asc(),
),
page=page,
page_size=limit,
)
has_more = page < pagination["pages"]
watermark = (
_encode_admin_full_cursor(
GOVERNANCE_TEMPLATES_FULL_CURSOR_SCOPE,
page=page + 1,
snapshot_sequence=snapshot_sequence,
)
if has_more
else encode_sequence_watermark(snapshot_sequence)
)
return GovernanceTemplateListDeltaResponse( return GovernanceTemplateListDeltaResponse(
templates=[_governance_template_item(session, item) for item in items], templates=_governance_template_items(session, items),
deleted=[], deleted=[],
watermark=_admin_delta_watermark(session, (GOVERNANCE_TEMPLATES_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
@@ -1210,18 +1746,26 @@ def list_governance_templates_delta(
principal: ApiPrincipal = Depends(require_scope("system:governance:read")), principal: ApiPrincipal = Depends(require_scope("system:governance:read")),
): ):
del principal del principal
if since is None: full_cursor = _decode_admin_full_cursor(
return _full_governance_template_delta_response(session) since,
scope=GOVERNANCE_TEMPLATES_FULL_CURSOR_SCOPE,
)
if since is None or full_cursor is not None:
return _full_governance_template_delta_response(
session,
cursor=full_cursor,
limit=limit,
)
entries, has_more = _admin_delta_entries(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), since=since, limit=limit) entries, has_more = _admin_delta_entries(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), since=since, limit=limit)
if entries is None: if entries is None:
return _full_governance_template_delta_response(session) return _full_governance_template_delta_response(session, limit=limit)
changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"] changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"]
items = [] items = []
if changed_ids: if changed_ids:
items = session.query(GovernanceTemplate).filter(GovernanceTemplate.id.in_(changed_ids)).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() items = session.query(GovernanceTemplate).filter(GovernanceTemplate.id.in_(changed_ids)).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all()
visible_template_ids = {item.id for item in items} visible_template_ids = {item.id for item in items}
return GovernanceTemplateListDeltaResponse( return GovernanceTemplateListDeltaResponse(
templates=[_governance_template_item(session, item) for item in items], templates=_governance_template_items(session, items),
deleted=_governance_template_deleted_entries(entries, visible_template_ids), deleted=_governance_template_deleted_entries(entries, visible_template_ids),
watermark=_admin_delta_response_watermark(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), entries=entries, has_more=has_more), watermark=_admin_delta_response_watermark(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
+139 -53
View File
@@ -3,61 +3,10 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem
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): class MaintenanceModeItem(BaseModel):
@@ -171,6 +120,61 @@ class ModuleStateUpdateRequest(BaseModel):
change_request_id: str | None = None change_request_id: str | None = None
class TenantModuleEntitlementItem(BaseModel):
id: str
name: str
dependencies: list[str] = Field(default_factory=list)
runtime_active: bool
availability: Literal["unavailable", "available", "forced"]
selected: bool
effective: bool
forced: bool
derived_dependency: bool
tenant_can_toggle: bool
reason: str | None = None
class TenantModuleEntitlementResponse(BaseModel):
tenant_id: str
revision: int
configured: bool
available_modules: list[str] = Field(default_factory=list)
forced_modules: list[str] = Field(default_factory=list)
selected_modules: list[str] = Field(default_factory=list)
effective_modules: list[str] = Field(default_factory=list)
derived_dependencies: list[str] = Field(default_factory=list)
modules: list[TenantModuleEntitlementItem] = Field(default_factory=list)
diagnostics: list[dict[str, str]] = Field(default_factory=list)
class TenantModuleTargetItem(BaseModel):
id: str
slug: str
name: str
is_active: bool
class TenantModuleTargetListResponse(BaseModel):
tenants: list[TenantModuleTargetItem] = Field(default_factory=list)
class SystemTenantModulePolicyUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
available_modules: list[str] = Field(default_factory=list, max_length=1000)
forced_modules: list[str] = Field(default_factory=list, max_length=1000)
enabled_modules: list[str] = Field(default_factory=list, max_length=1000)
expected_revision: int | None = Field(default=None, ge=0)
change_request_id: str | None = None
class TenantModuleSelectionUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled_modules: list[str] = Field(default_factory=list, max_length=1000)
expected_revision: int | None = Field(default=None, ge=0)
class ModuleInstallPlanItem(BaseModel): class ModuleInstallPlanItem(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -182,6 +186,7 @@ class ModuleInstallPlanItem(BaseModel):
python_ref: str | None = Field(default=None, max_length=1000) python_ref: str | None = Field(default=None, max_length=1000)
webui_package: str | None = Field(default=None, max_length=200) webui_package: str | None = Field(default=None, max_length=200)
webui_ref: str | None = Field(default=None, max_length=1000) webui_ref: str | None = Field(default=None, max_length=1000)
data_safety_acknowledged: bool = False
destroy_data: bool = False destroy_data: bool = False
status: Literal["planned", "applied", "blocked"] = "planned" status: Literal["planned", "applied", "blocked"] = "planned"
notes: str | None = Field(default=None, max_length=1000) notes: str | None = Field(default=None, max_length=1000)
@@ -201,6 +206,63 @@ class ModuleInstallChecklistItem(BaseModel):
detail: str | None = None detail: str | None = None
class ModuleInstallTargetItem(BaseModel):
module_id: str
action: Literal["install", "update", "uninstall"]
source: Literal["manual", "catalog"]
current_version: str | None = None
target_version: str | None = None
python_package: str | None = None
python_ref: str | None = None
webui_package: str | None = None
webui_ref: str | None = None
migration_safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic"
migration_notes: str | None = None
current_version_min: str | None = None
current_version_max_exclusive: str | None = None
bridge_release: bool = False
bridge_notes: str | None = None
allow_downgrade: bool = False
allow_same_version: bool = False
recovery_tested: bool = False
recovery_notes: str | None = None
data_safety_acknowledged: bool = False
class ModuleMigrationPlanStep(BaseModel):
module_id: str
action: Literal["install", "update", "uninstall"]
phase: Literal["upgrade", "retirement"]
source: Literal["manifest", "catalog", "pending"]
has_migration_metadata: bool = False
metadata_pending: bool = False
migration_safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic"
current_version: str | None = None
target_version: str | None = None
reason: str | None = None
class ModuleMigrationTaskPlanItem(BaseModel):
module_id: str
task_id: str
phase: Literal["pre_migration_check", "pre_migration_prepare", "post_migration_backfill", "post_migration_verify"]
summary: str
task_version: str = "1"
safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic"
idempotent: bool = True
timeout_seconds: int | None = None
source: Literal["manifest", "catalog", "pending"] = "manifest"
has_executor: bool = False
metadata_pending: bool = False
class ModuleMigrationExecutionPlan(BaseModel):
enabled_modules: list[str] = Field(default_factory=list)
requires_database_migration: bool = False
steps: list[ModuleMigrationPlanStep] = Field(default_factory=list)
tasks: list[ModuleMigrationTaskPlanItem] = Field(default_factory=list)
class ModuleInstallPreflightResponse(BaseModel): class ModuleInstallPreflightResponse(BaseModel):
allowed: bool allowed: bool
maintenance_mode: bool maintenance_mode: bool
@@ -210,6 +272,8 @@ class ModuleInstallPreflightResponse(BaseModel):
rollback_commands: list[str] = Field(default_factory=list) rollback_commands: list[str] = Field(default_factory=list)
issues: list[ModuleInstallPreflightIssue] = Field(default_factory=list) issues: list[ModuleInstallPreflightIssue] = Field(default_factory=list)
checklist: list[ModuleInstallChecklistItem] = Field(default_factory=list) checklist: list[ModuleInstallChecklistItem] = Field(default_factory=list)
target_plan: list[ModuleInstallTargetItem] = Field(default_factory=list)
migration_plan: ModuleMigrationExecutionPlan = Field(default_factory=ModuleMigrationExecutionPlan)
class ModuleInstallPlanResponse(BaseModel): class ModuleInstallPlanResponse(BaseModel):
@@ -328,6 +392,20 @@ class ModulePackageCatalogItem(BaseModel):
description: str | None = None description: str | None = None
version: str | None = None version: str | None = None
action: Literal["install", "update", "uninstall"] = "install" action: Literal["install", "update", "uninstall"] = "install"
dependencies: list[str] = Field(default_factory=list)
optional_dependencies: list[str] = Field(default_factory=list)
migration_safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic"
migration_notes: str | None = None
migration_after: list[str] = Field(default_factory=list)
migration_before: list[str] = Field(default_factory=list)
current_version_min: str | None = None
current_version_max_exclusive: str | None = None
bridge_release: bool = False
bridge_notes: str | None = None
allow_downgrade: bool = False
allow_same_version: bool = False
recovery_tested: bool = False
recovery_notes: str | None = None
python_package: str | None = None python_package: str | None = None
python_ref: str | None = None python_ref: str | None = None
webui_package: str | None = None webui_package: str | None = None
@@ -414,6 +492,10 @@ class GovernanceTemplateItem(BaseModel):
class GovernanceTemplateListResponse(BaseModel): class GovernanceTemplateListResponse(BaseModel):
templates: list[GovernanceTemplateItem] templates: list[GovernanceTemplateItem]
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class GovernanceTemplateListDeltaResponse(BaseModel): class GovernanceTemplateListDeltaResponse(BaseModel):
@@ -422,6 +504,10 @@ class GovernanceTemplateListDeltaResponse(BaseModel):
watermark: str | None = None watermark: str | None = None
has_more: bool = False has_more: bool = False
full: bool = False full: bool = False
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class GovernanceTemplateCreateRequest(BaseModel): class GovernanceTemplateCreateRequest(BaseModel):
+159 -2
View File
@@ -3,7 +3,17 @@ from __future__ import annotations
from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleManifest,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -14,12 +24,148 @@ def _route_factory(context: ModuleContext):
return router return router
ADMIN_PERMISSIONS = (
PermissionDefinition(
scope="admin:module:read",
module_id="admin",
resource="module",
action="read",
label="View tenant modules",
description="Inspect module availability, requirements, and effective state for the active tenant.",
category="Administration",
level="tenant",
),
PermissionDefinition(
scope="admin:module:write",
module_id="admin",
resource="module",
action="write",
label="Manage tenant modules",
description="Enable or disable modules for the active tenant within system policy.",
category="Administration",
level="tenant",
),
)
ADMIN_ROLE_TEMPLATES = (
RoleTemplate(
slug="module_admin",
name="Module administrator",
description="Manage the active tenant's module selection within system policy.",
permissions=("admin:module:read", "admin:module:write"),
level="tenant",
managed=True,
protected=False,
),
)
manifest = ModuleManifest( manifest = ModuleManifest(
id="admin", id="admin",
name="Admin", name="Admin",
version="0.1.6", version="0.1.15",
permissions=ADMIN_PERMISSIONS,
role_templates=ADMIN_ROLE_TEMPLATES,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
route_factory=_route_factory, route_factory=_route_factory,
documentation=(
DocumentationTopic(
id="admin.workspace",
title="Use the administration workspace",
summary="The administration workspace shows only the sections supplied by enabled modules and allowed by the current account's permissions.",
body="System and tenant administration share one workspace. Available sections can include settings, configuration changes and packages, governance templates, groups, and module lifecycle controls. A missing section normally means that its owning module is disabled or the current account lacks the required authority.",
documentation_types=("admin", "user"),
audience=("tenant_admin", "system_admin", "operator"),
metadata={
"kind": "reference",
"help_contexts": [
"admin.workspace",
"admin.overview",
"admin.section-navigation",
],
},
),
DocumentationTopic(
id="admin.tenant-module-entitlements",
title="Govern modules per tenant",
summary="System administrators set each tenant's module ceiling and forced modules; tenant module administrators choose within that ceiling.",
body=(
"Deployment activation installs and loads module code for the whole instance. Tenant module governance is a separate entitlement layer: system administrators mark modules unavailable, available, or forced for a tenant and may also change that tenant's selection. A tenant module administrator can only enable or disable available modules; forced modules and required dependencies remain effective. Module entitlement never grants permissions, and malformed policy fails closed to protected administration modules. Disabling a module stops new API, capability, schedule, and worker admission for that tenant; accepted durable work remains queued and requires an operator decision rather than being executed or discarded. Enabling a capability module such as Encryption only makes its services available; data encryption remains an explicit owning-module policy or migration decision."
),
layer="configured",
documentation_types=("admin",),
audience=("system_admin", "module_admin", "tenant_admin"),
related_modules=("access", "policy", "views", "encryption"),
metadata={
"kind": "workflow",
"help_contexts": [
"admin.system-tenant-modules",
"admin.tenant-modules",
],
},
),
DocumentationTopic(
id="admin.governance-and-module-lifecycle",
title="Govern configuration and module lifecycle",
summary="Admin owns reusable governance templates, configuration packages, and the operator-facing module lifecycle queue.",
body="Configuration packages import or export module-owned configuration; they do not install software. Module catalog actions create reviewed install, update, activation, deactivation, or retirement requests for the trusted installer process. Governance templates materialize approved role and group structures through the owning Access contracts.",
documentation_types=("admin",),
audience=("system_admin", "operator", "module_admin"),
related_modules=("access", "audit", "ops"),
metadata={
"kind": "reference",
"help_contexts": [
"admin.system-settings",
"admin.configuration-changes",
"admin.configuration-packages",
"admin.governance-templates",
],
},
),
DocumentationTopic(
id="admin.module-lifecycle-workflow",
title="Plan and supervise module lifecycle changes",
summary="Move a reviewed module package plan through preflight, maintenance-gated queueing, daemon execution, and durable run evidence.",
body=(
"The Modules administration surface projects one operator workflow: save a package plan, resolve preflight findings, enter maintenance mode with the required authority, queue a supervised installer request, and inspect the matching run record. "
"The stage indicator is derived from the saved plan timestamp, the latest matching request, and its run; an older request is never presented as evidence for a newer plan. "
"Disabled queue actions name the earliest blocker, the person who can resolve it, and the plan surface where work continues. Package mutation remains outside the FastAPI process and recovery evidence remains durable in the installer ledger."
),
layer="configured",
documentation_types=("admin",),
audience=("system_admin", "operator", "module_admin"),
related_modules=("ops", "audit"),
metadata={
"kind": "workflow",
"context_ids": [
"admin.module-lifecycle",
"admin.module-lifecycle.queue-blocker",
],
"help_contexts": [
"admin.module-lifecycle",
"admin.module-lifecycle.queue-blocker",
"admin.module-lifecycle.plan",
"admin.module-lifecycle.evidence",
],
},
),
),
frontend=FrontendModule(
module_id="admin",
package_name="@govoplan/admin-webui",
view_surfaces=(
ViewSurface(id="admin.section.overview", module_id="admin", kind="section", label="Administration overview", order=0),
ViewSurface(id="admin.section.system-settings", module_id="admin", kind="section", label="System settings", order=10),
ViewSurface(id="admin.section.system-configuration-changes", module_id="admin", kind="section", label="Configuration changes", order=20),
ViewSurface(id="admin.section.system-configuration-packages", module_id="admin", kind="section", label="Configuration packages", order=30),
ViewSurface(id="admin.section.system-role-templates", module_id="admin", kind="section", label="Role templates", order=40),
ViewSurface(id="admin.section.system-groups", module_id="admin", kind="section", label="Group templates", order=50),
ViewSurface(id="admin.section.system-modules", module_id="admin", kind="section", label="Modules", order=85),
ViewSurface(id="admin.section.system-tenant-modules", module_id="admin", kind="section", label="Tenant modules", order=86),
ViewSurface(id="admin.section.tenant-modules", module_id="admin", kind="section", label="Modules", order=60),
),
),
migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata), migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata),
uninstall_guard_providers=( uninstall_guard_providers=(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
@@ -28,6 +174,17 @@ manifest = ModuleManifest(
label="Admin", label="Admin",
), ),
), ),
architecture=declared_module_architecture(
layer="runtime_meta",
kind="presentation",
maturity="vertical_slice",
documentation_ref="README.md",
test_ref="tests/test_catalog_plan.py",
known_limits=("Some module-specific administration surfaces still own their own navigation and release evidence.",),
owned_concepts=("administration workspace", "configuration package workflow", "module lifecycle request"),
non_owned_concepts=("module installation effect", "access policy", "module-owned settings"),
operations_docs=("README.md",),
),
) )
+1
View File
@@ -0,0 +1 @@
"""GovOPlaN Admin backend tests."""
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from fastapi import HTTPException
from govoplan_admin.backend.api.v1.routes import _catalog_plan_item
class CatalogPlanItemTests(unittest.TestCase):
def test_builds_catalog_item_and_preserves_catalog_metadata(self) -> None:
validation: dict[str, object] = {
"valid": True,
"source": "https://catalog.example.test/modules.json",
"source_type": "remote",
"channel": "stable",
"signed": True,
"trusted": True,
"modules": [
"ignored",
{"module_id": "other", "action": "install"},
{
"module_id": "calendar",
"action": "install",
"python_package": "govoplan-calendar",
"python_ref": 42,
"webui_package": "@govoplan/calendar-webui",
"notes": "Catalog note",
"license_features": ["calendar.sync", "", 7],
},
],
}
decision = {
"allowed": True,
"reason": "Feature expires soon.",
"missing_features": ["calendar.future"],
}
with patch(
"govoplan_admin.backend.api.v1.routes.module_license_decision",
return_value=decision,
) as license_decision:
item, returned_validation = _catalog_plan_item(
"calendar",
{"calendar"},
validation=validation,
)
self.assertIs(returned_validation, validation)
self.assertEqual(item.module_id, "calendar")
self.assertEqual(item.action, "update")
self.assertEqual(item.source, "catalog")
self.assertEqual(item.python_package, "govoplan-calendar")
self.assertIsNone(item.python_ref)
self.assertEqual(item.webui_package, "@govoplan/calendar-webui")
self.assertIsNone(item.webui_ref)
self.assertEqual(item.notes, "Catalog note\nLicense warning: Feature expires soon.")
self.assertEqual(item.catalog["source"], "https://catalog.example.test/modules.json")
self.assertEqual(item.catalog["channel"], "stable")
self.assertTrue(item.catalog["signed"])
license_decision.assert_called_once_with(["calendar.sync", "7"])
def test_rejects_catalog_action_when_license_is_missing(self) -> None:
validation: dict[str, object] = {
"valid": True,
"modules": [{"module_id": "calendar", "action": "install", "license_features": ["calendar.sync"]}],
}
with patch(
"govoplan_admin.backend.api.v1.routes.module_license_decision",
return_value={"allowed": False, "missing_features": ["calendar.sync", "calendar.write"]},
), self.assertRaises(HTTPException) as raised:
_catalog_plan_item("calendar", set(), validation=validation)
self.assertEqual(raised.exception.status_code, 403)
self.assertEqual(
raised.exception.detail,
"License does not allow install for calendar: calendar.sync, calendar.write.",
)
def test_rejects_invalid_or_missing_catalog_entries(self) -> None:
with self.subTest("invalid catalog"), self.assertRaises(HTTPException) as invalid:
_catalog_plan_item(
"calendar",
set(),
validation={"valid": False, "error": "Signature is invalid."},
)
self.assertEqual(invalid.exception.status_code, 422)
self.assertEqual(invalid.exception.detail, "Signature is invalid.")
with self.subTest("entry not found"), self.assertRaises(HTTPException) as missing:
_catalog_plan_item(
"calendar",
set(),
validation={
"valid": True,
"modules": [
{"module_id": "calendar", "action": "remove"},
{"module_id": "other", "action": "install"},
],
},
)
self.assertEqual(missing.exception.status_code, 404)
self.assertEqual(missing.exception.detail, "Catalog install/update entry not found: calendar")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,66 @@
from __future__ import annotations
import unittest
from govoplan_admin.backend.manifest import manifest
class InterfaceDocumentationContractTests(unittest.TestCase):
def test_admin_topics_publish_stable_help_contexts(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
expected = {
"admin.workspace": {"admin.workspace", "admin.overview"},
"admin.governance-and-module-lifecycle": {
"admin.system-settings",
"admin.configuration-changes",
"admin.configuration-packages",
"admin.governance-templates",
},
"admin.module-lifecycle-workflow": {
"admin.module-lifecycle",
"admin.module-lifecycle.queue-blocker",
"admin.module-lifecycle.plan",
"admin.module-lifecycle.evidence",
},
"admin.tenant-module-entitlements": {
"admin.system-tenant-modules",
"admin.tenant-modules",
},
}
for topic_id, contexts in expected.items():
self.assertIn(topic_id, topics)
metadata = topics[topic_id].metadata or {}
self.assertTrue(
contexts.issubset(set(metadata.get("help_contexts", ()))),
topic_id,
)
def test_admin_contributed_surfaces_remain_declared(self) -> None:
surface_ids = {
surface.id for surface in manifest.frontend.view_surfaces
}
self.assertEqual(
surface_ids,
{
"admin.section.overview",
"admin.section.system-settings",
"admin.section.system-configuration-changes",
"admin.section.system-configuration-packages",
"admin.section.system-role-templates",
"admin.section.system-groups",
"admin.section.system-modules",
"admin.section.system-tenant-modules",
"admin.section.tenant-modules",
},
)
def test_module_administrator_has_only_tenant_module_permissions(self) -> None:
permissions = {item.scope for item in manifest.permissions}
template = next(item for item in manifest.role_templates if item.slug == "module_admin")
self.assertEqual({"admin:module:read", "admin:module:write"}, permissions)
self.assertEqual(tuple(sorted(permissions)), tuple(sorted(template.permissions)))
if __name__ == "__main__":
unittest.main()
+9 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/admin-webui", "name": "@govoplan/admin-webui",
"version": "0.1.6", "version": "0.1.15",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -12,12 +12,16 @@
"import": "./src/index.ts" "import": "./src/index.ts"
} }
}, },
"scripts": {
"test:installer-workflow": "node --experimental-strip-types --test tests/module-installer-workflow.test.ts",
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.15",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1" "react-router": ">=8.3.0 <9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const read = (path) => readFileSync(resolve(root, path), "utf8");
const overview = read("src/features/admin/AdminOverviewPanel.tsx");
const settings = read("src/features/admin/SystemSettingsPanel.tsx");
const changes = read("src/features/admin/ConfigurationChangesPanel.tsx");
const packages = read("src/features/admin/ConfigurationPackagesPanel.tsx");
const templates = read("src/features/admin/GovernanceTemplatesPanel.tsx");
const modules = read("src/features/admin/ModuleManagementPanel.tsx");
const moduleSource = read("src/module.ts");
const allSource = [overview, settings, changes, packages, templates, modules].join("\n");
for (const source of [overview, settings, changes, packages, templates, modules]) {
assert.match(source, /AdminPageLayout/);
assert.match(source, /DocumentationHelpLink/);
assert.match(source, /disabledReason|help=/);
}
for (const source of [changes, packages, templates, modules]) {
assert.match(source, /ConfirmDialog/);
}
assert.match(changes, /DataGrid/);
assert.match(packages, /ReferenceSelect/);
assert.match(packages, /manualReferences/);
assert.match(templates, /TableActionGroup/);
assert.match(modules, /StageRail/);
assert.match(modules, /ActionBlockerHint/);
assert.match(moduleSource, /version: "0\.1\.8"/);
assert.doesNotMatch(overview, /title="(?:ADMINISTRATION|GLOBAL|TENANT|GROUP|USER)"/);
assert.doesNotMatch(allSource, /window\.(alert|confirm|prompt)\s*\(/);
assert.doesNotMatch(allSource, /@govoplan\/(?!core-webui)[^"']+-webui\//);
console.log("Admin interface pattern-language checks passed.");
+188 -106
View File
@@ -1,74 +1,16 @@
import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui"; import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy } from "@govoplan/core-webui";
import { apiFetch } from "@govoplan/core-webui"; import { apiFetch, apiPath, apiQuery } from "@govoplan/core-webui";
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
export type PermissionItem = { export type {
scope: string; AdminOverview,
label: string; PrivacyRetentionLimitPermissionPatch,
description: string; PrivacyRetentionLimitPermissions,
category: string; PrivacyRetentionPolicy,
level: "tenant" | "system"; PrivacyRetentionPolicyFieldKey,
system_template_id?: string | null; PrivacyRetentionPolicyPatch,
system_required?: boolean; PermissionItem,
}; TenantAdminItem
} from "@govoplan/core-webui";
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 = { export type MaintenanceMode = {
enabled: boolean; enabled: boolean;
@@ -192,10 +134,7 @@ type DeltaResponseFields = {
}; };
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string { function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
const params = new URLSearchParams(); return apiQuery(options);
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
return params.toString() ? `?${params.toString()}` : "";
} }
export type SystemSettingsDeltaResponse = { export type SystemSettingsDeltaResponse = {
@@ -247,15 +186,52 @@ export type ModuleCatalogResponse = {
notes: string[]; notes: string[];
}; };
export type TenantModuleAvailability = "unavailable" | "available" | "forced";
export type TenantModuleEntitlementItem = {
id: string;
name: string;
dependencies: string[];
runtime_active: boolean;
availability: TenantModuleAvailability;
selected: boolean;
effective: boolean;
forced: boolean;
derived_dependency: boolean;
tenant_can_toggle: boolean;
reason?: string | null;
};
export type TenantModuleEntitlementResponse = {
tenant_id: string;
revision: number;
configured: boolean;
available_modules: string[];
forced_modules: string[];
selected_modules: string[];
effective_modules: string[];
derived_dependencies: string[];
modules: TenantModuleEntitlementItem[];
diagnostics: Array<{ code: string; message: string; severity: string }>;
};
export type TenantModuleTarget = {
id: string;
slug: string;
name: string;
is_active: boolean;
};
export type ModuleInstallPlanItem = { export type ModuleInstallPlanItem = {
module_id: string; module_id: string;
action: "install" | "uninstall"; action: "install" | "update" | "uninstall";
source: "manual" | "catalog"; source: "manual" | "catalog";
catalog?: Record<string, unknown> | null; catalog?: Record<string, unknown> | null;
python_package?: string | null; python_package?: string | null;
python_ref?: string | null; python_ref?: string | null;
webui_package?: string | null; webui_package?: string | null;
webui_ref?: string | null; webui_ref?: string | null;
data_safety_acknowledged: boolean;
destroy_data: boolean; destroy_data: boolean;
status: "planned" | "applied" | "blocked"; status: "planned" | "applied" | "blocked";
notes?: string | null; notes?: string | null;
@@ -275,6 +251,63 @@ export type ModuleInstallChecklistItem = {
detail?: string | null; detail?: string | null;
}; };
export type ModuleInstallTargetItem = {
module_id: string;
action: "install" | "update" | "uninstall";
source: "manual" | "catalog";
current_version?: string | null;
target_version?: string | null;
python_package?: string | null;
python_ref?: string | null;
webui_package?: string | null;
webui_ref?: string | null;
migration_safety: "automatic" | "requires_review" | "forward_only" | "destructive";
migration_notes?: string | null;
current_version_min?: string | null;
current_version_max_exclusive?: string | null;
bridge_release: boolean;
bridge_notes?: string | null;
allow_downgrade: boolean;
allow_same_version: boolean;
recovery_tested: boolean;
recovery_notes?: string | null;
data_safety_acknowledged: boolean;
};
export type ModuleMigrationPlanStep = {
module_id: string;
action: "install" | "update" | "uninstall";
phase: "upgrade" | "retirement";
source: "manifest" | "catalog" | "pending";
has_migration_metadata: boolean;
metadata_pending: boolean;
migration_safety: "automatic" | "requires_review" | "forward_only" | "destructive";
current_version?: string | null;
target_version?: string | null;
reason?: string | null;
};
export type ModuleMigrationTaskPlanItem = {
module_id: string;
task_id: string;
phase: "pre_migration_check" | "pre_migration_prepare" | "post_migration_backfill" | "post_migration_verify";
summary: string;
task_version: string;
safety: "automatic" | "requires_review" | "forward_only" | "destructive";
idempotent: boolean;
timeout_seconds?: number | null;
source: "manifest" | "catalog" | "pending";
has_executor: boolean;
metadata_pending: boolean;
};
export type ModuleMigrationExecutionPlan = {
enabled_modules: string[];
requires_database_migration: boolean;
steps: ModuleMigrationPlanStep[];
tasks: ModuleMigrationTaskPlanItem[];
};
export type ModuleInstallPreflight = { export type ModuleInstallPreflight = {
allowed: boolean; allowed: boolean;
maintenance_mode: boolean; maintenance_mode: boolean;
@@ -284,6 +317,8 @@ export type ModuleInstallPreflight = {
rollback_commands: string[]; rollback_commands: string[];
issues: ModuleInstallPreflightIssue[]; issues: ModuleInstallPreflightIssue[];
checklist: ModuleInstallChecklistItem[]; checklist: ModuleInstallChecklistItem[];
target_plan: ModuleInstallTargetItem[];
migration_plan: ModuleMigrationExecutionPlan;
}; };
export type ModuleInstallPlanResponse = { export type ModuleInstallPlanResponse = {
@@ -389,7 +424,21 @@ export type ModulePackageCatalogItem = {
name: string; name: string;
description?: string | null; description?: string | null;
version?: string | null; version?: string | null;
action: "install" | "uninstall"; action: "install" | "update" | "uninstall";
dependencies: string[];
optional_dependencies: string[];
migration_safety: "automatic" | "requires_review" | "forward_only" | "destructive";
migration_notes?: string | null;
migration_after: string[];
migration_before: string[];
current_version_min?: string | null;
current_version_max_exclusive?: string | null;
bridge_release: boolean;
bridge_notes?: string | null;
allow_downgrade: boolean;
allow_same_version: boolean;
recovery_tested: boolean;
recovery_notes?: string | null;
python_package?: string | null; python_package?: string | null;
python_ref?: string | null; python_ref?: string | null;
webui_package?: string | null; webui_package?: string | null;
@@ -467,20 +516,6 @@ export type GovernanceTemplateItem = {
updated_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> { export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings"); return apiFetch(settings, "/api/v1/admin/system/settings");
} }
@@ -505,24 +540,56 @@ export function updateModuleState(settings: ApiSettings, enabledModules: string[
}); });
} }
export function fetchSystemTenantModules(settings: ApiSettings, tenantId: string): Promise<TenantModuleEntitlementResponse> {
return apiFetch(settings, `/api/v1/admin/system/tenants/${encodeURIComponent(tenantId)}/modules`);
}
export async function fetchTenantModuleTargets(settings: ApiSettings): Promise<TenantModuleTarget[]> {
const response = await apiFetch<{ tenants: TenantModuleTarget[] }>(settings, "/api/v1/admin/system/tenant-module-targets");
return response.tenants;
}
export function updateSystemTenantModules(
settings: ApiSettings,
tenantId: string,
payload: {
available_modules: string[];
forced_modules: string[];
enabled_modules: string[];
expected_revision: number;
change_request_id?: string | null;
}
): Promise<TenantModuleEntitlementResponse> {
return apiFetch(settings, `/api/v1/admin/system/tenants/${encodeURIComponent(tenantId)}/modules`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export function fetchTenantModules(settings: ApiSettings): Promise<TenantModuleEntitlementResponse> {
return apiFetch(settings, "/api/v1/admin/tenant/modules");
}
export function updateTenantModules(
settings: ApiSettings,
payload: { enabled_modules: string[]; expected_revision: number }
): Promise<TenantModuleEntitlementResponse> {
return apiFetch(settings, "/api/v1/admin/tenant/modules", {
method: "PUT",
body: JSON.stringify(payload)
});
}
export function fetchModuleInstallPlan(settings: ApiSettings): Promise<ModuleInstallPlanResponse> { export function fetchModuleInstallPlan(settings: ApiSettings): Promise<ModuleInstallPlanResponse> {
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan"); return apiFetch(settings, "/api/v1/admin/system/modules/install-plan");
} }
export function fetchModuleInstallerRuns(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise<ModuleInstallerRunListResponse> { export function fetchModuleInstallerRuns(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise<ModuleInstallerRunListResponse> {
const params = new URLSearchParams(); return apiFetch(settings, apiPath("/api/v1/admin/system/modules/install-runs", options));
if (options.page_size) params.set("page_size", String(options.page_size));
if (options.cursor) params.set("cursor", options.cursor);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/system/modules/install-runs${suffix}`);
} }
export function fetchModuleInstallerRequests(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise<ModuleInstallerRequestListResponse> { export function fetchModuleInstallerRequests(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise<ModuleInstallerRequestListResponse> {
const params = new URLSearchParams(); return apiFetch(settings, apiPath("/api/v1/admin/system/modules/install-requests", options));
if (options.page_size) params.set("page_size", String(options.page_size));
if (options.cursor) params.set("cursor", options.cursor);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/system/modules/install-requests${suffix}`);
} }
export function createModuleInstallerRequest(settings: ApiSettings, options: ModuleInstallerRequestOptions): Promise<ModuleInstallerRequestItem> { export function createModuleInstallerRequest(settings: ApiSettings, options: ModuleInstallerRequestOptions): Promise<ModuleInstallerRequestItem> {
@@ -564,9 +631,25 @@ export function clearModuleInstallPlan(settings: ApiSettings): Promise<ModuleIns
} }
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> { export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; const pageSize = 500;
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`); const templates: GovernanceTemplateItem[] = [];
return response.templates; for (let page = 1; ; page += 1) {
const response = await apiFetch<{
templates: GovernanceTemplateItem[];
pages?: number;
}>(
settings,
apiPath("/api/v1/admin/system/governance-templates", {
kind,
page,
page_size: pageSize
})
);
templates.push(...response.templates);
if (page >= (response.pages ?? 1)) {
return templates;
}
}
} }
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> { export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> {
@@ -578,8 +661,7 @@ export function updateGovernanceTemplate(settings: ApiSettings, templateId: stri
} }
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string, changeRequestId?: string | null): Promise<void> { export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string, changeRequestId?: string | null): Promise<void> {
const suffix = changeRequestId ? `?change_request_id=${encodeURIComponent(changeRequestId)}` : ""; return apiFetch(settings, apiPath(`/api/v1/admin/system/governance-templates/${templateId}`, { change_request_id: changeRequestId }), { method: "DELETE" });
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}${suffix}`, { method: "DELETE" });
} }
export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> { export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> {
+22 -15
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui"; import type { ApiSettings } from "@govoplan/core-webui";
import { fetchAdminOverview, type AdminOverview } from "../../api/admin"; import { fetchAdminOverview, type AdminOverview } from "../../api/admin";
import { Card } from "@govoplan/core-webui"; import { Card, MetricCard } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui"; import { AdminPageLayout, DocumentationHelpLink, adminErrorMessage } from "@govoplan/core-webui";
import { ADMIN_INTERFACE_I18N, ADMIN_WORKSPACE_DOCUMENTATION } from "./interfacePatterns";
export default function AdminOverviewPanel({ settings, onSelect, availableSections }: {settings: ApiSettings;onSelect: (section: string) => void;availableSections: ReadonlySet<string>;}) { export default function AdminOverviewPanel({ settings, onSelect, availableSections }: {settings: ApiSettings;onSelect: (section: string) => void;availableSections: ReadonlySet<string>;}) {
const [overview, setOverview] = useState<AdminOverview | null>(null); const [overview, setOverview] = useState<AdminOverview | null>(null);
@@ -28,7 +29,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
some((value) => value !== null && value !== undefined)); some((value) => value !== null && value !== undefined));
return ( return (
<AdminPageLayout title="i18n:govoplan-admin.administration.b8be3d12" description="i18n:govoplan-admin.system_wide_governance_and_tenant_local_access_m.cda72499" loading={loading} error={error} actions={<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-admin.reload.cce71553</Button>}> <AdminPageLayout title="i18n:govoplan-admin.administration.b8be3d12" description="i18n:govoplan-admin.system_wide_governance_and_tenant_local_access_m.cda72499" loading={loading} error={error} actions={<><DocumentationHelpLink reference={ADMIN_WORKSPACE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : undefined}>i18n:govoplan-admin.reload.cce71553</Button></>}>
{overview && <> {overview && <>
{hasSystemMetrics && <> {hasSystemMetrics && <>
<div className="admin-overview-section-label">i18n:govoplan-admin.system.bc0792d8</div> <div className="admin-overview-section-label">i18n:govoplan-admin.system.bc0792d8</div>
@@ -40,17 +41,18 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
</div> </div>
</>} </>}
{hasAnySection(availableSections, platformSectionIds) && <Card title="ADMINISTRATION"> {hasAnySection(availableSections, platformSectionIds) && <Card title={ADMIN_INTERFACE_I18N.administrationHeading}>
<div className="admin-overview-grid"> <div className="admin-overview-grid">
{availableSections.has("system-modules") && <AreaLink title="i18n:govoplan-admin.modules.04e9462c" text="i18n:govoplan-admin.installed_modules_runtime_state_and_startup_stat.38dd7028" onClick={() => onSelect("system-modules")} />} {availableSections.has("system-modules") && <AreaLink title="i18n:govoplan-admin.modules.04e9462c" text="i18n:govoplan-admin.installed_modules_runtime_state_and_startup_stat.38dd7028" onClick={() => onSelect("system-modules")} />}
{availableSections.has("system-configuration-packages") && <AreaLink title="i18n:govoplan-admin.packages.0a999012" text="i18n:govoplan-admin.preflight_approve_apply_and_export_configuration.276bd0f8" onClick={() => onSelect("system-configuration-packages")} />} {availableSections.has("system-tenant-modules") && <AreaLink title="Tenant modules" text="Set per-tenant availability, forced modules, and current selection." onClick={() => onSelect("system-tenant-modules")} />}
{availableSections.has("system-configuration-packages") && <AreaLink title="i18n:govoplan-admin.configuration_packages.eb2f05f1" text="i18n:govoplan-admin.preflight_approve_apply_and_export_configuration.276bd0f8" onClick={() => onSelect("system-configuration-packages")} />}
{availableSections.has("system-settings") && <AreaLink title="i18n:govoplan-admin.maintenance.94de303b" text="i18n:govoplan-admin.instance_defaults_and_tenant_governance_capabili.99d6b2fa" onClick={() => onSelect("system-settings")} />} {availableSections.has("system-settings") && <AreaLink title="i18n:govoplan-admin.maintenance.94de303b" text="i18n:govoplan-admin.instance_defaults_and_tenant_governance_capabili.99d6b2fa" onClick={() => onSelect("system-settings")} />}
{availableSections.has("system-configuration-changes") && <AreaLink title="i18n:govoplan-admin.changes.8aa57de6" text="i18n:govoplan-admin.configuration_requests_approvals_and_version_his.19f37335" onClick={() => onSelect("system-configuration-changes")} />} {availableSections.has("system-configuration-changes") && <AreaLink title="i18n:govoplan-admin.changes.8aa57de6" text="i18n:govoplan-admin.configuration_requests_approvals_and_version_his.19f37335" onClick={() => onSelect("system-configuration-changes")} />}
{availableSections.has("system-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.system_level_administrative_history.49f76723" onClick={() => onSelect("system-audit")} />} {availableSections.has("system-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.system_level_administrative_history.49f76723" onClick={() => onSelect("system-audit")} />}
</div> </div>
</Card>} </Card>}
{hasAnySection(availableSections, globalSectionIds) && <Card title="GLOBAL"> {hasAnySection(availableSections, globalSectionIds) && <Card title={ADMIN_INTERFACE_I18N.globalHeading}>
<div className="admin-overview-grid"> <div className="admin-overview-grid">
{availableSections.has("system-tenants") && <AreaLink title="i18n:govoplan-admin.tenants.1f7ae776" text="i18n:govoplan-admin.create_suspend_and_govern_tenant_spaces.77992a39" onClick={() => onSelect("system-tenants")} />} {availableSections.has("system-tenants") && <AreaLink title="i18n:govoplan-admin.tenants.1f7ae776" text="i18n:govoplan-admin.create_suspend_and_govern_tenant_spaces.77992a39" onClick={() => onSelect("system-tenants")} />}
{availableSections.has("system-roles") && <AreaLink title="i18n:govoplan-admin.system_roles.a9461aa6" text="i18n:govoplan-admin.instance_wide_roles_assigned_directly_to_global_.91050488" onClick={() => onSelect("system-roles")} />} {availableSections.has("system-roles") && <AreaLink title="i18n:govoplan-admin.system_roles.a9461aa6" text="i18n:govoplan-admin.instance_wide_roles_assigned_directly_to_global_.91050488" onClick={() => onSelect("system-roles")} />}
@@ -60,6 +62,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
{availableSections.has("system-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.reusable_file_server_connections_credentials_and.8e5c7d43" onClick={() => onSelect("system-file-connectors")} />} {availableSections.has("system-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.reusable_file_server_connections_credentials_and.8e5c7d43" onClick={() => onSelect("system-file-connectors")} />}
{availableSections.has("system-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("system-mail-servers")} />} {availableSections.has("system-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("system-mail-servers")} />}
{availableSections.has("system-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.instance_privacy_retention_policy_and_lower_leve.b8d14069" onClick={() => onSelect("system-retention")} />} {availableSections.has("system-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.instance_privacy_retention_policy_and_lower_leve.b8d14069" onClick={() => onSelect("system-retention")} />}
{availableSections.has("system-view-policy") && <AreaLink title="View policy" text="Limit View actions, definitions, and surfaces across the instance." onClick={() => onSelect("system-view-policy")} />}
</div> </div>
</Card>} </Card>}
@@ -71,7 +74,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
<Metric title="i18n:govoplan-admin.api_keys.94fcf3c2" value={overview.active_api_key_count} text="i18n:govoplan-admin.active_tenant_automation_credentials.240c659e" /> <Metric title="i18n:govoplan-admin.api_keys.94fcf3c2" value={overview.active_api_key_count} text="i18n:govoplan-admin.active_tenant_automation_credentials.240c659e" />
</div> </div>
{hasAnySection(availableSections, tenantSectionIds) && <Card title="TENANT"> {hasAnySection(availableSections, tenantSectionIds) && <Card title={ADMIN_INTERFACE_I18N.tenantHeading}>
<div className="admin-overview-grid"> <div className="admin-overview-grid">
{availableSections.has("tenant-roles") && <AreaLink title="i18n:govoplan-admin.roles.47dcc27d" text="i18n:govoplan-admin.tenant_permission_bundles_and_system_managed_rol.bb563bea" onClick={() => onSelect("tenant-roles")} />} {availableSections.has("tenant-roles") && <AreaLink title="i18n:govoplan-admin.roles.47dcc27d" text="i18n:govoplan-admin.tenant_permission_bundles_and_system_managed_rol.bb563bea" onClick={() => onSelect("tenant-roles")} />}
{availableSections.has("tenant-groups") && <AreaLink title="i18n:govoplan-admin.groups.ae9629f4" text="i18n:govoplan-admin.tenant_memberships_and_inherited_roles.16eda9f6" onClick={() => onSelect("tenant-groups")} />} {availableSections.has("tenant-groups") && <AreaLink title="i18n:govoplan-admin.groups.ae9629f4" text="i18n:govoplan-admin.tenant_memberships_and_inherited_roles.16eda9f6" onClick={() => onSelect("tenant-groups")} />}
@@ -80,24 +83,28 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
{availableSections.has("tenant-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("tenant-mail-servers")} />} {availableSections.has("tenant-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("tenant-mail-servers")} />}
{availableSections.has("tenant-api-keys") && <AreaLink title="i18n:govoplan-admin.api_keys.94fcf3c2" text="i18n:govoplan-admin.scoped_automation_credentials_capped_by_owner_pe.b3e20e54" onClick={() => onSelect("tenant-api-keys")} />} {availableSections.has("tenant-api-keys") && <AreaLink title="i18n:govoplan-admin.api_keys.94fcf3c2" text="i18n:govoplan-admin.scoped_automation_credentials_capped_by_owner_pe.b3e20e54" onClick={() => onSelect("tenant-api-keys")} />}
{availableSections.has("tenant-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.tenant_level_privacy_retention_limits_inherited_.48f4989d" onClick={() => onSelect("tenant-retention")} />} {availableSections.has("tenant-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.tenant_level_privacy_retention_limits_inherited_.48f4989d" onClick={() => onSelect("tenant-retention")} />}
{availableSections.has("tenant-modules") && <AreaLink title="Modules" text="Choose modules made available to this tenant by system policy." onClick={() => onSelect("tenant-modules")} />}
{availableSections.has("tenant-view-policy") && <AreaLink title="View policy" text="Narrow inherited View actions and available surfaces for this tenant." onClick={() => onSelect("tenant-view-policy")} />}
{availableSections.has("tenant-settings") && <AreaLink title="i18n:govoplan-admin.general.9239ee2c" text="i18n:govoplan-admin.tenant_locale_and_tenant_specific_settings.ac49c83b" onClick={() => onSelect("tenant-settings")} />} {availableSections.has("tenant-settings") && <AreaLink title="i18n:govoplan-admin.general.9239ee2c" text="i18n:govoplan-admin.tenant_locale_and_tenant_specific_settings.ac49c83b" onClick={() => onSelect("tenant-settings")} />}
{availableSections.has("tenant-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.tenant_level_administrative_history_only.55495c3c" onClick={() => onSelect("tenant-audit")} />} {availableSections.has("tenant-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.tenant_level_administrative_history_only.55495c3c" onClick={() => onSelect("tenant-audit")} />}
</div> </div>
</Card>} </Card>}
{hasAnySection(availableSections, groupSectionIds) && <Card title="GROUP"> {hasAnySection(availableSections, groupSectionIds) && <Card title={ADMIN_INTERFACE_I18N.groupHeading}>
<div className="admin-overview-grid"> <div className="admin-overview-grid">
{availableSections.has("tenant-group-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.group_file_connector_policy_limits" onClick={() => onSelect("tenant-group-file-connectors")} />} {availableSections.has("tenant-group-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.group_file_connector_policy_limits" onClick={() => onSelect("tenant-group-file-connectors")} />}
{availableSections.has("tenant-group-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.group_mail_server_policy_limits" onClick={() => onSelect("tenant-group-mail-servers")} />} {availableSections.has("tenant-group-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.group_mail_server_policy_limits" onClick={() => onSelect("tenant-group-mail-servers")} />}
{availableSections.has("tenant-group-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.group_retention_policy_limits" onClick={() => onSelect("tenant-group-retention")} />} {availableSections.has("tenant-group-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.group_retention_policy_limits" onClick={() => onSelect("tenant-group-retention")} />}
{availableSections.has("group-view-policy") && <AreaLink title="View policy" text="Set View limits for a selected group." onClick={() => onSelect("group-view-policy")} />}
</div> </div>
</Card>} </Card>}
{hasAnySection(availableSections, userSectionIds) && <Card title="USER"> {hasAnySection(availableSections, userSectionIds) && <Card title={ADMIN_INTERFACE_I18N.userHeading}>
<div className="admin-overview-grid"> <div className="admin-overview-grid">
{availableSections.has("tenant-user-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.user_file_connector_policy_limits" onClick={() => onSelect("tenant-user-file-connectors")} />} {availableSections.has("tenant-user-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.user_file_connector_policy_limits" onClick={() => onSelect("tenant-user-file-connectors")} />}
{availableSections.has("tenant-user-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.user_mail_server_policy_limits" onClick={() => onSelect("tenant-user-mail-servers")} />} {availableSections.has("tenant-user-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.user_mail_server_policy_limits" onClick={() => onSelect("tenant-user-mail-servers")} />}
{availableSections.has("tenant-user-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.user_retention_policy_limits" onClick={() => onSelect("tenant-user-retention")} />} {availableSections.has("tenant-user-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.user_retention_policy_limits" onClick={() => onSelect("tenant-user-retention")} />}
{availableSections.has("user-view-policy") && <AreaLink title="View policy" text="Set View limits for a selected user." onClick={() => onSelect("user-view-policy")} />}
</div> </div>
</Card>} </Card>}
</>} </>}
@@ -105,18 +112,18 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
} }
const platformSectionIds = ["system-modules", "system-configuration-packages", "system-settings", "system-configuration-changes", "system-audit"]; const platformSectionIds = ["system-modules", "system-tenant-modules", "system-configuration-packages", "system-settings", "system-configuration-changes", "system-audit"];
const globalSectionIds = ["system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-file-connectors", "system-mail-servers", "system-retention"]; const globalSectionIds = ["system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-file-connectors", "system-mail-servers", "system-retention", "system-view-policy"];
const tenantSectionIds = ["tenant-roles", "tenant-groups", "tenant-users", "tenant-file-connectors", "tenant-mail-servers", "tenant-api-keys", "tenant-retention", "tenant-settings", "tenant-audit"]; const tenantSectionIds = ["tenant-roles", "tenant-groups", "tenant-users", "tenant-file-connectors", "tenant-mail-servers", "tenant-api-keys", "tenant-retention", "tenant-view-policy", "tenant-modules", "tenant-settings", "tenant-audit"];
const groupSectionIds = ["tenant-group-file-connectors", "tenant-group-mail-servers", "tenant-group-retention"]; const groupSectionIds = ["tenant-group-file-connectors", "tenant-group-mail-servers", "tenant-group-retention", "group-view-policy"];
const userSectionIds = ["tenant-user-file-connectors", "tenant-user-mail-servers", "tenant-user-retention"]; const userSectionIds = ["tenant-user-file-connectors", "tenant-user-mail-servers", "tenant-user-retention", "user-view-policy"];
function hasAnySection(sections: ReadonlySet<string>, candidates: readonly string[]): boolean { function hasAnySection(sections: ReadonlySet<string>, candidates: readonly string[]): boolean {
return candidates.some((section) => sections.has(section)); return candidates.some((section) => sections.has(section));
} }
function Metric({ title, value, text }: {title: string;value: string | number;text: string;}) { 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>; return <MetricCard label={title} value={value} detail={text} />;
} }
function AreaLink({ title, text, onClick }: {title: string;text: string;onClick: () => void;}) { function AreaLink({ title, text, onClick }: {title: string;text: string;onClick: () => void;}) {
@@ -1,13 +1,14 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Check, RefreshCw } from "lucide-react"; import { Check, RefreshCw } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui"; import type { ApiSettings } from "@govoplan/core-webui";
import { AdminPageLayout, Button, Card, StatusBadge, adminErrorMessage, formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks } from "@govoplan/core-webui"; import { AdminPageLayout, Button, Card, ConfirmDialog, DataGrid, DocumentationHelpLink, StatusBadge, TableActionGroup, adminErrorMessage, formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks, type DataGridColumn } from "@govoplan/core-webui";
import { import {
approveConfigurationChangeRequest, approveConfigurationChangeRequest,
fetchConfigurationChangesDelta, fetchConfigurationChangesDelta,
type ConfigurationChangeRecord, type ConfigurationChangeRecord,
type ConfigurationChangeRequest } from type ConfigurationChangeRequest } from
"../../api/admin"; "../../api/admin";
import { ADMIN_GOVERNANCE_DOCUMENTATION, ADMIN_INTERFACE_I18N } from "./interfacePatterns";
const DELTA_KEY = "admin:configuration-changes"; const DELTA_KEY = "admin:configuration-changes";
@@ -19,6 +20,7 @@ export default function ConfigurationChangesPanel({ settings, canApprove }: {set
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState(""); const [busyId, setBusyId] = useState("");
const [approving, setApproving] = useState<ConfigurationChangeRequest | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@@ -68,84 +70,100 @@ export default function ConfigurationChangesPanel({ settings, canApprove }: {set
setError(adminErrorMessage(err)); setError(adminErrorMessage(err));
} finally { } finally {
setBusyId(""); setBusyId("");
setApproving(null);
} }
} }
const pending = useMemo(() => requests.filter((item) => item.status !== "applied" && item.status !== "rejected"), [requests]); const pending = useMemo(() => requests.filter((item) => item.status !== "applied" && item.status !== "rejected"), [requests]);
const requestColumns: DataGridColumn<ConfigurationChangeRequest>[] = [
{
id: "setting",
header: "i18n:govoplan-admin.setting.fb449f71",
width: "minmax(220px, 1fr)",
minWidth: 180,
resizable: true,
sortable: true,
filterable: true,
value: (request) => `${request.label || request.key} ${request.key}`,
render: (request) => <div><strong>{request.label || request.key}</strong><span className="muted block">{request.key}</span></div>
},
{ id: "status", header: "i18n:govoplan-admin.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (request) => request.status, render: (request) => <StatusBadge status={statusTone(request.status)} label={request.status} /> },
{ id: "requested", header: "i18n:govoplan-admin.requested.c26bf60f", width: 180, sortable: true, value: (request) => request.requested_at, render: (request) => formatDateTime(request.requested_at) },
{ id: "approvals", header: "i18n:govoplan-admin.approvals.deb9d03c", width: 110, sortable: true, value: (request) => request.approvals.length },
{ id: "target", header: "i18n:govoplan-admin.target.61ad50a9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, filterable: true, value: (request) => targetLabel(request.target), render: (request) => targetLabel(request.target) },
{
id: "actions",
header: "i18n:govoplan-admin.action.97c89a4d",
width: 72,
sticky: "end",
align: "right",
render: (request) => <TableActionGroup actions={[{
id: "approve",
label: "i18n:govoplan-admin.approve.7b2c7f14",
icon: <Check size={16} aria-hidden="true" />,
applicable: request.status === "pending_approval",
disabled: !canApprove || Boolean(busyId),
disabledReason: request.status !== "pending_approval"
? "i18n:govoplan-admin.request_is_not_pending_approval.6bf3c031"
: !canApprove
? ADMIN_INTERFACE_I18N.governanceWriteRequired
: busyId
? ADMIN_INTERFACE_I18N.busy
: undefined,
onClick: () => setApproving(request)
}]} />
}
];
const historyColumns: DataGridColumn<ConfigurationChangeRecord>[] = [
{ id: "version", header: "i18n:govoplan-admin.version.2da600bf", width: 100, sortable: true, value: (record) => record.version, render: (record) => `#${record.version}` },
{ id: "setting", header: "i18n:govoplan-admin.setting.fb449f71", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (record) => `${record.key} ${record.id}`, render: (record) => <div><strong>{record.key}</strong><span className="muted block">{record.id}</span></div> },
{ id: "status", header: "i18n:govoplan-admin.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (record) => record.status, render: (record) => <StatusBadge status={statusTone(record.status)} label={record.status} /> },
{ id: "applied", header: "i18n:govoplan-admin.applied.a3e4a569", width: 180, sortable: true, value: (record) => record.created_at, render: (record) => formatDateTime(record.created_at) },
{ id: "approvers", header: "i18n:govoplan-admin.approvers.0e2de1fb", width: 120, sortable: true, value: (record) => record.approval_user_ids.length, render: (record) => record.approval_user_ids.length || "-" },
{ id: "target", header: "i18n:govoplan-admin.target.61ad50a9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, filterable: true, value: (record) => targetLabel(record.target), render: (record) => targetLabel(record.target) }
];
return ( return (
<>
<AdminPageLayout <AdminPageLayout
title="i18n:govoplan-admin.configuration_changes.82933bbb" title="i18n:govoplan-admin.configuration_changes.82933bbb"
description="i18n:govoplan-admin.safety_controlled_configuration_requests_approva.e8259509" description="i18n:govoplan-admin.safety_controlled_configuration_requests_approva.e8259509"
loading={loading} loading={loading}
error={error} error={error}
success={message} success={message}
actions={<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} /> i18n:govoplan-admin.reload.cce71553</Button>}> actions={<><DocumentationHelpLink reference={ADMIN_GOVERNANCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading || Boolean(busyId)} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : busyId ? ADMIN_INTERFACE_I18N.busy : undefined}><RefreshCw size={16} /> i18n:govoplan-admin.reload.cce71553</Button></>}>
<Card title="i18n:govoplan-admin.requests.f7194e6a"> <Card title="i18n:govoplan-admin.requests.f7194e6a">
<div className="admin-table-wrap"> <DataGrid
<table className="admin-table"> id="admin-configuration-change-requests"
<thead> rows={pending}
<tr> columns={requestColumns}
<th>i18n:govoplan-admin.setting.fb449f71</th> getRowKey={(request) => request.id}
<th>i18n:govoplan-admin.status.bae7d5be</th> emptyText="i18n:govoplan-admin.no_open_requests.580c95f9"
<th>i18n:govoplan-admin.requested.c26bf60f</th> />
<th>i18n:govoplan-admin.approvals.deb9d03c</th>
<th>i18n:govoplan-admin.target.61ad50a9</th>
<th className="actions">i18n:govoplan-admin.action.97c89a4d</th>
</tr>
</thead>
<tbody>
{pending.map((request) =>
<tr key={request.id}>
<td><strong>{request.label || request.key}</strong><span className="muted block">{request.key}</span></td>
<td><StatusBadge status={statusTone(request.status)} label={request.status} /></td>
<td>{formatDateTime(request.requested_at)}</td>
<td>{request.approvals.length}</td>
<td>{targetLabel(request.target)}</td>
<td className="actions">
{canApprove && request.status === "pending_approval" &&
<Button onClick={() => void approve(request)} disabled={busyId === request.id}><Check size={16} /> i18n:govoplan-admin.approve.7b2c7f14</Button>
}
</td>
</tr>
)}
{!pending.length && <tr><td colSpan={6} className="muted">i18n:govoplan-admin.no_open_requests.580c95f9</td></tr>}
</tbody>
</table>
</div>
</Card> </Card>
<Card title="i18n:govoplan-admin.history.90ccd649"> <Card title="i18n:govoplan-admin.history.90ccd649">
<div className="admin-table-wrap"> <DataGrid
<table className="admin-table"> id="admin-configuration-change-history"
<thead> rows={history}
<tr> columns={historyColumns}
<th>i18n:govoplan-admin.version.2da600bf</th> getRowKey={(record) => record.id}
<th>i18n:govoplan-admin.setting.fb449f71</th> emptyText="i18n:govoplan-admin.no_applied_configuration_changes.6a3ae4a7"
<th>i18n:govoplan-admin.status.bae7d5be</th> />
<th>i18n:govoplan-admin.applied.a3e4a569</th>
<th>i18n:govoplan-admin.approvers.0e2de1fb</th>
<th>i18n:govoplan-admin.target.61ad50a9</th>
</tr>
</thead>
<tbody>
{history.map((record) =>
<tr key={record.id}>
<td>#{record.version}</td>
<td><strong>{record.key}</strong><span className="muted block">{record.id}</span></td>
<td><StatusBadge status={statusTone(record.status)} label={record.status} /></td>
<td>{formatDateTime(record.created_at)}</td>
<td>{record.approval_user_ids.length || "-"}</td>
<td>{targetLabel(record.target)}</td>
</tr>
)}
{!history.length && <tr><td colSpan={6} className="muted">i18n:govoplan-admin.no_applied_configuration_changes.6a3ae4a7</td></tr>}
</tbody>
</table>
</div>
</Card> </Card>
</AdminPageLayout>); </AdminPageLayout>
<ConfirmDialog
open={Boolean(approving)}
title={ADMIN_INTERFACE_I18N.approveTitle}
message={ADMIN_INTERFACE_I18N.approveMessage}
confirmLabel="i18n:govoplan-admin.approve.7b2c7f14"
busy={Boolean(busyId)}
onCancel={() => setApproving(null)}
onConfirm={() => approving && void approve(approving)}
/>
</>);
} }
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Check, Download, Play, RefreshCw } from "lucide-react"; import { Check, Download, Play, RefreshCw } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { AdminPageLayout, Button, Card, StatusBadge, adminErrorMessage, i18nMessage } from "@govoplan/core-webui"; import { AdminPageLayout, Button, Card, ConfirmDialog, DataGrid, DocumentationHelpLink, FormField, ReferenceSelect, StatusBadge, ToggleSwitch, adminErrorMessage, i18nMessage, type DataGridColumn } from "@govoplan/core-webui";
import { import {
applyConfigurationPackage, applyConfigurationPackage,
createConfigurationChangeRequest, createConfigurationChangeRequest,
@@ -13,6 +13,11 @@ import {
type ConfigurationPackagePlanItem, type ConfigurationPackagePlanItem,
type ConfigurationPackageRequiredData } from type ConfigurationPackageRequiredData } from
"../../api/admin"; "../../api/admin";
import {
createChangeRequestReferenceProvider,
createTenantReferenceProvider
} from "./configurationReferenceProviders";
import { ADMIN_GOVERNANCE_DOCUMENTATION, ADMIN_INTERFACE_I18N } from "./interfacePatterns";
const SAMPLE_ACCESS_PACKAGE = { const SAMPLE_ACCESS_PACKAGE = {
package_id: "govoplan.access.minimal-office", package_id: "govoplan.access.minimal-office",
@@ -60,24 +65,43 @@ type ApplyResult = {
updated_refs: Record<string, string>; updated_refs: Record<string, string>;
}; };
export default function ConfigurationPackagesPanel({ settings, canWrite }: {settings: ApiSettings;canWrite: boolean;}) { export default function ConfigurationPackagesPanel({ settings, auth, canWrite }: {settings: ApiSettings;auth: AuthInfo;canWrite: boolean;}) {
const [catalogValidation, setCatalogValidation] = useState<Record<string, unknown> | null>(null); const [catalogValidation, setCatalogValidation] = useState<Record<string, unknown> | null>(null);
const [packageText, setPackageText] = useState(() => JSON.stringify(SAMPLE_ACCESS_PACKAGE, null, 2)); const [packageText, setPackageText] = useState(() => JSON.stringify(SAMPLE_ACCESS_PACKAGE, null, 2));
const [tenantId, setTenantId] = useState(""); const activeTenantId = auth.active_tenant?.id ?? auth.tenant.id;
const [tenantId, setTenantId] = useState(activeTenantId);
const [suppliedDataText, setSuppliedDataText] = useState("{}"); const [suppliedDataText, setSuppliedDataText] = useState("{}");
const [changeRequestId, setChangeRequestId] = useState(""); const [changeRequestId, setChangeRequestId] = useState("");
const [manualReferences, setManualReferences] = useState(false);
const [dryRun, setDryRun] = useState<DryRunResult | null>(null); const [dryRun, setDryRun] = useState<DryRunResult | null>(null);
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null); const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
const [exportText, setExportText] = useState(""); const [exportText, setExportText] = useState("");
const [lastRequest, setLastRequest] = useState<ConfigurationChangeRequest | null>(null); const [lastRequest, setLastRequest] = useState<ConfigurationChangeRequest | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
const [confirmApply, setConfirmApply] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const parsedPackage = useMemo(() => parseObject(packageText), [packageText]); const parsedPackage = useMemo(() => parseObject(packageText), [packageText]);
const parsedSuppliedData = useMemo(() => parseObject(suppliedDataText), [suppliedDataText]); const parsedSuppliedData = useMemo(() => parseObject(suppliedDataText), [suppliedDataText]);
const canRun = Boolean(parsedPackage.value && parsedSuppliedData.value); const canRun = Boolean(parsedPackage.value && parsedSuppliedData.value);
const tenantProvider = useMemo(
() => createTenantReferenceProvider(settings),
[settings.accessToken, settings.apiBaseUrl, settings.apiKey]
);
const changeRequestProvider = useMemo(
() => createChangeRequestReferenceProvider(settings, {
purpose: "configuration_packages.apply",
tenantId
}),
[
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey,
tenantId
]
);
async function loadCatalog() { async function loadCatalog() {
setLoading(true); setLoading(true);
@@ -93,6 +117,9 @@ export default function ConfigurationPackagesPanel({ settings, canWrite }: {sett
} }
useEffect(() => {void loadCatalog();}, [settings.accessToken, settings.apiBaseUrl]); useEffect(() => {void loadCatalog();}, [settings.accessToken, settings.apiBaseUrl]);
useEffect(() => {
setTenantId((current) => current || activeTenantId);
}, [activeTenantId]);
async function runDryRun() { async function runDryRun() {
const manifest = requireParsedPackage(); const manifest = requireParsedPackage();
@@ -209,13 +236,14 @@ export default function ConfigurationPackagesPanel({ settings, canWrite }: {sett
} }
return ( return (
<>
<AdminPageLayout <AdminPageLayout
title="i18n:govoplan-admin.configuration_packages.eb2f05f1" title="i18n:govoplan-admin.configuration_packages.eb2f05f1"
description="i18n:govoplan-admin.import_preflight_approve_apply_and_export_module.29aec929" description="i18n:govoplan-admin.import_preflight_approve_apply_and_export_module.29aec929"
loading={loading} loading={loading}
error={error || parsedPackage.error || parsedSuppliedData.error || ""} error={error || parsedPackage.error || parsedSuppliedData.error || ""}
success={message} success={message}
actions={<Button onClick={() => void loadCatalog()} disabled={loading || Boolean(busy)}><RefreshCw size={16} /> i18n:govoplan-admin.reload.cce71553</Button>}> actions={<><DocumentationHelpLink reference={ADMIN_GOVERNANCE_DOCUMENTATION} /><Button onClick={() => void loadCatalog()} disabled={loading || Boolean(busy)} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : busy ? ADMIN_INTERFACE_I18N.busy : undefined}><RefreshCw size={16} /> i18n:govoplan-admin.reload.cce71553</Button></>}>
<Card title="i18n:govoplan-admin.catalog.4a88d27b"> <Card title="i18n:govoplan-admin.catalog.4a88d27b">
<div className="button-row compact-actions"> <div className="button-row compact-actions">
@@ -228,16 +256,58 @@ export default function ConfigurationPackagesPanel({ settings, canWrite }: {sett
<Card title="i18n:govoplan-admin.package.7431e3df"> <Card title="i18n:govoplan-admin.package.7431e3df">
<div className="module-installer-request-grid"> <div className="module-installer-request-grid">
<label className="wide"><span>i18n:govoplan-admin.tenant_id.59eba244</span><input value={tenantId} onChange={(event) => setTenantId(event.target.value)} placeholder="active tenant" /></label> <div className="wide">
<label className="wide"><span>i18n:govoplan-admin.change_request_id.96ee3239</span><input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." /></label> <ToggleSwitch
checked={manualReferences}
onChange={setManualReferences}
label={ADMIN_INTERFACE_I18N.enterReferencesManually}
help={ADMIN_INTERFACE_I18N.manualReferenceHelp}
/>
</div>
<div className="wide">
<FormField label="i18n:govoplan-admin.tenant_id.59eba244" documentation={ADMIN_GOVERNANCE_DOCUMENTATION}>
{manualReferences ? (
<input value={tenantId} onChange={(event) => setTenantId(event.target.value)} placeholder="active tenant" />
) : (
<ReferenceSelect
value={tenantId}
onChange={(value) => {
setTenantId(value);
setChangeRequestId("");
}}
provider={tenantProvider}
aria-label={ADMIN_INTERFACE_I18N.tenantPickerLabel}
placeholder={ADMIN_INTERFACE_I18N.selectTenant}
disabled={Boolean(busy)}
/>
)}
</FormField>
</div>
<div className="wide">
<FormField label="i18n:govoplan-admin.change_request_id.96ee3239" documentation={ADMIN_GOVERNANCE_DOCUMENTATION}>
{manualReferences ? (
<input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." />
) : (
<ReferenceSelect
value={changeRequestId}
onChange={(value) => setChangeRequestId(value)}
provider={changeRequestProvider}
aria-label={ADMIN_INTERFACE_I18N.requestPickerLabel}
placeholder={ADMIN_INTERFACE_I18N.selectEligibleRequest}
emptyText={ADMIN_INTERFACE_I18N.noEligibleRequests}
disabled={Boolean(busy)}
/>
)}
</FormField>
</div>
<label className="wide"><span>i18n:govoplan-admin.package_json.a2b10f38</span><textarea rows={18} value={packageText} onChange={(event) => setPackageText(event.target.value)} /></label> <label className="wide"><span>i18n:govoplan-admin.package_json.a2b10f38</span><textarea rows={18} value={packageText} onChange={(event) => setPackageText(event.target.value)} /></label>
<label className="wide"><span>i18n:govoplan-admin.supplied_data_json.6932bfb7</span><textarea rows={6} value={suppliedDataText} onChange={(event) => setSuppliedDataText(event.target.value)} /></label> <label className="wide"><span>i18n:govoplan-admin.supplied_data_json.6932bfb7</span><textarea rows={6} value={suppliedDataText} onChange={(event) => setSuppliedDataText(event.target.value)} /></label>
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => void runDryRun()} disabled={!canRun || Boolean(busy)}><Play size={16} /> i18n:govoplan-admin.dry_run.3d14659c</Button> <Button onClick={() => void runDryRun()} disabled={!canRun || Boolean(busy)} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : !canRun ? ADMIN_INTERFACE_I18N.validJsonRequired : undefined}><Play size={16} /> i18n:govoplan-admin.dry_run.3d14659c</Button>
<Button onClick={() => void requestApproval()} disabled={!canWrite || !parsedPackage.value || Boolean(busy)}><Check size={16} /> i18n:govoplan-admin.request_approval.6245aea1</Button> <Button onClick={() => void requestApproval()} disabled={!canWrite || !parsedPackage.value || Boolean(busy)} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !parsedPackage.value ? ADMIN_INTERFACE_I18N.packageRequired : undefined}><Check size={16} /> i18n:govoplan-admin.request_approval.6245aea1</Button>
<Button variant="primary" onClick={() => void applyPackage()} disabled={!canWrite || !canRun || Boolean(busy)}><Check size={16} /> i18n:govoplan-admin.apply.cfea419c</Button> <Button variant="primary" onClick={() => setConfirmApply(true)} disabled={!canWrite || !canRun || Boolean(busy)} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !canRun ? ADMIN_INTERFACE_I18N.validJsonRequired : undefined}><Check size={16} /> i18n:govoplan-admin.apply.cfea419c</Button>
<Button onClick={() => void exportAccessPackage()} disabled={Boolean(busy)}><Download size={16} /> i18n:govoplan-admin.export_access.9a2eb91e</Button> <Button onClick={() => void exportAccessPackage()} disabled={Boolean(busy)} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : undefined}><Download size={16} /> i18n:govoplan-admin.export_access.9a2eb91e</Button>
</div> </div>
{lastRequest && <p className="muted small-note">i18n:govoplan-admin.last_request.4508ef35 {lastRequest.id} ({lastRequest.status})</p>} {lastRequest && <p className="muted small-note">i18n:govoplan-admin.last_request.4508ef35 {lastRequest.id} ({lastRequest.status})</p>}
</Card> </Card>
@@ -257,7 +327,20 @@ export default function ConfigurationPackagesPanel({ settings, canWrite }: {sett
{exportText && <Card title="i18n:govoplan-admin.export.f3e4fadb"> {exportText && <Card title="i18n:govoplan-admin.export.f3e4fadb">
<textarea className="code-panel module-install-plan-commands" rows={16} value={exportText} onChange={(event) => setExportText(event.target.value)} /> <textarea className="code-panel module-install-plan-commands" rows={16} value={exportText} onChange={(event) => setExportText(event.target.value)} />
</Card>} </Card>}
</AdminPageLayout>); </AdminPageLayout>
<ConfirmDialog
open={confirmApply}
title={ADMIN_INTERFACE_I18N.applyTitle}
message={ADMIN_INTERFACE_I18N.applyMessage}
confirmLabel={ADMIN_INTERFACE_I18N.applyConfirm}
busy={busy === "apply"}
onCancel={() => setConfirmApply(false)}
onConfirm={() => {
setConfirmApply(false);
void applyPackage();
}}
/>
</>);
} }
@@ -273,89 +356,68 @@ function parseObject(text: string): {value: Record<string, unknown> | null;error
function PackageDiagnostics({ diagnostics }: {diagnostics: ConfigurationPackageDiagnostic[];}) { function PackageDiagnostics({ diagnostics }: {diagnostics: ConfigurationPackageDiagnostic[];}) {
if (diagnostics.length === 0) return <p className="muted">i18n:govoplan-admin.no_diagnostics.2b6e2630</p>; if (diagnostics.length === 0) return <p className="muted">i18n:govoplan-admin.no_diagnostics.2b6e2630</p>;
const columns: DataGridColumn<ConfigurationPackageDiagnostic>[] = [
{ id: "severity", header: "i18n:govoplan-admin.severity.de314fa0", width: 130, sortable: true, filterable: true, value: (item) => item.severity, render: (item) => <StatusBadge status={diagnosticTone(item.severity)} label={item.severity} /> },
{ id: "code", header: "i18n:govoplan-admin.code.adac6937", width: 190, sortable: true, filterable: true, value: (item) => item.code, render: (item) => <code>{item.code}</code> },
{ id: "owner", header: "i18n:govoplan-admin.owner.89ff3122", width: 150, sortable: true, filterable: true, value: (item) => item.module_id || "-", render: (item) => item.module_id || "-" },
{ id: "object", header: "i18n:govoplan-admin.object.2883f191", width: 180, sortable: true, filterable: true, value: (item) => item.object_ref || "-", render: (item) => item.object_ref || "-" },
{ id: "message", header: "i18n:govoplan-admin.message.68f4145f", width: "minmax(260px, 1fr)", minWidth: 220, resizable: true, filterable: true, value: (item) => `${item.message} ${item.resolution || ""}`, render: (item) => <div>{item.message}{item.resolution ? <span className="muted block">{item.resolution}</span> : null}</div> }
];
return ( return (
<div className="admin-table-wrap"> <DataGrid
<table className="admin-table"> id="admin-configuration-package-diagnostics"
<thead><tr><th>i18n:govoplan-admin.severity.de314fa0</th><th>i18n:govoplan-admin.code.adac6937</th><th>i18n:govoplan-admin.owner.89ff3122</th><th>i18n:govoplan-admin.object.2883f191</th><th>i18n:govoplan-admin.message.68f4145f</th></tr></thead> rows={diagnostics}
<tbody> columns={columns}
{diagnostics.map((item, index) => getRowKey={(item, index) => `${item.code}-${index}`}
<tr key={`${item.code}-${index}`}> />);
<td><StatusBadge status={diagnosticTone(item.severity)} label={item.severity} /></td>
<td><code>{item.code}</code></td>
<td>{item.module_id || "-"}</td>
<td>{item.object_ref || "-"}</td>
<td>{item.message}{item.resolution ? <span className="muted block">{item.resolution}</span> : null}</td>
</tr>
)}
</tbody>
</table>
</div>);
} }
function RequiredData({ items }: {items: ConfigurationPackageRequiredData[];}) { function RequiredData({ items }: {items: ConfigurationPackageRequiredData[];}) {
if (items.length === 0) return null; if (items.length === 0) return null;
const columns: DataGridColumn<ConfigurationPackageRequiredData>[] = [
{ id: "key", header: "i18n:govoplan-admin.key.c67dd20e", width: "minmax(200px, 1fr)", minWidth: 170, resizable: true, sortable: true, filterable: true, value: (item) => item.key, render: (item) => <code>{item.key}</code> },
{ id: "label", header: "i18n:govoplan-admin.label.74341e3c", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (item) => item.label },
{ id: "type", header: "i18n:govoplan-admin.type.3deb7456", width: 140, sortable: true, filterable: true, value: (item) => item.data_type },
{ id: "required", header: "i18n:govoplan-admin.required.eed6bfb4", width: 110, sortable: true, value: (item) => item.required, render: (item) => item.required ? "yes" : "no" },
{ id: "secret", header: "i18n:govoplan-admin.secret.f4e7a874", width: 100, sortable: true, value: (item) => item.secret, render: (item) => item.secret ? "yes" : "no" }
];
return ( return (
<> <>
<h3>i18n:govoplan-admin.required_data.1b1c1b34</h3> <h3>i18n:govoplan-admin.required_data.1b1c1b34</h3>
<div className="admin-table-wrap"> <DataGrid id="admin-configuration-package-required-data" rows={items} columns={columns} getRowKey={(item) => item.key} />
<table className="admin-table">
<thead><tr><th>i18n:govoplan-admin.key.c67dd20e</th><th>i18n:govoplan-admin.label.74341e3c</th><th>i18n:govoplan-admin.type.3deb7456</th><th>i18n:govoplan-admin.required.eed6bfb4</th><th>i18n:govoplan-admin.secret.f4e7a874</th></tr></thead>
<tbody>
{items.map((item) =>
<tr key={item.key}>
<td><code>{item.key}</code></td>
<td>{item.label}</td>
<td>{item.data_type}</td>
<td>{item.required ? "yes" : "no"}</td>
<td>{item.secret ? "yes" : "no"}</td>
</tr>
)}
</tbody>
</table>
</div>
</>); </>);
} }
function PackagePlan({ items }: {items: ConfigurationPackagePlanItem[];}) { function PackagePlan({ items }: {items: ConfigurationPackagePlanItem[];}) {
if (items.length === 0) return <p className="muted">i18n:govoplan-admin.no_plan_items.7108c582</p>; if (items.length === 0) return <p className="muted">i18n:govoplan-admin.no_plan_items.7108c582</p>;
const columns: DataGridColumn<ConfigurationPackagePlanItem>[] = [
{ id: "action", header: "i18n:govoplan-admin.action.97c89a4d", width: 130, sortable: true, filterable: true, value: (item) => item.action, render: (item) => <StatusBadge status={planTone(item.action)} label={item.action} /> },
{ id: "module", header: "i18n:govoplan-admin.module.b8ff0289", width: 170, sortable: true, filterable: true, value: (item) => item.module_id },
{ id: "fragment", header: "i18n:govoplan-admin.fragment.3f19d616", width: 170, sortable: true, filterable: true, value: (item) => item.fragment_type },
{ id: "id", header: "i18n:govoplan-admin.id.474ae526", width: 180, sortable: true, filterable: true, value: (item) => item.fragment_id || "-", render: (item) => item.fragment_id || "-" },
{ id: "summary", header: "i18n:govoplan-admin.summary.12b71c3e", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (item) => item.summary || "-", render: (item) => item.summary || "-" }
];
return ( return (
<> <>
<h3>i18n:govoplan-admin.plan.ae2f98a0</h3> <h3>i18n:govoplan-admin.plan.ae2f98a0</h3>
<div className="admin-table-wrap"> <DataGrid id="admin-configuration-package-plan" rows={items} columns={columns} getRowKey={(item, index) => `${item.module_id}-${item.fragment_type}-${item.fragment_id ?? index}`} />
<table className="admin-table">
<thead><tr><th>i18n:govoplan-admin.action.97c89a4d</th><th>i18n:govoplan-admin.module.b8ff0289</th><th>i18n:govoplan-admin.fragment.3f19d616</th><th>i18n:govoplan-admin.id.474ae526</th><th>i18n:govoplan-admin.summary.12b71c3e</th></tr></thead>
<tbody>
{items.map((item, index) =>
<tr key={`${item.module_id}-${item.fragment_type}-${item.fragment_id ?? index}`}>
<td><StatusBadge status={planTone(item.action)} label={item.action} /></td>
<td>{item.module_id}</td>
<td>{item.fragment_type}</td>
<td>{item.fragment_id || "-"}</td>
<td>{item.summary || "-"}</td>
</tr>
)}
</tbody>
</table>
</div>
</>); </>);
} }
function ReferenceMap({ title, refs }: {title: string;refs: Record<string, string>;}) { function ReferenceMap({ title, refs }: {title: string;refs: Record<string, string>;}) {
const entries = Object.entries(refs); const entries = Object.entries(refs).map(([key, value]) => ({ key, value }));
if (entries.length === 0) return null; if (entries.length === 0) return null;
const columns: DataGridColumn<{key: string;value: string;}>[] = [
{ id: "key", header: "i18n:govoplan-admin.key.c67dd20e", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (item) => item.key, render: (item) => <code>{item.key}</code> },
{ id: "value", header: "Value", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (item) => item.value }
];
return ( return (
<> <>
<h3>{title}</h3> <h3>{title}</h3>
<div className="admin-table-wrap"> <DataGrid id={`admin-configuration-package-refs-${title}`} rows={entries} columns={columns} getRowKey={(item) => item.key} />
<table className="admin-table">
<tbody>
{entries.map(([key, value]) => <tr key={key}><td><code>{key}</code></td><td>{value}</td></tr>)}
</tbody>
</table>
</div>
</>); </>);
} }
@@ -19,7 +19,8 @@ import {
type PermissionItem, type PermissionItem,
type TenantAdminItem } from type TenantAdminItem } from
"../../api/admin"; "../../api/admin";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { ADMIN_GOVERNANCE_DOCUMENTATION, ADMIN_INTERFACE_I18N, mutationDisabledReason } from "./interfacePatterns";
const emptyDraft = { const emptyDraft = {
slug: "", slug: "",
@@ -54,6 +55,7 @@ export default function GovernanceTemplatesPanel({
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey; const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
const permissionsByScope = useMemo(() => new Map(permissions.map((permission) => [permission.scope, permission])), [permissions]);
useUnsavedDraftGuard({ useUnsavedDraftGuard({
dirty, dirty,
@@ -186,11 +188,11 @@ export default function GovernanceTemplatesPanel({
{ id: "status", header: "i18n:govoplan-admin.status.bae7d5be", 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: "status", header: "i18n:govoplan-admin.status.bae7d5be", 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: "i18n:govoplan-admin.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", id: "actions", header: "i18n:govoplan-admin.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right",
render: (row) => <div className="admin-icon-actions"> render: (row) => <TableActionGroup actions={[
<AdminIconButton label={i18nMessage("i18n:govoplan-admin.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-admin.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-admin.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} /> { id: "edit", label: i18nMessage("i18n:govoplan-admin.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canWrite, disabledReason: !canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : undefined, onClick: () => openEdit(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-admin.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} /> { id: "delete", label: i18nMessage("i18n:govoplan-admin.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, disabledReason: !canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : undefined, onClick: () => setDeleting(row) }
</div> ]} />
}], }],
[canWrite, kind, tenants]); [canWrite, kind, tenants]);
@@ -207,7 +209,7 @@ export default function GovernanceTemplatesPanel({
loading={loading} loading={loading}
error={error} error={error}
success={success} success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-admin.reload.cce71553</Button><AdminIconButton label={kind === "group" ? "i18n:govoplan-admin.add_group_template.b74d8f0f" : "i18n:govoplan-admin.add_tenant_role.fcd904ea"} icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}> actions={<><DocumentationHelpLink reference={ADMIN_GOVERNANCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading || busy} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : busy ? ADMIN_INTERFACE_I18N.busy : undefined}>i18n:govoplan-admin.reload.cce71553</Button><AdminIconButton label={kind === "group" ? "i18n:govoplan-admin.add_group_template.b74d8f0f" : "i18n:govoplan-admin.add_tenant_role.fcd904ea"} icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} disabledReason={!canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : undefined} /></>}>
<div className="admin-table-surface"> <div className="admin-table-surface">
<DataGrid id={`admin-system-${kind}-templates-v3`} rows={items} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText={i18nMessage("i18n:govoplan-admin.no_central_value_templates_found.081149fa", { value0: kind })} /> <DataGrid id={`admin-system-${kind}-templates-v3`} rows={items} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText={i18nMessage("i18n:govoplan-admin.no_central_value_templates_found.081149fa", { value0: kind })} />
@@ -219,7 +221,7 @@ export default function GovernanceTemplatesPanel({
title={editing === "new" ? kind === "group" ? "i18n:govoplan-admin.create_group_template.72407248" : "i18n:govoplan-admin.create_tenant_role.f58db104" : kind === "group" ? "i18n:govoplan-admin.edit_group_template.9bc72d21" : "i18n:govoplan-admin.edit_tenant_role.c15a260d"} title={editing === "new" ? kind === "group" ? "i18n:govoplan-admin.create_group_template.72407248" : "i18n:govoplan-admin.create_tenant_role.f58db104" : kind === "group" ? "i18n:govoplan-admin.edit_group_template.9bc72d21" : "i18n:govoplan-admin.edit_tenant_role.c15a260d"}
onClose={() => !busy && setEditing(null)} onClose={() => !busy && setEditing(null)}
className="admin-dialog admin-dialog-wide" className="admin-dialog admin-dialog-wide"
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-admin.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "i18n:govoplan-admin.saving.56a2285c" : kind === "group" ? "i18n:govoplan-admin.save_template.0885fab2" : "i18n:govoplan-admin.save_tenant_role.8fc0d37d"}</Button></>}> footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : undefined}>i18n:govoplan-admin.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={mutationDisabledReason({ busy, permitted: canWrite, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-admin.saving.56a2285c" : kind === "group" ? "i18n:govoplan-admin.save_template.0885fab2" : "i18n:govoplan-admin.save_tenant_role.8fc0d37d"}</Button></>}>
<div className="admin-form-grid two-columns"> <div className="admin-form-grid two-columns">
<FormField label="i18n:govoplan-admin.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField> <FormField label="i18n:govoplan-admin.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
@@ -238,7 +240,7 @@ export default function GovernanceTemplatesPanel({
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-admin.template_details.d5d75e4d"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-admin.close.bbfa773e</Button>}> <Dialog open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-admin.template_details.d5d75e4d"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-admin.close.bbfa773e</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-admin.kind.e00ac23f</dt><dd>{viewing.kind}</dd></div><div><dt>i18n:govoplan-admin.slug.094da9b9</dt><dd>{viewing.slug}</dd></div><div><dt>i18n:govoplan-admin.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-admin.active.a733b809" : "i18n:govoplan-admin.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-admin.tenants.1f7ae776</dt><dd>{viewing.assignments.length || "i18n:govoplan-admin.none.6eef6648"}</dd></div><div><dt>i18n:govoplan-admin.description.55f8ebc8</dt><dd>{viewing.description || "—"}</dd></div><div><dt>i18n:govoplan-admin.permissions.d06d5557</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>} {viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-admin.kind.e00ac23f</dt><dd>{viewing.kind}</dd></div><div><dt>i18n:govoplan-admin.slug.094da9b9</dt><dd>{viewing.slug}</dd></div><div><dt>i18n:govoplan-admin.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-admin.active.a733b809" : "i18n:govoplan-admin.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-admin.tenants.1f7ae776</dt><dd>{viewing.assignments.length || "i18n:govoplan-admin.none.6eef6648"}</dd></div><div><dt>i18n:govoplan-admin.description.55f8ebc8</dt><dd>{viewing.description || "—"}</dd></div><div><dt>i18n:govoplan-admin.permissions.d06d5557</dt><dd>{viewing.permissions.length ? <PermissionDetails scopes={viewing.permissions} permissionsByScope={permissionsByScope} /> : "—"}</dd></div></dl>}
</Dialog> </Dialog>
<ConfirmDialog open={Boolean(deleting)} title={kind === "group" ? "i18n:govoplan-admin.delete_group_template.8745d842" : "i18n:govoplan-admin.delete_tenant_role.4efa0813"} message={i18nMessage("i18n:govoplan-admin.delete_value_removal_is_blocked_while_a_material.d4eba73d", { value0: deleting?.name })} confirmLabel={kind === "group" ? "i18n:govoplan-admin.delete_template.399bf72a" : "i18n:govoplan-admin.delete_tenant_role.4efa0813"} tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} /> <ConfirmDialog open={Boolean(deleting)} title={kind === "group" ? "i18n:govoplan-admin.delete_group_template.8745d842" : "i18n:govoplan-admin.delete_tenant_role.4efa0813"} message={i18nMessage("i18n:govoplan-admin.delete_value_removal_is_blocked_while_a_material.d4eba73d", { value0: deleting?.name })} confirmLabel={kind === "group" ? "i18n:govoplan-admin.delete_template.399bf72a" : "i18n:govoplan-admin.delete_tenant_role.4efa0813"} tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
@@ -249,3 +251,31 @@ export default function GovernanceTemplatesPanel({
function draftKey(draft: typeof emptyDraft): string { function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft); return JSON.stringify(draft);
} }
function PermissionDetails({ scopes, permissionsByScope }: {scopes: string[];permissionsByScope: ReadonlyMap<string, PermissionItem>;}) {
const groups = groupPermissionScopes(scopes, permissionsByScope);
return (
<div className="admin-permission-details">
{groups.map((group) => <section key={group.module}>
<strong>{group.module}</strong>
<ul>
{group.permissions.map((permission) => <li key={permission.scope}>
<span>{permission.label}</span>
<code>{permission.scope}</code>
</li>)}
</ul>
</section>)}
</div>);
}
function groupPermissionScopes(scopes: string[], permissionsByScope: ReadonlyMap<string, PermissionItem>) {
const groups = new Map<string, {scope: string;label: string}[]>();
for (const scope of [...scopes].sort()) {
const moduleId = scope.split(":", 1)[0] || "other";
const permission = permissionsByScope.get(scope);
const group = groups.get(moduleId) ?? [];
group.push({ scope, label: permission?.label || scope });
groups.set(moduleId, group);
}
return [...groups.entries()].map(([module, permissions]) => ({ module, permissions }));
}
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui"; import type { ApiSettings } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage, Button, dispatchPlatformModulesChanged, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, Button, ConfirmDialog, dispatchPlatformModulesChanged, DocumentationHelpLink, formatDateTime, MetricCard, StageRail, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type FormatDateTimeOptions } from "@govoplan/core-webui";
import { Check, Clock, FileText, Pencil, Send } from "lucide-react";
import { import {
cancelModuleInstallerRequest, cancelModuleInstallerRequest,
clearModuleInstallPlan, clearModuleInstallPlan,
@@ -26,10 +27,30 @@ import {
type ModuleInstallerRunListResponse, type ModuleInstallerRunListResponse,
type ModuleInstallPlanItem, type ModuleInstallPlanItem,
type ModuleInstallPlanResponse, type ModuleInstallPlanResponse,
type ModuleInstallTargetItem,
type ModuleMigrationPlanStep,
type ModuleMigrationTaskPlanItem,
type ModuleLicenseDiagnostics, type ModuleLicenseDiagnostics,
type ModulePackageCatalogResponse, type ModulePackageCatalogResponse,
type ModulePackageCatalogItem } from type ModulePackageCatalogItem } from
"../../api/admin"; "../../api/admin";
import {
installerRequestMatchesPlan,
moduleInstallerQueueBlock,
moduleInstallerWorkflowStages,
type ModuleInstallerQueueBlock,
type ModuleInstallerWorkflowStageId,
type ModuleInstallerWorkflowStageState
} from "./moduleInstallerWorkflow";
import { ADMIN_INTERFACE_I18N, MODULE_LIFECYCLE_DOCUMENTATION } from "./interfacePatterns";
const MODULE_INSTALLER_I18N = {
queueUnavailable: "i18n:govoplan-admin.queue_supervised_run_unavailable.f1a20202",
operatorPlan: "i18n:govoplan-admin.operator_install_plan.b203aabc",
requiredAction: "i18n:govoplan-admin.required_action.f1a20203",
responsibleActor: "i18n:govoplan-admin.who_can_fix_it.f1a20204",
resolutionTarget: "i18n:govoplan-admin.where_to_go.f1a20205"
} as const;
export default function ModuleManagementPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) { export default function ModuleManagementPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) {
const [catalog, setCatalog] = useState<ModuleCatalogResponse | null>(null); const [catalog, setCatalog] = useState<ModuleCatalogResponse | null>(null);
@@ -47,6 +68,9 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
const [planBusy, setPlanBusy] = useState(false); const [planBusy, setPlanBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const [confirmClearPlan, setConfirmClearPlan] = useState(false);
const [confirmMaintenance, setConfirmMaintenance] = useState(false);
const [cancelRequestId, setCancelRequestId] = useState("");
async function load() { async function load() {
setLoading(true); setLoading(true);
@@ -83,6 +107,34 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
const maintenanceEnabled = Boolean(catalog?.maintenance_mode.enabled || installPlan?.maintenance_mode.enabled); 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 planDirty = Boolean(installPlan && JSON.stringify(normalizePlanItems(draftPlanItems)) !== JSON.stringify(normalizePlanItems(installPlan.items)));
const planValid = planValidationError(draftPlanItems) === ""; const planValid = planValidationError(draftPlanItems) === "";
const latestInstallerRequest = installerRequests?.requests[0] ?? null;
const currentInstallerRequest = latestInstallerRequest && installerRequestMatchesPlan(
installPlan?.updated_at,
latestInstallerRequest.created_at
) ? latestInstallerRequest : null;
const currentInstallerRun = currentInstallerRequest
? installerRuns?.runs.find((run) => run.request_id === currentInstallerRequest.request_id) ?? null
: null;
const installerWorkflowInput = {
planItemCount: draftPlanItems.length,
planDirty,
planValid,
preflightAllowed: installPlan?.preflight?.allowed ?? null,
maintenanceEnabled,
canWrite,
canAccessMaintenance,
requestStatus: currentInstallerRequest?.status,
runStatus: currentInstallerRun?.status
};
const installerStages = moduleInstallerWorkflowStages(installerWorkflowInput);
const installerQueueBlock = moduleInstallerQueueBlock(installerWorkflowInput);
const installerQueueBlockReason = installerQueueBlock
? moduleInstallerQueueBlockReason(
installerQueueBlock,
planValidationError(draftPlanItems),
installPlan?.preflight?.issues[0]?.message
)
: "";
useUnsavedDraftGuard({ useUnsavedDraftGuard({
dirty: dirty || planDirty, dirty: dirty || planDirty,
onSave: saveDirtyChanges, onSave: saveDirtyChanges,
@@ -95,6 +147,15 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
moduleStateRequest ? "i18n:govoplan-admin.apply_approved_request.ab218d9a" : "i18n:govoplan-admin.create_request_and_apply.da79e153" : moduleStateRequest ? "i18n:govoplan-admin.apply_approved_request.ab218d9a" : "i18n:govoplan-admin.create_request_and_apply.da79e153" :
moduleStateRequest ? "i18n:govoplan-admin.waiting_for_maintenance.60d00019" : "i18n:govoplan-admin.create_dry_run_request.05b9cbdb"; moduleStateRequest ? "i18n:govoplan-admin.waiting_for_maintenance.60d00019" : "i18n:govoplan-admin.create_dry_run_request.05b9cbdb";
const saveDisabled = !canWrite || !dirty || busy || !maintenanceEnabled && Boolean(moduleStateRequest); const saveDisabled = !canWrite || !dirty || busy || !maintenanceEnabled && Boolean(moduleStateRequest);
const saveDisabledReason = busy
? ADMIN_INTERFACE_I18N.busy
: !canWrite
? ADMIN_INTERFACE_I18N.writeRequired
: !dirty
? ADMIN_INTERFACE_I18N.noPendingChanges
: !maintenanceEnabled && moduleStateRequest
? ADMIN_INTERFACE_I18N.maintenanceRequired
: undefined;
function setModuleDesired(module: ModuleCatalogItem, enabled: boolean) { function setModuleDesired(module: ModuleCatalogItem, enabled: boolean) {
if (module.protected) return; if (module.protected) return;
@@ -310,28 +371,59 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
} }
return ( return (
<>
<AdminPageLayout <AdminPageLayout
title="i18n:govoplan-admin.system_modules.629539f2" title="i18n:govoplan-admin.system_modules.629539f2"
description="i18n:govoplan-admin.installed_modules_active_runtime_state_and_saved.f33907dd" description="i18n:govoplan-admin.installed_modules_active_runtime_state_and_saved.f33907dd"
loading={loading} loading={loading}
error={error} error={error}
success={success} success={success}
actions={<><Button onClick={() => void load()} disabled={loading || busy}>i18n:govoplan-admin.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={saveDisabled}>{busy ? "i18n:govoplan-admin.working.049ac820" : saveLabel}</Button></>}> actions={<><DocumentationHelpLink reference={MODULE_LIFECYCLE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading || busy} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : busy ? ADMIN_INTERFACE_I18N.busy : undefined}>i18n:govoplan-admin.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={saveDisabled} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-admin.working.049ac820" : saveLabel}</Button></>}>
{catalog && <> {catalog && <>
<div className="metric-grid module-management-metrics"> <div className="metric-grid module-management-metrics">
<ModuleMetric label="i18n:govoplan-admin.installed.7bb4405c" value={catalog.modules.length} detail="i18n:govoplan-admin.discovered_packages.660902de" /> <MetricCard label="i18n:govoplan-admin.installed.7bb4405c" value={catalog.modules.length} detail="i18n:govoplan-admin.discovered_packages.660902de" tone="info" />
<ModuleMetric label="i18n:govoplan-admin.active.a733b809" value={activeCount} detail="i18n:govoplan-admin.running_registry.5274b24b" /> <MetricCard label="i18n:govoplan-admin.active.a733b809" value={activeCount} detail="i18n:govoplan-admin.running_registry.5274b24b" tone="info" />
<ModuleMetric label="i18n:govoplan-admin.saved.c0ae8f6e" value={desiredCount} detail="i18n:govoplan-admin.startup_state.d1300be6" /> <MetricCard label="i18n:govoplan-admin.saved.c0ae8f6e" value={desiredCount} detail="i18n:govoplan-admin.startup_state.d1300be6" tone="info" />
<ModuleMetric label="i18n:govoplan-admin.drift.4876f7b9" value={pendingCount} detail={pendingCount ? "i18n:govoplan-admin.runtime_differs.43d1fd78" : "i18n:govoplan-admin.runtime_matches.a84afa48"} tone={pendingCount ? "warning" : "good"} /> <MetricCard label="i18n:govoplan-admin.drift.4876f7b9" value={pendingCount} detail={pendingCount ? "i18n:govoplan-admin.runtime_differs.43d1fd78" : "i18n:govoplan-admin.runtime_matches.a84afa48"} tone={pendingCount ? "warning" : "good"} />
<ModuleMetric label="i18n:govoplan-admin.maintenance.94de303b" value={maintenanceEnabled ? "i18n:govoplan-admin.on.e0049a66" : "i18n:govoplan-admin.off.e3de5ab0"} detail={canAccessMaintenance ? "i18n:govoplan-admin.bypass_allowed.4e347c27" : "i18n:govoplan-admin.bypass_denied.ab987400"} tone={maintenanceEnabled ? "warning" : "info"} /> <MetricCard label="i18n:govoplan-admin.maintenance.94de303b" value={maintenanceEnabled ? "i18n:govoplan-admin.on.e0049a66" : "i18n:govoplan-admin.off.e3de5ab0"} detail={canAccessMaintenance ? "i18n:govoplan-admin.bypass_allowed.4e347c27" : "i18n:govoplan-admin.bypass_denied.ab987400"} tone={maintenanceEnabled ? "warning" : "info"} />
</div> </div>
<StageRail
className="module-installer-stage-rail"
ariaLabel="i18n:govoplan-admin.module_lifecycle_progress.f1a20201"
items={installerStages.map((stage) => ({
id: stage.id,
label: moduleInstallerStageLabel(stage.id),
icon: stage.id === "plan"
? <Pencil size={15} aria-hidden="true" />
: stage.id === "preflight"
? <Check size={15} aria-hidden="true" />
: stage.id === "queue"
? <Send size={15} aria-hidden="true" />
: stage.id === "execute"
? <Clock size={15} aria-hidden="true" />
: <FileText size={15} aria-hidden="true" />,
tone: moduleInstallerStageTone(stage.state),
current: stage.current,
locked: stage.locked,
lockedLabel: "i18n:govoplan-admin.locked.a798882f",
statusLabel: stage.current
? moduleInstallerStageStatus(
stage.id,
installerQueueBlockReason,
installPlan?.preflight?.allowed ?? null,
currentInstallerRequest?.status,
currentInstallerRun?.status
)
: undefined
}))} />
{dirty && <div className="module-management-notes"> {dirty && <div className="module-management-notes">
<p className="alert warning">i18n:govoplan-admin.changing_enabled_modules_requires_a_dry_run_chan.10b6f5ed</p> <p className="alert warning">i18n:govoplan-admin.changing_enabled_modules_requires_a_dry_run_chan.10b6f5ed</p>
{moduleStateRequest && <p>i18n:govoplan-admin.change_request_ready.b17e8c57 <code>{moduleStateRequest.request.id}</code></p>} {moduleStateRequest && <p>i18n:govoplan-admin.change_request_ready.b17e8c57 <code>{moduleStateRequest.request.id}</code></p>}
{!maintenanceEnabled && <div className="button-row compact-actions"> {!maintenanceEnabled && <div className="button-row compact-actions">
<Button onClick={() => void enableMaintenanceMode()} disabled={!canWrite || !canAccessMaintenance || maintenanceBusy}> <Button onClick={() => setConfirmMaintenance(true)} disabled={!canWrite || !canAccessMaintenance || maintenanceBusy} disabledReason={maintenanceBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !canAccessMaintenance ? ADMIN_INTERFACE_I18N.maintenanceAuthorityRequired : undefined}>
{maintenanceBusy ? "i18n:govoplan-admin.enabling.2b8e03e6" : "i18n:govoplan-admin.enable_maintenance_mode.75f98d57"} {maintenanceBusy ? "i18n:govoplan-admin.enabling.2b8e03e6" : "i18n:govoplan-admin.enable_maintenance_mode.75f98d57"}
</Button> </Button>
</div>} </div>}
@@ -352,8 +444,8 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
<div className="module-management-meta"> <div className="module-management-meta">
<ModuleStatus module={module} desiredEnabled={desiredEnabled} /> <ModuleStatus module={module} desiredEnabled={desiredEnabled} />
{module.protected && <StatusBadge status="locked" label="i18n:govoplan-admin.locked.a798882f" />} {module.protected && <StatusBadge status="locked" label="i18n:govoplan-admin.locked.a798882f" />}
{module.frontend_package && <span>{module.frontend_package}</span>} <span>i18n:govoplan-admin.webui_package.19645536 <code>{module.frontend_package ?? "none"}</code></span>
{module.migration_module_id && <span>i18n:govoplan-admin.db.e355c23a {module.migration_module_id}</span>} <span>i18n:govoplan-admin.db.e355c23a <code>{module.migration_module_id ?? "none"}</code></span>
</div> </div>
<div className="module-management-details"> <div className="module-management-details">
<span>i18n:govoplan-admin.requires.a4fc9357 {module.dependencies.length ? module.dependencies.join(", ") : "none"}</span> <span>i18n:govoplan-admin.requires.a4fc9357 {module.dependencies.length ? module.dependencies.join(", ") : "none"}</span>
@@ -366,9 +458,10 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
label={desiredEnabled ? "i18n:govoplan-admin.enabled.df174a3f" : "i18n:govoplan-admin.disabled.f4f4473d"} label={desiredEnabled ? "i18n:govoplan-admin.enabled.df174a3f" : "i18n:govoplan-admin.disabled.f4f4473d"}
checked={desiredEnabled} checked={desiredEnabled}
disabled={!canWrite || module.protected || busy} disabled={!canWrite || module.protected || busy}
help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : module.protected ? ADMIN_INTERFACE_I18N.protectedDefinition : busy ? ADMIN_INTERFACE_I18N.busy : undefined}
onChange={(checked) => setModuleDesired(module, checked)} /> onChange={(checked) => setModuleDesired(module, checked)} />
{module.install_uninstall_supported && <Button onClick={() => void addUninstallPlan(module)} disabled={!canWrite || planBusy || module.current_enabled || module.desired_enabled}>i18n:govoplan-admin.plan_uninstall.e62804ab</Button>} {module.install_uninstall_supported && <Button onClick={() => void addUninstallPlan(module)} disabled={!canWrite || planBusy || module.current_enabled || module.desired_enabled} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : module.current_enabled || module.desired_enabled ? ADMIN_INTERFACE_I18N.deactivateBeforeUninstall : undefined}>i18n:govoplan-admin.plan_uninstall.e62804ab</Button>}
</div> </div>
</div>); </div>);
@@ -414,15 +507,25 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
{item.python_package && <span>{item.python_package}</span>} {item.python_package && <span>{item.python_package}</span>}
{item.webui_package && <span>{item.webui_package}</span>} {item.webui_package && <span>{item.webui_package}</span>}
{item.tags.length > 0 && <span>{item.tags.join(", ")}</span>} {item.tags.length > 0 && <span>{item.tags.join(", ")}</span>}
{item.dependencies.length > 0 && <span>i18n:govoplan-admin.dependencies.9f4f78d1 {item.dependencies.join(", ")}</span>}
{item.migration_safety !== "automatic" && <span>i18n:govoplan-admin.migration_safety.729bdb3c {migrationSafetyLabel(item.migration_safety)}</span>}
{item.migration_after.length > 0 && <span>i18n:govoplan-admin.after.695c89be {item.migration_after.join(", ")}</span>}
{item.migration_before.length > 0 && <span>i18n:govoplan-admin.before.a11cf31f {item.migration_before.join(", ")}</span>}
{item.bridge_release && <span>i18n:govoplan-admin.bridge_release.176cf241</span>}
{(item.current_version_min || item.current_version_max_exclusive) && <span>i18n:govoplan-admin.current_window.73e25c9f {currentWindowLabel(item)}</span>}
{item.recovery_tested && <span>i18n:govoplan-admin.recovery_tested.e831e2f1</span>}
{item.license_features.length > 0 && <span>i18n:govoplan-admin.license.de13bf1a {item.license_features.join(", ")}</span>} {item.license_features.length > 0 && <span>i18n:govoplan-admin.license.de13bf1a {item.license_features.join(", ")}</span>}
{item.license_missing_features.length > 0 && <span>i18n:govoplan-admin.missing.feb2bbaa {item.license_missing_features.join(", ")}</span>} {item.license_missing_features.length > 0 && <span>i18n:govoplan-admin.missing.feb2bbaa {item.license_missing_features.join(", ")}</span>}
{item.provides_interfaces.length > 0 && <span>i18n:govoplan-admin.provides.221b70d9 {providedInterfacesLabel(item)}</span>} {item.provides_interfaces.length > 0 && <span>i18n:govoplan-admin.provides.221b70d9 {providedInterfacesLabel(item)}</span>}
{item.requires_interfaces.length > 0 && <span>i18n:govoplan-admin.requires.a4fc9357 {requiredInterfacesLabel(item)}</span>} {item.requires_interfaces.length > 0 && <span>i18n:govoplan-admin.requires.a4fc9357 {requiredInterfacesLabel(item)}</span>}
</div> </div>
{item.description && <p className="module-package-catalog-description">{item.description}</p>} {item.description && <p className="module-package-catalog-description">{item.description}</p>}
{item.migration_notes && <p className="module-package-catalog-description">{item.migration_notes}</p>}
{item.bridge_notes && <p className="module-package-catalog-description">{item.bridge_notes}</p>}
{item.recovery_notes && <p className="module-package-catalog-description">{item.recovery_notes}</p>}
{item.license_reason && <p className={`module-package-catalog-description${item.license_allowed ? "" : " alert warning"}`}>{item.license_reason}</p>} {item.license_reason && <p className={`module-package-catalog-description${item.license_allowed ? "" : " alert warning"}`}>{item.license_reason}</p>}
</div> </div>
<Button onClick={() => void addCatalogItem(item)} disabled={!canWrite || planBusy || !packageCatalog.valid || !item.license_allowed || item.action !== "install"}>i18n:govoplan-admin.plan_install.e82bffe6</Button> <Button onClick={() => void addCatalogItem(item)} disabled={!canWrite || planBusy || !packageCatalog.valid || !item.license_allowed || !["install", "update"].includes(item.action)} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !packageCatalog.valid || !item.license_allowed || !["install", "update"].includes(item.action) ? ADMIN_INTERFACE_I18N.catalogBlocked : undefined}>{catalogPlanButtonLabel(item, catalog)}</Button>
</div> </div>
)} )}
</div>} </div>}
@@ -435,9 +538,9 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
<p>i18n:govoplan-admin.plan_package_installs_and_removals_here_then_app.4aeb03bf</p> <p>i18n:govoplan-admin.plan_package_installs_and_removals_here_then_app.4aeb03bf</p>
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={addPlanItem} disabled={!canWrite || planBusy}>i18n:govoplan-admin.add_plan_item.fa55f028</Button> <Button onClick={addPlanItem} disabled={!canWrite || planBusy} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : undefined}>i18n:govoplan-admin.add_plan_item.fa55f028</Button>
<Button onClick={() => void clearPlan()} disabled={!canWrite || planBusy || draftPlanItems.length === 0}>i18n:govoplan-admin.clear.719ea396</Button> <Button onClick={() => setConfirmClearPlan(true)} disabled={!canWrite || planBusy || draftPlanItems.length === 0} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : draftPlanItems.length === 0 ? ADMIN_INTERFACE_I18N.planRequired : undefined}>i18n:govoplan-admin.clear.719ea396</Button>
<Button variant="primary" onClick={() => void savePlan()} disabled={!canWrite || planBusy || !planDirty || !planValid}>{planBusy ? "i18n:govoplan-admin.saving.ae7e8875" : "i18n:govoplan-admin.save_plan.842dc280"}</Button> <Button variant="primary" onClick={() => void savePlan()} disabled={!canWrite || planBusy || !planDirty || !planValid} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !planDirty ? ADMIN_INTERFACE_I18N.noPendingChanges : !planValid ? ADMIN_INTERFACE_I18N.planRequired : undefined}>{planBusy ? "i18n:govoplan-admin.saving.ae7e8875" : "i18n:govoplan-admin.save_plan.842dc280"}</Button>
</div> </div>
</div> </div>
@@ -450,6 +553,43 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
<span>{installPlan.preflight.restart_required ? "i18n:govoplan-admin.restart_reload_required_after_package_changes.3a99cd63" : "i18n:govoplan-admin.no_package_restart_pending.efe6b8ce"}</span> <span>{installPlan.preflight.restart_required ? "i18n:govoplan-admin.restart_reload_required_after_package_changes.3a99cd63" : "i18n:govoplan-admin.no_package_restart_pending.efe6b8ce"}</span>
{installPlan.preflight.frontend_rebuild_required && <span>i18n:govoplan-admin.webui_rebuild_required.010ce49f</span>} {installPlan.preflight.frontend_rebuild_required && <span>i18n:govoplan-admin.webui_rebuild_required.010ce49f</span>}
</div> </div>
{installPlan.preflight.target_plan.length > 0 && <div className="module-install-checklist">
<strong>i18n:govoplan-admin.target_plan.524ea0c8</strong>
{installPlan.preflight.target_plan.map((target) =>
<div key={`${target.module_id}-${target.action}`} className={`module-install-checklist-item status-${targetStatus(target)}`}>
<strong>{target.module_id} - {target.action}</strong>
<span>{targetVersionLabel(target)}</span>
<p>{migrationSafetyLabel(target.migration_safety)}{target.data_safety_acknowledged ? " - i18n:govoplan-admin.acknowledged.d08d7c6d" : ""}</p>
{target.bridge_release && <p>i18n:govoplan-admin.bridge_release.176cf241</p>}
{target.recovery_tested && <p>i18n:govoplan-admin.recovery_tested.e831e2f1</p>}
{target.python_ref && <p>{target.python_ref}</p>}
{target.migration_notes && <p>{target.migration_notes}</p>}
{target.bridge_notes && <p>{target.bridge_notes}</p>}
{target.recovery_notes && <p>{target.recovery_notes}</p>}
</div>
)}
</div>}
{(installPlan.preflight.migration_plan.steps.length > 0 || installPlan.preflight.migration_plan.tasks.length > 0) && <div className="module-install-checklist">
<strong>i18n:govoplan-admin.migration_plan.f42b9d90</strong>
<span>i18n:govoplan-admin.enabled_modules.3c38e9ff {installPlan.preflight.migration_plan.enabled_modules.join(", ")}</span>
{installPlan.preflight.migration_plan.steps.map((step, index) =>
<div key={`${step.module_id}-${step.phase}-${index}`} className={`module-install-checklist-item status-${migrationStepStatus(step)}`}>
<strong>{index + 1}. {step.module_id} - {migrationPhaseLabel(step.phase)}</strong>
<span>{migrationSourceLabel(step.source)}{step.metadata_pending ? " - i18n:govoplan-admin.metadata_pending.25190d87" : ""}</span>
<p>{migrationSafetyLabel(step.migration_safety)}</p>
{step.reason && <p>{step.reason}</p>}
</div>
)}
{installPlan.preflight.migration_plan.tasks.length > 0 && <strong>i18n:govoplan-admin.migration_tasks.a4410d3a</strong>}
{installPlan.preflight.migration_plan.tasks.map((task) =>
<div key={`${task.module_id}-${task.task_id}-${task.phase}`} className={`module-install-checklist-item status-${migrationTaskStatus(task)}`}>
<strong>{task.module_id} - {migrationTaskPhaseLabel(task.phase)}</strong>
<span>{task.summary}</span>
<p>{migrationSourceLabel(task.source)}{task.metadata_pending ? " - i18n:govoplan-admin.executor_pending.7633225b" : ""}{task.has_executor ? " - i18n:govoplan-admin.executor_available.507de429" : ""}</p>
<p>{migrationSafetyLabel(task.safety)} - {task.idempotent ? "i18n:govoplan-admin.idempotent.47a0f2d6" : "i18n:govoplan-admin.not_idempotent.d3014c71"} - i18n:govoplan-admin.task_version.f0f26b92 {task.task_version}</p>
</div>
)}
</div>}
{installPlan.preflight.issues.length > 0 && <div className="module-install-preflight-issues"> {installPlan.preflight.issues.length > 0 && <div className="module-install-preflight-issues">
{installPlan.preflight.issues.map((issue, index) => {installPlan.preflight.issues.map((issue, index) =>
<div key={`${issue.code}-${issue.module_id ?? "global"}-${index}`} className={`module-install-preflight-issue severity-${issue.severity}`}> <div key={`${issue.code}-${issue.module_id ?? "global"}-${index}`} className={`module-install-preflight-issue severity-${issue.severity}`}>
@@ -479,16 +619,17 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
{item.catalog?.sequence !== null && item.catalog?.sequence !== undefined && <StatusBadge status="inactive" label={i18nMessage("i18n:govoplan-admin.seq_value.0ae5fa47", { value0: item.catalog.sequence })} />} {item.catalog?.sequence !== null && item.catalog?.sequence !== undefined && <StatusBadge status="inactive" label={i18nMessage("i18n:govoplan-admin.seq_value.0ae5fa47", { value0: item.catalog.sequence })} />}
{item.catalog?.key_id && <StatusBadge status="inactive" label={i18nMessage("i18n:govoplan-admin.key_value.847b914f", { value0: String(item.catalog.key_id) })} />} {item.catalog?.key_id && <StatusBadge status="inactive" label={i18nMessage("i18n:govoplan-admin.key_value.847b914f", { value0: String(item.catalog.key_id) })} />}
</div> </div>
<label><span>i18n:govoplan-admin.action.97c89a4d</span><select value={item.action} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { action: event.target.value as ModuleInstallPlanItem["action"] })}><option value="install">i18n:govoplan-admin.install.fd6c3ebf</option><option value="uninstall">i18n:govoplan-admin.uninstall.a735da1d</option></select></label> <label><span>i18n:govoplan-admin.action.97c89a4d</span><select value={item.action} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { action: event.target.value as ModuleInstallPlanItem["action"] })}><option value="install">i18n:govoplan-admin.install.fd6c3ebf</option><option value="update">i18n:govoplan-admin.update.503a059f</option><option value="uninstall">i18n:govoplan-admin.uninstall.a735da1d</option></select></label>
<label><span>i18n:govoplan-admin.module.b8ff0289</span><input value={item.module_id} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { module_id: event.target.value })} placeholder="files" /></label> <label><span>i18n:govoplan-admin.module.b8ff0289</span><input value={item.module_id} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { module_id: event.target.value })} placeholder="files" /></label>
<label><span>i18n:govoplan-admin.python_package.34591c71</span><input value={item.python_package ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { python_package: event.target.value })} placeholder="govoplan-files" /></label> <label><span>i18n:govoplan-admin.python_package.34591c71</span><input value={item.python_package ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { python_package: event.target.value })} placeholder="govoplan-files" /></label>
<ToggleSwitch label="i18n:govoplan-admin.destroy_data.34557c3c" checked={Boolean(item.destroy_data)} onChange={(checked) => updatePlanItem(index, { destroy_data: checked })} disabled={!canWrite || planBusy || item.action !== "uninstall"} /> <ToggleSwitch label="i18n:govoplan-admin.destroy_data.34557c3c" checked={Boolean(item.destroy_data)} onChange={(checked) => updatePlanItem(index, { destroy_data: checked })} disabled={!canWrite || planBusy || item.action !== "uninstall"} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : planBusy ? ADMIN_INTERFACE_I18N.busy : item.action !== "uninstall" ? ADMIN_INTERFACE_I18N.uninstallOnly : undefined} />
<ToggleSwitch label="i18n:govoplan-admin.data_safety_reviewed.b4774edc" checked={Boolean(item.data_safety_acknowledged)} onChange={(checked) => updatePlanItem(index, { data_safety_acknowledged: checked })} disabled={!canWrite || planBusy || item.action === "uninstall"} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : planBusy ? ADMIN_INTERFACE_I18N.busy : item.action === "uninstall" ? ADMIN_INTERFACE_I18N.installUpdateOnly : undefined} />
<label className="wide"><span>i18n:govoplan-admin.python_ref.1af35d0d</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 className="wide"><span>i18n:govoplan-admin.python_ref.1af35d0d</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>i18n:govoplan-admin.webui_package.19645536</span><input value={item.webui_package ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { webui_package: event.target.value })} placeholder="@govoplan/files-webui" /></label> <label><span>i18n:govoplan-admin.webui_package.19645536</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>i18n:govoplan-admin.webui_ref.cbae3559</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 className="wide"><span>i18n:govoplan-admin.webui_ref.cbae3559</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>i18n:govoplan-admin.status.bae7d5be</span><select value={item.status} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { status: event.target.value as ModuleInstallPlanItem["status"] })}><option value="planned">i18n:govoplan-admin.planned.9cbe42aa</option><option value="applied">i18n:govoplan-admin.applied.a3e4a569</option><option value="blocked">i18n:govoplan-admin.blocked.99613c74</option></select></label> <label><span>i18n:govoplan-admin.status.bae7d5be</span><select value={item.status} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { status: event.target.value as ModuleInstallPlanItem["status"] })}><option value="planned">i18n:govoplan-admin.planned.9cbe42aa</option><option value="applied">i18n:govoplan-admin.applied.a3e4a569</option><option value="blocked">i18n:govoplan-admin.blocked.99613c74</option></select></label>
<label className="wide"><span>i18n:govoplan-admin.notes.70440046</span><input value={item.notes ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { notes: event.target.value })} /></label> <label className="wide"><span>i18n:govoplan-admin.notes.70440046</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}>i18n:govoplan-admin.remove.e963907d</Button></div> <div className="module-install-plan-actions"><Button onClick={() => setDraftPlanItems((items) => items.filter((_, itemIndex) => itemIndex !== index))} disabled={!canWrite || planBusy} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : undefined}>i18n:govoplan-admin.remove.e963907d</Button></div>
</div> </div>
)} )}
</div> </div>
@@ -503,18 +644,33 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
<h2>i18n:govoplan-admin.daemon_execution.cc0fad8d</h2> <h2>i18n:govoplan-admin.daemon_execution.cc0fad8d</h2>
<p>i18n:govoplan-admin.queue_the_saved_plan_for_a_separate_installer_da.92d4c96e</p> <p>i18n:govoplan-admin.queue_the_saved_plan_for_a_separate_installer_da.92d4c96e</p>
</div> </div>
<Button variant="primary" onClick={() => void queueInstallerRequest()} disabled={!canWrite || !canAccessMaintenance || planBusy || planDirty || !maintenanceEnabled || !installPlan.preflight?.allowed}> <Button variant="primary" onClick={() => void queueInstallerRequest()} disabled={Boolean(installerQueueBlock) || planBusy} disabledReason={installerQueueBlockReason || undefined}>
i18n:govoplan-admin.queue_supervised_run.a53f248c i18n:govoplan-admin.queue_supervised_run.a53f248c
</Button> </Button>
</div> </div>
{planDirty && <p className="alert warning">i18n:govoplan-admin.save_the_install_plan_before_queueing_a_daemon_r.3ba00691</p>} {installerQueueBlock ? (
{!maintenanceEnabled && <p className="alert warning">i18n:govoplan-admin.maintenance_mode_must_be_enabled_before_queueing.d233a32f</p>} <ActionBlockerHint
{!canAccessMaintenance && <p className="alert warning">i18n:govoplan-admin.queueing_installer_requests_requires_maintenance.ae81f9ac</p>} reason={{
summary: MODULE_INSTALLER_I18N.queueUnavailable,
requiredAction: installerQueueBlockReason,
actor: moduleInstallerQueueBlockActor(installerQueueBlock),
target: MODULE_INSTALLER_I18N.operatorPlan,
technicalDetails: installerQueueBlock === "preflight"
? installPlan.preflight?.issues[0]?.message
: undefined
}}
labels={{
requiredAction: MODULE_INSTALLER_I18N.requiredAction,
actor: MODULE_INSTALLER_I18N.responsibleActor,
target: MODULE_INSTALLER_I18N.resolutionTarget
}}
documentation={MODULE_LIFECYCLE_DOCUMENTATION} />
) : null}
<div className="module-installer-request-grid"> <div className="module-installer-request-grid">
<ToggleSwitch label="i18n:govoplan-admin.run_migrations.db6e0ce2" checked={requestOptions.migrateDatabase} onChange={(checked) => setRequestOptions((current) => ({ ...current, migrateDatabase: checked }))} disabled={!canWrite || planBusy} /> <ToggleSwitch label="i18n:govoplan-admin.run_migrations.db6e0ce2" checked={requestOptions.migrateDatabase} onChange={(checked) => setRequestOptions((current) => ({ ...current, migrateDatabase: checked }))} disabled={!canWrite || planBusy} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : planBusy ? ADMIN_INTERFACE_I18N.busy : undefined} />
<ToggleSwitch label="i18n:govoplan-admin.build_webui.fe8ccad7" checked={requestOptions.buildWebui} onChange={(checked) => setRequestOptions((current) => ({ ...current, buildWebui: checked }))} disabled={!canWrite || planBusy} /> <ToggleSwitch label="i18n:govoplan-admin.build_webui.fe8ccad7" checked={requestOptions.buildWebui} onChange={(checked) => setRequestOptions((current) => ({ ...current, buildWebui: checked }))} disabled={!canWrite || planBusy} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : planBusy ? ADMIN_INTERFACE_I18N.busy : undefined} />
<ToggleSwitch label="i18n:govoplan-admin.activate_installs.731d22a1" checked={requestOptions.activateInstalledModules} onChange={(checked) => setRequestOptions((current) => ({ ...current, activateInstalledModules: checked }))} disabled={!canWrite || planBusy} /> <ToggleSwitch label="i18n:govoplan-admin.activate_installs.731d22a1" checked={requestOptions.activateInstalledModules} onChange={(checked) => setRequestOptions((current) => ({ ...current, activateInstalledModules: checked }))} disabled={!canWrite || planBusy} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : planBusy ? ADMIN_INTERFACE_I18N.busy : undefined} />
<ToggleSwitch label="i18n:govoplan-admin.drop_uninstalls_from_startup.0e2a5c5b" checked={requestOptions.removeUninstalledModulesFromDesired} onChange={(checked) => setRequestOptions((current) => ({ ...current, removeUninstalledModulesFromDesired: checked }))} disabled={!canWrite || planBusy} /> <ToggleSwitch label="i18n:govoplan-admin.drop_uninstalls_from_startup.0e2a5c5b" checked={requestOptions.removeUninstalledModulesFromDesired} onChange={(checked) => setRequestOptions((current) => ({ ...current, removeUninstalledModulesFromDesired: checked }))} disabled={!canWrite || planBusy} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : planBusy ? ADMIN_INTERFACE_I18N.busy : undefined} />
<label><span>i18n:govoplan-admin.health_urls.3b86647c</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>i18n:govoplan-admin.health_urls.3b86647c</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>i18n:govoplan-admin.restart_commands.035997f9</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>i18n:govoplan-admin.restart_commands.035997f9</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>i18n:govoplan-admin.db_backup_command.5bded3ac</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>i18n:govoplan-admin.db_backup_command.5bded3ac</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>
@@ -545,18 +701,18 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
<StatusBadge status={statusTone(request.status)} label={request.status} /> <StatusBadge status={statusTone(request.status)} label={request.status} />
</div> </div>
<div className="module-management-details"> <div className="module-management-details">
<span>i18n:govoplan-admin.created.0c78dab1 {formatDateTime(request.created_at)}</span> <span>i18n:govoplan-admin.created.0c78dab1 {formatDateTime(request.created_at, ADMIN_DATE_TIME_OPTIONS)}</span>
<span>i18n:govoplan-admin.started.fe3824e9 {formatDateTime(request.started_at)}</span> <span>i18n:govoplan-admin.started.fe3824e9 {formatDateTime(request.started_at, ADMIN_DATE_TIME_OPTIONS)}</span>
<span>i18n:govoplan-admin.finished.4b52fe3f {formatDateTime(request.finished_at)}</span> <span>i18n:govoplan-admin.finished.4b52fe3f {formatDateTime(request.finished_at, ADMIN_DATE_TIME_OPTIONS)}</span>
{request.cancelled_at && <span>i18n:govoplan-admin.cancelled.8c9dcfa8 {formatDateTime(request.cancelled_at)}</span>} {request.cancelled_at && <span>i18n:govoplan-admin.cancelled.8c9dcfa8 {formatDateTime(request.cancelled_at, ADMIN_DATE_TIME_OPTIONS)}</span>}
{request.retry_of && <span>i18n:govoplan-admin.retry_of.3a6bb304 {request.retry_of}</span>} {request.retry_of && <span>i18n:govoplan-admin.retry_of.3a6bb304 {request.retry_of}</span>}
{traceId(request.trace) && <span>i18n:govoplan-admin.trace.04a75036 <code>{traceId(request.trace)}</code></span>} {traceId(request.trace) && <span>i18n:govoplan-admin.trace.04a75036 <code>{traceId(request.trace)}</code></span>}
</div> </div>
{request.error && <p className="module-installer-run-error">{request.error}</p>} {request.error && <p className="module-installer-run-error">{request.error}</p>}
</div> </div>
<div className="module-installer-run-actions"> <div className="module-installer-run-actions">
{request.status === "queued" && <Button onClick={() => void cancelInstallerRequest(request.request_id)} disabled={!canWrite || !canAccessMaintenance || !maintenanceEnabled || planBusy}>i18n:govoplan-admin.cancel.77dfd213</Button>} {request.status === "queued" && <Button onClick={() => setCancelRequestId(request.request_id)} disabled={!canWrite || !canAccessMaintenance || !maintenanceEnabled || planBusy} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !canAccessMaintenance ? ADMIN_INTERFACE_I18N.maintenanceAuthorityRequired : !maintenanceEnabled ? ADMIN_INTERFACE_I18N.maintenanceRequired : undefined}>i18n:govoplan-admin.cancel.77dfd213</Button>}
{(request.status === "failed" || request.status === "cancelled") && <Button onClick={() => void retryInstallerRequest(request.request_id)} disabled={!canWrite || !canAccessMaintenance || !maintenanceEnabled || planBusy}>i18n:govoplan-admin.retry.9f5cd8a2</Button>} {(request.status === "failed" || request.status === "cancelled") && <Button onClick={() => void retryInstallerRequest(request.request_id)} disabled={!canWrite || !canAccessMaintenance || !maintenanceEnabled || planBusy} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !canAccessMaintenance ? ADMIN_INTERFACE_I18N.maintenanceAuthorityRequired : !maintenanceEnabled ? ADMIN_INTERFACE_I18N.maintenanceRequired : undefined}>i18n:govoplan-admin.retry.9f5cd8a2</Button>}
{request.record_path && <code>{request.record_path}</code>} {request.record_path && <code>{request.record_path}</code>}
</div> </div>
</div> </div>
@@ -588,8 +744,8 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
{run.request_id && <span>i18n:govoplan-admin.request.43c2e7a7 {run.request_id}</span>} {run.request_id && <span>i18n:govoplan-admin.request.43c2e7a7 {run.request_id}</span>}
{traceId(run.trace) && <span>i18n:govoplan-admin.trace.04a75036 <code>{traceId(run.trace)}</code></span>} {traceId(run.trace) && <span>i18n:govoplan-admin.trace.04a75036 <code>{traceId(run.trace)}</code></span>}
<span>i18n:govoplan-admin.commands.f6a34aed {run.commands_count}</span> <span>i18n:govoplan-admin.commands.f6a34aed {run.commands_count}</span>
<span>i18n:govoplan-admin.started.fe3824e9 {formatDateTime(run.started_at)}</span> <span>i18n:govoplan-admin.started.fe3824e9 {formatDateTime(run.started_at, ADMIN_DATE_TIME_OPTIONS)}</span>
<span>i18n:govoplan-admin.finished.4b52fe3f {formatDateTime(run.finished_at)}</span> <span>i18n:govoplan-admin.finished.4b52fe3f {formatDateTime(run.finished_at, ADMIN_DATE_TIME_OPTIONS)}</span>
</div> </div>
{run.error && <p className="module-installer-run-error">{run.error}</p>} {run.error && <p className="module-installer-run-error">{run.error}</p>}
</div> </div>
@@ -599,12 +755,104 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
</div>} </div>}
</div>} </div>}
</>} </>}
</AdminPageLayout>); </AdminPageLayout>
<ConfirmDialog
open={confirmClearPlan}
title={ADMIN_INTERFACE_I18N.clearPlanTitle}
message={ADMIN_INTERFACE_I18N.clearPlanMessage}
confirmLabel="i18n:govoplan-admin.clear.719ea396"
tone="danger"
busy={planBusy}
onCancel={() => setConfirmClearPlan(false)}
onConfirm={() => {
setConfirmClearPlan(false);
void clearPlan();
}}
/>
<ConfirmDialog
open={confirmMaintenance}
title={ADMIN_INTERFACE_I18N.enableMaintenanceTitle}
message={ADMIN_INTERFACE_I18N.enableMaintenanceMessage}
confirmLabel="i18n:govoplan-admin.enable_maintenance_mode.75f98d57"
busy={maintenanceBusy}
onCancel={() => setConfirmMaintenance(false)}
onConfirm={() => {
setConfirmMaintenance(false);
void enableMaintenanceMode();
}}
/>
<ConfirmDialog
open={Boolean(cancelRequestId)}
title={ADMIN_INTERFACE_I18N.cancelRequestTitle}
message={ADMIN_INTERFACE_I18N.cancelRequestMessage}
confirmLabel="i18n:govoplan-admin.cancel.77dfd213"
tone="danger"
busy={planBusy}
onCancel={() => setCancelRequestId("")}
onConfirm={() => {
const requestId = cancelRequestId;
setCancelRequestId("");
if (requestId) void cancelInstallerRequest(requestId);
}}
/>
</>);
} }
function ModuleMetric({ label, value, detail, tone = "info" }: {label: string;value: string | number;detail: string;tone?: "info" | "good" | "warning";}) { function moduleInstallerStageLabel(stage: ModuleInstallerWorkflowStageId): string {
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>; if (stage === "plan") return "i18n:govoplan-admin.plan.ae2f98a0";
if (stage === "preflight") return "i18n:govoplan-admin.preflight.8016a487";
if (stage === "queue") return "i18n:govoplan-admin.installer_requests.b88db439";
if (stage === "execute") return "i18n:govoplan-admin.daemon_execution.cc0fad8d";
return "i18n:govoplan-admin.installer_runs.e9e344e1";
}
function moduleInstallerStageTone(
state: ModuleInstallerWorkflowStageState
): "success" | "active" | "warning" | "danger" | "neutral" {
if (state === "complete") return "success";
if (state === "current") return "active";
if (state === "failed") return "danger";
if (state === "blocked") return "warning";
return "neutral";
}
function moduleInstallerStageStatus(
stage: ModuleInstallerWorkflowStageId,
queueBlockReason: string,
preflightAllowed: boolean | null,
requestStatus?: string | null,
runStatus?: string | null
): string {
if (stage === "plan") return queueBlockReason || "i18n:govoplan-admin.plan.ae2f98a0";
if (stage === "preflight") {
if (preflightAllowed === true) return "i18n:govoplan-admin.installer_preflight_passed.6d5f8060";
if (preflightAllowed === false) return "i18n:govoplan-admin.installer_preflight_blocked.cf83bf79";
return "i18n:govoplan-admin.pending.c515ec74";
}
if (stage === "queue") return queueBlockReason || requestStatus || "i18n:govoplan-admin.queue_supervised_run.a53f248c";
if (stage === "execute") return runStatus || requestStatus || "i18n:govoplan-admin.pending.c515ec74";
return runStatus || requestStatus || "i18n:govoplan-admin.installer_runs.e9e344e1";
}
function moduleInstallerQueueBlockReason(
block: ModuleInstallerQueueBlock,
validationError: string,
preflightIssue?: string | null
): string {
if (block === "write_access") return "i18n:govoplan-admin.module_write_access_is_required.f1a20208";
if (block === "empty_plan") return "i18n:govoplan-admin.no_package_changes_planned.f12d8b33";
if (block === "invalid_plan") return validationError || "i18n:govoplan-admin.installer_preflight_blocked.cf83bf79";
if (block === "unsaved_plan") return "i18n:govoplan-admin.save_the_install_plan_before_queueing_a_daemon_r.3ba00691";
if (block === "preflight") return preflightIssue || "i18n:govoplan-admin.installer_preflight_blocked.cf83bf79";
if (block === "maintenance_mode") return "i18n:govoplan-admin.maintenance_mode_must_be_enabled_before_queueing.d233a32f";
return "i18n:govoplan-admin.queueing_installer_requests_requires_maintenance.ae81f9ac";
}
function moduleInstallerQueueBlockActor(block: ModuleInstallerQueueBlock): string {
return block === "write_access" || block === "maintenance_access"
? "i18n:govoplan-admin.system_administrator.f1a20207"
: "i18n:govoplan-admin.current_operator.f1a20206";
} }
function LicenseStatus({ license }: {license: ModuleLicenseDiagnostics;}) { function LicenseStatus({ license }: {license: ModuleLicenseDiagnostics;}) {
@@ -618,7 +866,7 @@ function LicenseStatus({ license }: {license: ModuleLicenseDiagnostics;}) {
</div> </div>
<p className="module-package-catalog-description">{label}</p> <p className="module-package-catalog-description">{label}</p>
<div className="module-management-details"> <div className="module-management-details">
<span>i18n:govoplan-admin.valid.b374b8f9 {formatDateTime(license.valid_from)} to {formatDateTime(license.valid_until)}</span> <span>i18n:govoplan-admin.valid.b374b8f9 {formatDateTime(license.valid_from, ADMIN_DATE_TIME_OPTIONS)} to {formatDateTime(license.valid_until, ADMIN_DATE_TIME_OPTIONS)}</span>
<span>i18n:govoplan-admin.features.5df81ffa {license.features.length ? license.features.join(", ") : "none"}</span> <span>i18n:govoplan-admin.features.5df81ffa {license.features.length ? license.features.join(", ") : "none"}</span>
{license.required_features.length > 0 && <span>i18n:govoplan-admin.required.d5793988 {license.required_features.join(", ")}</span>} {license.required_features.length > 0 && <span>i18n:govoplan-admin.required.d5793988 {license.required_features.join(", ")}</span>}
{license.missing_features.length > 0 && <span>i18n:govoplan-admin.missing.feb2bbaa {license.missing_features.join(", ")}</span>} {license.missing_features.length > 0 && <span>i18n:govoplan-admin.missing.feb2bbaa {license.missing_features.join(", ")}</span>}
@@ -644,6 +892,65 @@ function requiredInterfacesLabel(item: ModulePackageCatalogItem): string {
}).join(", "); }).join(", ");
} }
function migrationSafetyLabel(value: ModulePackageCatalogItem["migration_safety"] | ModuleInstallTargetItem["migration_safety"]): string {
if (value === "requires_review") return "i18n:govoplan-admin.requires_review.8ba0b8c6";
if (value === "forward_only") return "i18n:govoplan-admin.forward_only.6c107a46";
if (value === "destructive") return "i18n:govoplan-admin.destructive.0051026c";
return "i18n:govoplan-admin.automatic.d9a7260f";
}
function targetStatus(target: ModuleInstallTargetItem): "done" | "warning" | "blocked" {
if (target.migration_safety === "destructive" && !target.data_safety_acknowledged) return "blocked";
if (target.migration_safety === "forward_only" && !target.data_safety_acknowledged) return "blocked";
if (target.migration_safety !== "automatic") return "warning";
return "done";
}
function targetVersionLabel(target: ModuleInstallTargetItem): string {
const current = target.current_version ?? "none";
const next = target.action === "uninstall" ? "removed" : target.target_version ?? "unknown";
return `${current} -> ${next}`;
}
function migrationPhaseLabel(value: ModuleMigrationPlanStep["phase"]): string {
if (value === "retirement") return "i18n:govoplan-admin.retirement.6b680dd2";
return "i18n:govoplan-admin.upgrade.12c5007d";
}
function migrationTaskPhaseLabel(value: ModuleMigrationTaskPlanItem["phase"]): string {
if (value === "pre_migration_check") return "i18n:govoplan-admin.pre_migration_check.f654164a";
if (value === "pre_migration_prepare") return "i18n:govoplan-admin.pre_migration_prepare.d18fdd26";
if (value === "post_migration_backfill") return "i18n:govoplan-admin.post_migration_backfill.21043d74";
return "i18n:govoplan-admin.post_migration_verify.6af9f28b";
}
function migrationSourceLabel(value: ModuleMigrationPlanStep["source"] | ModuleMigrationTaskPlanItem["source"]): string {
if (value === "catalog") return "i18n:govoplan-admin.catalog_metadata.9b96c41a";
if (value === "pending") return "i18n:govoplan-admin.pending_metadata.a24cfeb0";
return "i18n:govoplan-admin.manifest_metadata.b9ae362a";
}
function migrationStepStatus(step: ModuleMigrationPlanStep): "done" | "pending" | "warning" | "blocked" {
if (step.metadata_pending) return "warning";
if (step.migration_safety !== "automatic") return "warning";
return "pending";
}
function migrationTaskStatus(task: ModuleMigrationTaskPlanItem): "done" | "pending" | "warning" | "blocked" {
if (!task.idempotent) return "blocked";
if (!task.metadata_pending && !task.has_executor) return "blocked";
if (task.safety === "forward_only" || task.safety === "destructive") return "warning";
if (task.metadata_pending || task.safety === "requires_review") return "warning";
return "pending";
}
function currentWindowLabel(item: Pick<ModulePackageCatalogItem, "current_version_min" | "current_version_max_exclusive">): string {
return [
item.current_version_min ? `>=${item.current_version_min}` : "",
item.current_version_max_exclusive ? `<${item.current_version_max_exclusive}` : "",
].filter(Boolean).join(" ") || "any";
}
function ModuleStatus({ module, desiredEnabled }: {module: ModuleCatalogItem;desiredEnabled: boolean;}) { function ModuleStatus({ module, desiredEnabled }: {module: ModuleCatalogItem;desiredEnabled: boolean;}) {
if (module.current_enabled && !desiredEnabled) return <StatusBadge status="warning" label="i18n:govoplan-admin.will_disable.14bb193a" />; if (module.current_enabled && !desiredEnabled) return <StatusBadge status="warning" label="i18n:govoplan-admin.will_disable.14bb193a" />;
if (!module.current_enabled && desiredEnabled) return <StatusBadge status="warning" label="i18n:govoplan-admin.will_enable.5d52d70b" />; if (!module.current_enabled && desiredEnabled) return <StatusBadge status="warning" label="i18n:govoplan-admin.will_enable.5d52d70b" />;
@@ -651,6 +958,12 @@ function ModuleStatus({ module, desiredEnabled }: {module: ModuleCatalogItem;des
return <StatusBadge status="inactive" label="i18n:govoplan-admin.inactive.09af574c" />; return <StatusBadge status="inactive" label="i18n:govoplan-admin.inactive.09af574c" />;
} }
function catalogPlanButtonLabel(item: ModulePackageCatalogItem, catalog: ModuleCatalogResponse | null): string {
if (item.action === "update") return "i18n:govoplan-admin.plan_update.86e6857a";
const installed = catalog?.modules.some((module) => module.id === item.module_id && module.installed) ?? false;
return installed ? "i18n:govoplan-admin.plan_update.86e6857a" : "i18n:govoplan-admin.plan_install.e82bffe6";
}
function sameSet(left: ReadonlySet<string>, right: ReadonlySet<string>) { function sameSet(left: ReadonlySet<string>, right: ReadonlySet<string>) {
if (left.size !== right.size) return false; if (left.size !== right.size) return false;
for (const item of left) { for (const item of left) {
@@ -669,6 +982,7 @@ function emptyPlanItem(): ModuleInstallPlanItem {
python_ref: "", python_ref: "",
webui_package: "", webui_package: "",
webui_ref: "", webui_ref: "",
data_safety_acknowledged: false,
destroy_data: false, destroy_data: false,
status: "planned", status: "planned",
notes: "" notes: ""
@@ -683,9 +997,10 @@ function normalizePlanItems(items: ModuleInstallPlanItem[]): ModuleInstallPlanIt
source: item.source === "catalog" ? "catalog" : "manual", source: item.source === "catalog" ? "catalog" : "manual",
catalog: item.source === "catalog" ? item.catalog ?? null : null, catalog: item.source === "catalog" ? item.catalog ?? null : null,
python_package: cleanOptional(item.python_package), python_package: cleanOptional(item.python_package),
python_ref: item.action === "install" ? cleanOptional(item.python_ref) : null, python_ref: item.action !== "uninstall" ? cleanOptional(item.python_ref) : null,
webui_package: cleanOptional(item.webui_package), webui_package: cleanOptional(item.webui_package),
webui_ref: item.action === "install" ? cleanOptional(item.webui_ref) : null, webui_ref: item.action !== "uninstall" ? cleanOptional(item.webui_ref) : null,
data_safety_acknowledged: item.action !== "uninstall" && Boolean(item.data_safety_acknowledged),
destroy_data: item.action === "uninstall" && Boolean(item.destroy_data), destroy_data: item.action === "uninstall" && Boolean(item.destroy_data),
status: item.status, status: item.status,
notes: cleanOptional(item.notes) notes: cleanOptional(item.notes)
@@ -701,10 +1016,10 @@ function cleanOptional(value?: string | null): string | null {
function planValidationError(items: ModuleInstallPlanItem[]): string { function planValidationError(items: ModuleInstallPlanItem[]): string {
for (const item of normalizePlanItems(items)) { for (const item of normalizePlanItems(items)) {
if (!item.module_id) return "i18n:govoplan-admin.every_plan_item_needs_a_module_id.23a7f206"; if (!item.module_id) return "i18n:govoplan-admin.every_plan_item_needs_a_module_id.23a7f206";
if (item.action === "install" && !item.python_ref) return i18nMessage("i18n:govoplan-admin.value_needs_a_python_package_reference.950b898e", { value0: item.module_id }); if (item.action !== "uninstall" && !item.python_ref) return i18nMessage("i18n:govoplan-admin.value_needs_a_python_package_reference.950b898e", { value0: item.module_id });
if (item.action === "install" && item.python_ref && !item.python_package) return i18nMessage("i18n:govoplan-admin.value_needs_a_python_package_name_for_rollback.6c89ed75", { value0: item.module_id }); if (item.action !== "uninstall" && item.python_ref && !item.python_package) return i18nMessage("i18n:govoplan-admin.value_needs_a_python_package_name_for_rollback.6c89ed75", { value0: item.module_id });
if (item.action === "uninstall" && !item.python_package) return i18nMessage("i18n:govoplan-admin.value_needs_a_python_package_name_for_uninstall.004ba6fa", { value0: item.module_id }); if (item.action === "uninstall" && !item.python_package) return i18nMessage("i18n:govoplan-admin.value_needs_a_python_package_name_for_uninstall.004ba6fa", { value0: item.module_id });
if (item.action === "install" && Boolean(item.webui_package) !== Boolean(item.webui_ref)) return i18nMessage("i18n:govoplan-admin.value_needs_both_webui_package_and_webui_referen.d2bab073", { value0: item.module_id }); if (item.action !== "uninstall" && Boolean(item.webui_package) !== Boolean(item.webui_ref)) return i18nMessage("i18n:govoplan-admin.value_needs_both_webui_package_and_webui_referen.d2bab073", { value0: item.module_id });
if (item.action === "uninstall" && item.webui_ref && !item.webui_package) return i18nMessage("i18n:govoplan-admin.value_has_a_webui_reference_but_no_webui_package.18f44149", { value0: item.module_id }); if (item.action === "uninstall" && item.webui_ref && !item.webui_package) return i18nMessage("i18n:govoplan-admin.value_has_a_webui_reference_but_no_webui_package.18f44149", { value0: item.module_id });
} }
return ""; return "";
@@ -733,12 +1048,16 @@ function licenseLabel(license: ModuleLicenseDiagnostics): string {
return "i18n:govoplan-admin.unsigned.e91344ea"; return "i18n:govoplan-admin.unsigned.e91344ea";
} }
function formatDateTime(value?: string | null): string { const ADMIN_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
if (!value) return "i18n:govoplan-admin.not_recorded.9925ee3c"; fallback: "i18n:govoplan-admin.not_recorded.9925ee3c",
const date = new Date(value); year: "numeric",
if (Number.isNaN(date.getTime())) return value; month: "numeric",
return date.toLocaleString(); day: "numeric",
} hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: undefined
};
function traceId(value?: Record<string, unknown> | null): string { function traceId(value?: Record<string, unknown> | null): string {
const correlationId = value?.correlation_id; const correlationId = value?.correlation_id;
@@ -5,7 +5,8 @@ import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui";
import { fetchSystemSettingsDelta, updateSystemSettings, type LanguagePackage, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsDeltaSections, type SystemSettingsItem } from "../../api/admin"; import { fetchSystemSettingsDelta, updateSystemSettings, type LanguagePackage, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsDeltaSections, type SystemSettingsItem } from "../../api/admin";
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { AdminPageLayout, AdminSelectionList, DocumentationHelpLink, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { ADMIN_GOVERNANCE_DOCUMENTATION, ADMIN_INTERFACE_I18N, mutationDisabledReason } from "./interfacePatterns";
const DELTA_KEY = "admin:system-settings"; const DELTA_KEY = "admin:system-settings";
@@ -112,10 +113,8 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint
{setBusy(false);} {setBusy(false);}
} }
function toggleLanguage(code: string, checked: boolean) { function setEnabledLanguages(selected: string[]) {
const enabled = new Set(draft.enabled_language_codes); const enabled = new Set(selected);
if (checked) enabled.add(code);
else enabled.delete(code);
const nextEnabled = draft.available_languages.map((item) => item.code).filter((item) => enabled.has(item)); const nextEnabled = draft.available_languages.map((item) => item.code).filter((item) => enabled.has(item));
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale); const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale }); setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
@@ -145,11 +144,11 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint
loading={loading} loading={loading}
error={error} error={error}
success={success} success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-admin.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy}>{busy ? "i18n:govoplan-admin.saving.56a2285c" : "i18n:govoplan-admin.save_settings.913aba9f"}</Button></>}> actions={<><DocumentationHelpLink reference={ADMIN_GOVERNANCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading || busy} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : busy ? ADMIN_INTERFACE_I18N.busy : undefined}>i18n:govoplan-admin.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabledReason={mutationDisabledReason({ busy, permitted: canWrite, changed: dirty })}>{busy ? "i18n:govoplan-admin.saving.56a2285c" : "i18n:govoplan-admin.save_settings.913aba9f"}</Button></>}>
<div className="admin-settings-form"> <div className="admin-settings-form">
<Card title="i18n:govoplan-admin.defaults_for_newly_created_tenants.9d0f4ccd"> <Card title="i18n:govoplan-admin.defaults_for_newly_created_tenants.9d0f4ccd">
<FormField label="i18n:govoplan-admin.default_locale.b99d021f"> <FormField label="i18n:govoplan-admin.default_locale.b99d021f" documentation={ADMIN_GOVERNANCE_DOCUMENTATION}>
<select value={draft.default_locale} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} disabled={!canWrite || busy || defaultLocaleOptions.length === 0}> <select value={draft.default_locale} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} disabled={!canWrite || busy || defaultLocaleOptions.length === 0}>
{defaultLocaleOptions.map((code) => { {defaultLocaleOptions.map((code) => {
const language = draft.available_languages.find((item) => item.code === code); const language = draft.available_languages.find((item) => item.code === code);
@@ -159,18 +158,11 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint
</FormField> </FormField>
</Card> </Card>
<Card title="i18n:govoplan-admin.language_packages"> <Card title="i18n:govoplan-admin.language_packages">
<div className="settings-list"> <AdminSelectionList
{draft.available_languages.map((item) => ( options={draft.available_languages.map((item) => ({ id: item.code, label: item.code.toUpperCase(), description: languageOptionLabel(item), disabled: !canWrite || busy || item.code === draft.default_locale }))}
<label className="admin-inline-check" key={item.code}> selected={draft.enabled_language_codes}
<input onChange={setEnabledLanguages}
type="checkbox" />
checked={draft.enabled_language_codes.includes(item.code)}
disabled={!canWrite || busy || item.code === draft.default_locale}
onChange={(event) => toggleLanguage(item.code, event.target.checked)} />
<span><strong>{item.code.toUpperCase()}</strong> {languageOptionLabel(item)}</span>
</label>
))}
</div>
<div className="form-grid"> <div className="form-grid">
<FormField label="i18n:govoplan-admin.language_code"> <FormField label="i18n:govoplan-admin.language_code">
<input value={packageDraft.code} disabled={!canWrite || busy} placeholder="fr" onChange={(event) => setPackageDraft({ ...packageDraft, code: event.target.value })} /> <input value={packageDraft.code} disabled={!canWrite || busy} placeholder="fr" onChange={(event) => setPackageDraft({ ...packageDraft, code: event.target.value })} />
@@ -182,16 +174,16 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint
<input value={packageDraft.nativeLabel} disabled={!canWrite || busy} placeholder="i18n:govoplan-admin.native_language_name_placeholder" onChange={(event) => setPackageDraft({ ...packageDraft, nativeLabel: event.target.value })} /> <input value={packageDraft.nativeLabel} disabled={!canWrite || busy} placeholder="i18n:govoplan-admin.native_language_name_placeholder" onChange={(event) => setPackageDraft({ ...packageDraft, nativeLabel: event.target.value })} />
</FormField> </FormField>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={installLanguagePackage} disabled={!canWrite || busy || !normalizeLanguageCode(packageDraft.code) || !packageDraft.label.trim()}>i18n:govoplan-admin.install_language_package</Button> <Button onClick={installLanguagePackage} disabledReason={mutationDisabledReason({ busy, permitted: canWrite, complete: Boolean(normalizeLanguageCode(packageDraft.code) && packageDraft.label.trim()) })}>i18n:govoplan-admin.install_language_package</Button>
</div> </div>
</div> </div>
<p className="muted small-note">i18n:govoplan-admin.language_packages_help</p> <p className="muted small-note">i18n:govoplan-admin.language_packages_help</p>
</Card> </Card>
<Card title="i18n:govoplan-admin.tenant_administration_capabilities.5d265972"> <Card title="i18n:govoplan-admin.tenant_administration_capabilities.5d265972">
<div className="settings-list"> <div className="settings-list">
<ToggleSwitch checked={draft.allow_tenant_custom_groups} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_groups: checked })} label="i18n:govoplan-admin.allow_tenant_defined_groups_by_default.32099d5a" /> <ToggleSwitch checked={draft.allow_tenant_custom_groups} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_groups: checked })} disabled={!canWrite || busy} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : busy ? ADMIN_INTERFACE_I18N.busy : undefined} label="i18n:govoplan-admin.allow_tenant_defined_groups_by_default.32099d5a" />
<ToggleSwitch checked={draft.allow_tenant_custom_roles} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_roles: checked })} label="i18n:govoplan-admin.allow_tenant_defined_roles_by_default.b1f79ee9" /> <ToggleSwitch checked={draft.allow_tenant_custom_roles} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_roles: checked })} disabled={!canWrite || busy} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : busy ? ADMIN_INTERFACE_I18N.busy : undefined} label="i18n:govoplan-admin.allow_tenant_defined_roles_by_default.b1f79ee9" />
<ToggleSwitch checked={draft.allow_tenant_api_keys} onChange={(checked) => setDraft({ ...draft, allow_tenant_api_keys: checked })} label="i18n:govoplan-admin.allow_tenant_api_keys_by_default.58a6cf17" /> <ToggleSwitch checked={draft.allow_tenant_api_keys} onChange={(checked) => setDraft({ ...draft, allow_tenant_api_keys: checked })} disabled={!canWrite || busy} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : busy ? ADMIN_INTERFACE_I18N.busy : undefined} label="i18n:govoplan-admin.allow_tenant_api_keys_by_default.58a6cf17" />
</div> </div>
<p className="muted small-note">i18n:govoplan-admin.these_settings_are_enforced_by_the_backend_centr.8112ed6f</p> <p className="muted small-note">i18n:govoplan-admin.these_settings_are_enforced_by_the_backend_centr.8112ed6f</p>
</Card> </Card>
@@ -199,16 +191,17 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint
<div className="settings-list"> <div className="settings-list">
<ToggleSwitch <ToggleSwitch
checked={draft.maintenance_mode.enabled} checked={draft.maintenance_mode.enabled}
disabled={!canWrite || !canAccessMaintenance} disabled={!canWrite || !canAccessMaintenance || busy}
help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !canAccessMaintenance ? ADMIN_INTERFACE_I18N.maintenanceAuthorityRequired : busy ? ADMIN_INTERFACE_I18N.busy : undefined}
onChange={(checked) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, enabled: checked } })} onChange={(checked) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, enabled: checked } })}
label="i18n:govoplan-admin.restrict_authenticated_api_access_to_maintenance.2bc47195" /> label="i18n:govoplan-admin.restrict_authenticated_api_access_to_maintenance.2bc47195" />
</div> </div>
<FormField label="i18n:govoplan-admin.maintenance_message.ca62571f"> <FormField label="i18n:govoplan-admin.maintenance_message.ca62571f" help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : undefined} documentation={ADMIN_GOVERNANCE_DOCUMENTATION}>
<textarea <textarea
rows={3} rows={3}
value={draft.maintenance_mode.message ?? ""} value={draft.maintenance_mode.message ?? ""}
disabled={!canWrite} disabled={!canWrite || busy}
onChange={(event) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, message: event.target.value } })} /> onChange={(event) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, message: event.target.value } })} />
</FormField> </FormField>
@@ -0,0 +1,362 @@
import { useEffect, useMemo, useState } from "react";
import {
AdminPageLayout,
adminErrorMessage,
Button,
Card,
dispatchPlatformModulesChanged,
DismissibleAlert,
DocumentationHelpLink,
FormField,
MetricCard,
SearchableSelect,
StatusBadge,
ToggleSwitch,
useUnsavedDraftGuard,
type ApiSettings,
type SearchableSelectOption
} from "@govoplan/core-webui";
import { RefreshCw, Save, Undo2 } from "lucide-react";
import {
fetchSystemTenantModules,
fetchTenantModuleTargets,
fetchTenantModules,
updateSystemTenantModules,
updateTenantModules,
type TenantModuleAvailability,
type TenantModuleEntitlementResponse,
type TenantModuleTarget
} from "../../api/admin";
type Props = {
settings: ApiSettings;
scope: "system" | "tenant";
canWrite: boolean;
};
type Draft = {
available: Set<string>;
forced: Set<string>;
enabled: Set<string>;
};
const DOCUMENTATION = {
contextId: "admin.tenant-modules",
documentationType: "admin" as const
};
export default function TenantModuleManagementPanel({ settings, scope, canWrite }: Props) {
const [targets, setTargets] = useState<TenantModuleTarget[]>([]);
const [targetId, setTargetId] = useState("");
const [state, setState] = useState<TenantModuleEntitlementResponse | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const targetOptions = useMemo<SearchableSelectOption[]>(() => targets.map((target) => ({
value: target.id,
label: target.name,
description: `${target.slug}${target.is_active ? "" : " - inactive"}`,
searchText: `${target.name} ${target.slug}`
})), [targets]);
const dirty = Boolean(state && draft && (
!sameSet(draft.available, new Set(state.available_modules))
|| !sameSet(draft.forced, new Set(state.forced_modules))
|| !sameSet(draft.enabled, new Set(state.selected_modules))
));
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: discard
});
useEffect(() => {
void initialize();
}, [scope, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function initialize() {
setLoading(true);
setError("");
setSuccess("");
try {
if (scope === "system") {
const loadedTargets = await fetchTenantModuleTargets(settings);
setTargets(loadedTargets);
const nextTarget = loadedTargets.some((item) => item.id === targetId)
? targetId
: loadedTargets[0]?.id ?? "";
setTargetId(nextTarget);
if (nextTarget) {
await load(nextTarget, false);
} else {
setState(null);
setDraft(null);
}
} else {
setTargets([]);
setTargetId("");
await load(undefined, false);
}
} catch (err) {
setError(adminErrorMessage(err));
setState(null);
setDraft(null);
} finally {
setLoading(false);
}
}
async function load(nextTargetId = targetId, manageLoading = true) {
if (scope === "system" && !nextTargetId) return;
if (manageLoading) setLoading(true);
setError("");
setSuccess("");
try {
const loaded = scope === "system"
? await fetchSystemTenantModules(settings, nextTargetId)
: await fetchTenantModules(settings);
setState(loaded);
setDraft(draftFromState(loaded));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
if (manageLoading) setLoading(false);
}
}
async function selectTarget(nextTargetId: string) {
if (!nextTargetId || nextTargetId === targetId) return;
setTargetId(nextTargetId);
await load(nextTargetId);
}
function discard() {
if (state) setDraft(draftFromState(state));
setError("");
setSuccess("");
}
async function save(): Promise<boolean> {
if (!state || !draft || !dirty) return true;
setBusy(true);
setError("");
setSuccess("");
try {
const loaded = scope === "system"
? await updateSystemTenantModules(settings, targetId, {
available_modules: sorted(draft.available),
forced_modules: sorted(draft.forced),
enabled_modules: sorted(draft.enabled),
expected_revision: state.revision
})
: await updateTenantModules(settings, {
enabled_modules: sorted(draft.enabled),
expected_revision: state.revision
});
setState(loaded);
setDraft(draftFromState(loaded));
setSuccess("Tenant module selection saved.");
dispatchPlatformModulesChanged();
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
}
function setAvailability(moduleId: string, availability: TenantModuleAvailability) {
setDraft((current) => {
if (!current) return current;
const next = cloneDraft(current);
if (availability === "unavailable") {
next.available.delete(moduleId);
next.forced.delete(moduleId);
next.enabled.delete(moduleId);
} else {
next.available.add(moduleId);
if (availability === "forced") next.forced.add(moduleId);
else next.forced.delete(moduleId);
}
return next;
});
}
function setEnabled(moduleId: string, enabled: boolean) {
setDraft((current) => {
if (!current) return current;
const next = cloneDraft(current);
if (enabled) next.enabled.add(moduleId);
else next.enabled.delete(moduleId);
return next;
});
}
const labels = scope === "system"
? {
title: "Tenant modules",
description: "Set a tenant's module ceiling, forced modules, and current selection."
}
: {
title: "Modules",
description: "Enable or disable modules made available to this tenant by system policy."
};
return (
<AdminPageLayout
title={labels.title}
description={labels.description}
loading={loading}
error={error}
success={success}
actions={
<>
<Button
title="Reload saved module policy"
aria-label="Reload saved module policy"
onClick={() => void load()}
disabled={loading || busy || (scope === "system" && !targetId)}
>
<RefreshCw size={16} />
</Button>
<Button onClick={discard} disabled={!dirty || busy}>
<Undo2 size={16} /> Discard
</Button>
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}>
<Save size={16} /> {busy ? "Saving..." : "Save"}
</Button>
<DocumentationHelpLink reference={DOCUMENTATION} label="Open module governance documentation" />
</>
}
>
{scope === "system" && (
<FormField label="Tenant" documentation={DOCUMENTATION}>
<SearchableSelect
value={targetId}
options={targetOptions}
onChange={(value) => void selectTarget(value)}
placeholder="Select tenant"
searchPlaceholder="Search tenants..."
disabled={loading || busy || targetOptions.length === 0}
/>
</FormField>
)}
{state && draft && (
<>
<div className="metric-grid module-management-metrics">
<MetricCard label="Available" value={draft.available.size} tone="info" />
<MetricCard label="Forced" value={draft.forced.size} tone="warning" />
<MetricCard label="Selected" value={draft.enabled.size} />
<MetricCard label="Effective now" value={state.effective_modules.length} tone="good" />
</div>
{state.diagnostics.map((diagnostic) => (
<DismissibleAlert key={`${diagnostic.code}:${diagnostic.message}`} tone="warning" compact>
{diagnostic.message}
</DismissibleAlert>
))}
<Card title={scope === "system" ? "Module policy and tenant selection" : "Tenant module selection"}>
<div className="module-management-list">
{state.modules.map((module) => {
const availability = draftAvailability(draft, module.id);
const effectiveSelection = draft.enabled.has(module.id) || availability === "forced" || module.derived_dependency;
const selectionLocked = availability !== "available" || module.derived_dependency;
return (
<div className={`module-management-row${dirtyModule(state, draft, module.id) ? " pending" : ""}`} key={module.id}>
<div className="module-management-main">
<div className="module-management-title">
<strong>{module.name}</strong>
<code>{module.id}</code>
<StatusBadge
status={module.runtime_active ? "success" : "neutral"}
label={module.runtime_active ? "Runtime active" : "Runtime inactive"}
/>
{module.effective && <StatusBadge status="info" label="Effective" />}
</div>
<div className="module-management-details">
<span>{module.dependencies.length ? `Requires ${module.dependencies.join(", ")}` : "No module dependencies"}</span>
{module.reason && <span>{module.reason}</span>}
</div>
</div>
<div className="module-management-toggle">
{scope === "system" && (
<label>
<span>System policy</span>
<select
value={availability}
onChange={(event) => setAvailability(module.id, event.target.value as TenantModuleAvailability)}
disabled={!canWrite || busy || (module.id === "access" || module.id === "admin")}
>
<option value="unavailable">Unavailable</option>
<option value="available">Available</option>
<option value="forced">Forced</option>
</select>
</label>
)}
<ToggleSwitch
label="Enabled for tenant"
checked={effectiveSelection}
onChange={(checked) => setEnabled(module.id, checked)}
disabled={!canWrite || busy || selectionLocked}
help={module.reason || undefined}
/>
</div>
</div>
);
})}
</div>
</Card>
</>
)}
{!loading && !state && (
<DismissibleAlert tone="info" dismissible={false}>
{scope === "system" ? "No tenant is available for module policy." : "Module policy is unavailable."}
</DismissibleAlert>
)}
</AdminPageLayout>
);
}
function draftFromState(state: TenantModuleEntitlementResponse): Draft {
return {
available: new Set(state.available_modules),
forced: new Set(state.forced_modules),
enabled: new Set(state.selected_modules)
};
}
function cloneDraft(draft: Draft): Draft {
return {
available: new Set(draft.available),
forced: new Set(draft.forced),
enabled: new Set(draft.enabled)
};
}
function draftAvailability(draft: Draft, moduleId: string): TenantModuleAvailability {
if (draft.forced.has(moduleId)) return "forced";
if (draft.available.has(moduleId)) return "available";
return "unavailable";
}
function dirtyModule(state: TenantModuleEntitlementResponse, draft: Draft, moduleId: string): boolean {
return state.available_modules.includes(moduleId) !== draft.available.has(moduleId)
|| state.forced_modules.includes(moduleId) !== draft.forced.has(moduleId)
|| state.selected_modules.includes(moduleId) !== draft.enabled.has(moduleId);
}
function sameSet(left: Set<string>, right: Set<string>): boolean {
return left.size === right.size && [...left].every((item) => right.has(item));
}
function sorted(values: Set<string>): string[] {
return [...values].sort();
}
@@ -0,0 +1,191 @@
import {
filterSearchableSelectOptions,
unavailableReferenceOption,
type ApiSettings,
type ConfigurationReferenceSelectorsUiCapability,
type ReferenceOption,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import {
fetchConfigurationChanges,
fetchTenants,
type ConfigurationChangeRequest
} from "../../api/admin";
export const configurationReferenceSelectors: ConfigurationReferenceSelectorsUiCapability = {
tenantProvider: createTenantReferenceProvider,
changeRequestProvider: createChangeRequestReferenceProvider
};
export function createTenantReferenceProvider(
settings: ApiSettings
): ReferenceOptionProvider {
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
const tenants = await fetchTenants(settings);
if (signal.aborted) throw abortError();
return tenants.map((tenant) => ({
value: tenant.id,
label: tenant.name || tenant.slug || tenant.id,
description: [
tenant.slug,
tenant.is_active ? null : "Inactive",
tenant.id
].filter(Boolean).join(" · "),
kind: "tenant",
availability: tenant.is_active ? "available" : "inactive",
disabled: !tenant.is_active,
sourceModule: "tenancy",
provenance: {
tenantId: tenant.id,
active: tenant.is_active
}
}));
}
return {
async search(query, context) {
const options = await catalogue(context.signal);
return retainSelected(
filterSearchableSelectOptions(options, query, context.limit),
options,
context.selectedValues
);
},
async resolve(values, context) {
const options = await catalogue(context.signal);
const byValue = new Map(options.map((option) => [option.value, option]));
return values.map(
(value) =>
byValue.get(value)
?? unavailableReferenceOption(value, "Unavailable tenant")
);
}
};
}
export function createChangeRequestReferenceProvider(
settings: ApiSettings,
{
purpose,
tenantId
}: {
purpose: string;
tenantId?: string | null;
}
): ReferenceOptionProvider {
async function catalogue(signal: AbortSignal): Promise<{
eligible: ReferenceOption[];
all: ReferenceOption[];
}> {
const response = await fetchConfigurationChanges(settings);
if (signal.aborted) throw abortError();
const matching = response.requests.filter(
(request) =>
request.key === purpose
&& requestTargetsTenant(request, tenantId)
);
const all = matching.map(changeRequestOption);
return {
eligible: all.filter((option) => !option.disabled),
all
};
}
return {
async search(query, context) {
const options = await catalogue(context.signal);
return retainSelected(
filterSearchableSelectOptions(
options.eligible,
query,
context.limit
),
options.all,
context.selectedValues
);
},
async resolve(values, context) {
const options = await catalogue(context.signal);
const byValue = new Map(
options.all.map((option) => [option.value, option])
);
return values.map(
(value) =>
byValue.get(value)
?? unavailableReferenceOption(
value,
"Unavailable configuration request"
)
);
}
};
}
function changeRequestOption(
request: ConfigurationChangeRequest
): ReferenceOption {
const closed = request.status === "applied" || request.status === "rejected";
const eligible = request.status === "approved" && request.dry_run;
return {
value: request.id,
label: request.label || request.key,
description: [
request.status.split("_").join(" "),
request.dry_run ? null : "dry run not recorded",
formatTimestamp(request.requested_at),
`requested by ${request.requested_by}`,
request.id
].filter(Boolean).join(" · "),
searchText: `${request.id} ${request.requested_by} ${request.status}`,
kind: "configuration_change_request",
availability: closed
? "unavailable"
: eligible
? "available"
: "inactive",
disabled: !eligible,
sourceModule: "admin",
provenance: {
purpose: request.key,
target: request.target ?? {},
requestedBy: request.requested_by,
requestedAt: request.requested_at,
status: request.status,
dryRunRecorded: request.dry_run
}
};
}
function requestTargetsTenant(
request: ConfigurationChangeRequest,
tenantId?: string | null
): boolean {
if (!tenantId) return true;
const targetTenant = request.target?.tenant_id;
return !targetTenant || String(targetTenant) === tenantId;
}
function retainSelected(
matches: readonly ReferenceOption[],
catalogue: readonly ReferenceOption[],
selectedValues: readonly string[]
): ReferenceOption[] {
const result = [...matches];
const returned = new Set(result.map((option) => option.value));
const byValue = new Map(catalogue.map((option) => [option.value, option]));
for (const value of selectedValues) {
if (returned.has(value)) continue;
result.push(
byValue.get(value)
?? unavailableReferenceOption(value)
);
}
return result;
}
function formatTimestamp(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function abortError(): DOMException {
return new DOMException("The operation was aborted.", "AbortError");
}
@@ -0,0 +1,77 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const ADMIN_WORKSPACE_DOCUMENTATION = {
topicId: "admin.workspace",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const ADMIN_GOVERNANCE_DOCUMENTATION = {
topicId: "admin.governance-and-module-lifecycle",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const MODULE_LIFECYCLE_DOCUMENTATION = {
topicId: "admin.module-lifecycle-workflow",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const ADMIN_INTERFACE_I18N = {
loading: "i18n:govoplan-admin.administration_data_is_loading.6bf3c001",
busy: "i18n:govoplan-admin.an_administration_operation_is_in_progress.6bf3c002",
writeRequired: "i18n:govoplan-admin.system_administration_write_permission_is_required.6bf3c003",
governanceWriteRequired: "i18n:govoplan-admin.system_governance_write_permission_is_required.6bf3c004",
completeRequiredFields: "i18n:govoplan-admin.complete_the_required_fields_before_saving.6bf3c005",
noPendingChanges: "i18n:govoplan-admin.make_a_change_before_saving.6bf3c006",
validJsonRequired: "i18n:govoplan-admin.provide_valid_package_and_supplied_data_json.6bf3c007",
packageRequired: "i18n:govoplan-admin.provide_valid_package_json_before_requesting_approval.6bf3c008",
planRequired: "i18n:govoplan-admin.add_at_least_one_valid_plan_item.6bf3c009",
savePlanFirst: "i18n:govoplan-admin.save_the_changed_plan_before_queueing_it.6bf3c010",
maintenanceAuthorityRequired: "i18n:govoplan-admin.system_maintenance_authority_is_required.6bf3c011",
maintenanceRequired: "i18n:govoplan-admin.enable_maintenance_mode_before_this_action.6bf3c012",
protectedDefinition: "i18n:govoplan-admin.this_protected_module_cannot_be_changed.6bf3c013",
deactivateBeforeUninstall: "i18n:govoplan-admin.deactivate_the_module_before_planning_uninstall.6bf3c014",
catalogBlocked: "i18n:govoplan-admin.resolve_the_catalog_license_or_action_blocker_first.6bf3c015",
approveTitle: "i18n:govoplan-admin.approve_configuration_change.6bf3c016",
approveMessage: "i18n:govoplan-admin.approving_records_your_authority_and_may_unlock_application.6bf3c017",
applyTitle: "i18n:govoplan-admin.apply_configuration_package.6bf3c018",
applyMessage: "i18n:govoplan-admin.apply_the_current_package_to_the_selected_scope.6bf3c019",
applyConfirm: "i18n:govoplan-admin.apply_package.6bf3c020",
enterReferencesManually: "i18n:govoplan-admin.enter_reference_ids_manually.6bf3c021",
manualReferenceHelp: "i18n:govoplan-admin.use_manual_ids_only_for_intentional_historical_references.6bf3c022",
tenantPickerLabel: "i18n:govoplan-admin.configuration_package_tenant.6bf3c023",
selectTenant: "i18n:govoplan-admin.select_a_tenant.6bf3c024",
requestPickerLabel: "i18n:govoplan-admin.configuration_package_change_request.6bf3c025",
selectEligibleRequest: "i18n:govoplan-admin.select_an_eligible_request_optional.6bf3c026",
noEligibleRequests: "i18n:govoplan-admin.no_eligible_configuration_requests.6bf3c027",
administrationHeading: "i18n:govoplan-admin.administration.b8be3d12",
globalHeading: "i18n:govoplan-admin.global.6bf3c028",
tenantHeading: "i18n:govoplan-admin.tenant.6bf3c029",
groupHeading: "i18n:govoplan-admin.group.171a0606",
userHeading: "i18n:govoplan-admin.user.6bf3c030",
clearPlanTitle: "i18n:govoplan-admin.clear_saved_module_plan.6bf3c032",
clearPlanMessage: "i18n:govoplan-admin.clear_all_saved_module_install_update_and_uninstall_items.6bf3c033",
enableMaintenanceTitle: "i18n:govoplan-admin.enable_maintenance_mode.6bf3c034",
enableMaintenanceMessage: "i18n:govoplan-admin.restrict_normal_authenticated_access_while_module_work_runs.6bf3c035",
cancelRequestTitle: "i18n:govoplan-admin.cancel_installer_request.6bf3c036",
cancelRequestMessage: "i18n:govoplan-admin.cancel_the_queued_request_before_the_daemon_accepts_it.6bf3c037",
uninstallOnly: "i18n:govoplan-admin.this_option_only_applies_to_uninstall_plans.6bf3c038",
installUpdateOnly: "i18n:govoplan-admin.this_option_only_applies_to_install_and_update_plans.6bf3c039"
} as const;
export function mutationDisabledReason({
busy,
permitted,
complete = true,
changed = true
}: {
busy: boolean;
permitted: boolean;
complete?: boolean;
changed?: boolean;
}): string | undefined {
if (busy) return ADMIN_INTERFACE_I18N.busy;
if (!permitted) return ADMIN_INTERFACE_I18N.writeRequired;
if (!complete) return ADMIN_INTERFACE_I18N.completeRequiredFields;
if (!changed) return ADMIN_INTERFACE_I18N.noPendingChanges;
return undefined;
}
@@ -0,0 +1,152 @@
export type ModuleInstallerWorkflowStageId =
| "plan"
| "preflight"
| "queue"
| "execute"
| "evidence";
export type ModuleInstallerWorkflowStageState =
| "complete"
| "current"
| "locked"
| "blocked"
| "failed";
export type ModuleInstallerWorkflowStage = {
id: ModuleInstallerWorkflowStageId;
state: ModuleInstallerWorkflowStageState;
current: boolean;
locked: boolean;
};
export type ModuleInstallerQueueBlock =
| "write_access"
| "empty_plan"
| "invalid_plan"
| "unsaved_plan"
| "preflight"
| "maintenance_mode"
| "maintenance_access";
export type ModuleInstallerWorkflowInput = {
planItemCount: number;
planDirty: boolean;
planValid: boolean;
preflightAllowed: boolean | null;
maintenanceEnabled: boolean;
canWrite: boolean;
canAccessMaintenance: boolean;
requestStatus?: string | null;
runStatus?: string | null;
};
const ACTIVE_STATUSES = new Set([
"queued",
"pending",
"claimed",
"starting",
"running",
"cancelling",
"rolling_back"
]);
const FAILED_STATUSES = new Set([
"blocked",
"cancelled",
"failed",
"rollback_failed"
]);
export function moduleInstallerQueueBlock(
input: ModuleInstallerWorkflowInput
): ModuleInstallerQueueBlock | null {
if (!input.canWrite) return "write_access";
if (input.planItemCount === 0) return "empty_plan";
if (!input.planValid) return "invalid_plan";
if (input.planDirty) return "unsaved_plan";
if (input.preflightAllowed !== true) return "preflight";
if (!input.canAccessMaintenance) return "maintenance_access";
if (!input.maintenanceEnabled) return "maintenance_mode";
return null;
}
export function moduleInstallerWorkflowStages(
input: ModuleInstallerWorkflowInput
): ModuleInstallerWorkflowStage[] {
const queueBlock = moduleInstallerQueueBlock(input);
const planReady = input.planItemCount > 0 && input.planValid && !input.planDirty;
const preflightReady = planReady && input.preflightAllowed === true;
const requestStatus = normalizeStatus(input.requestStatus);
const runStatus = normalizeStatus(input.runStatus);
const hasRequest = Boolean(requestStatus);
const effectiveStatus = runStatus || requestStatus;
const active = ACTIVE_STATUSES.has(effectiveStatus);
const terminalStatus = hasRequest && !active ? effectiveStatus : "";
const terminal = Boolean(terminalStatus);
const failed = FAILED_STATUSES.has(terminalStatus);
if (!planReady) {
return stagesAt("plan", input.planValid || input.planItemCount === 0 ? "current" : "blocked");
}
if (!preflightReady) {
return stagesAt("preflight", input.preflightAllowed === false ? "blocked" : "current", ["plan"]);
}
if (!hasRequest) {
return stagesAt("queue", queueBlock ? "blocked" : "current", ["plan", "preflight"]);
}
if (!terminal) {
return stagesAt("execute", "current", ["plan", "preflight", "queue"]);
}
return stagesAt(
"evidence",
failed ? "failed" : "current",
["plan", "preflight", "queue", "execute"]
);
}
export function installerRequestMatchesPlan(
planUpdatedAt?: string | null,
requestCreatedAt?: string | null
): boolean {
if (!planUpdatedAt) return true;
if (!requestCreatedAt) return false;
const planTime = Date.parse(planUpdatedAt);
const requestTime = Date.parse(requestCreatedAt);
return Number.isFinite(planTime)
&& Number.isFinite(requestTime)
&& requestTime >= planTime;
}
function stagesAt(
currentId: ModuleInstallerWorkflowStageId,
currentState: Extract<ModuleInstallerWorkflowStageState, "current" | "blocked" | "failed">,
completed: ModuleInstallerWorkflowStageId[] = []
): ModuleInstallerWorkflowStage[] {
const ids: ModuleInstallerWorkflowStageId[] = [
"plan",
"preflight",
"queue",
"execute",
"evidence"
];
const currentIndex = ids.indexOf(currentId);
const completedIds = new Set(completed);
return ids.map((id, index) => {
const current = id === currentId;
const state: ModuleInstallerWorkflowStageState = current
? currentState
: completedIds.has(id)
? "complete"
: "locked";
return {
id,
state,
current,
locked: !current && index > currentIndex
};
});
}
function normalizeStatus(value?: string | null): string {
return value?.trim().toLowerCase().replaceAll("-", "_") ?? "";
}
+178 -16
View File
@@ -2,17 +2,66 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "en": {
"i18n:govoplan-admin.administration_data_is_loading.6bf3c001": "Administration data is loading.",
"i18n:govoplan-admin.an_administration_operation_is_in_progress.6bf3c002": "An administration operation is in progress.",
"i18n:govoplan-admin.system_administration_write_permission_is_required.6bf3c003": "System administration write permission is required.",
"i18n:govoplan-admin.system_governance_write_permission_is_required.6bf3c004": "System governance write permission is required.",
"i18n:govoplan-admin.complete_the_required_fields_before_saving.6bf3c005": "Complete the required fields before saving.",
"i18n:govoplan-admin.make_a_change_before_saving.6bf3c006": "Make a change before saving.",
"i18n:govoplan-admin.provide_valid_package_and_supplied_data_json.6bf3c007": "Provide valid package and supplied-data JSON.",
"i18n:govoplan-admin.provide_valid_package_json_before_requesting_approval.6bf3c008": "Provide valid package JSON before requesting approval.",
"i18n:govoplan-admin.add_at_least_one_valid_plan_item.6bf3c009": "Add at least one valid plan item.",
"i18n:govoplan-admin.save_the_changed_plan_before_queueing_it.6bf3c010": "Save the changed plan before queueing it.",
"i18n:govoplan-admin.system_maintenance_authority_is_required.6bf3c011": "System maintenance authority is required.",
"i18n:govoplan-admin.enable_maintenance_mode_before_this_action.6bf3c012": "Enable maintenance mode before this action.",
"i18n:govoplan-admin.this_protected_module_cannot_be_changed.6bf3c013": "This protected module cannot be changed.",
"i18n:govoplan-admin.deactivate_the_module_before_planning_uninstall.6bf3c014": "Deactivate the module before planning its uninstall.",
"i18n:govoplan-admin.resolve_the_catalog_license_or_action_blocker_first.6bf3c015": "Resolve the catalog, license, or package-action blocker first.",
"i18n:govoplan-admin.approve_configuration_change.6bf3c016": "Approve configuration change",
"i18n:govoplan-admin.approving_records_your_authority_and_may_unlock_application.6bf3c017": "Approving records your authority and may unlock application of this configuration change.",
"i18n:govoplan-admin.apply_configuration_package.6bf3c018": "Apply configuration package",
"i18n:govoplan-admin.apply_the_current_package_to_the_selected_scope.6bf3c019": "Apply the current package to the selected scope? This may create or update module-owned configuration.",
"i18n:govoplan-admin.apply_package.6bf3c020": "Apply package",
"i18n:govoplan-admin.enter_reference_ids_manually.6bf3c021": "Enter reference IDs manually",
"i18n:govoplan-admin.use_manual_ids_only_for_intentional_historical_references.6bf3c022": "Use manual IDs only when a package intentionally references a target no longer returned by the live catalog.",
"i18n:govoplan-admin.configuration_package_tenant.6bf3c023": "Configuration package tenant",
"i18n:govoplan-admin.select_a_tenant.6bf3c024": "Select a tenant",
"i18n:govoplan-admin.configuration_package_change_request.6bf3c025": "Configuration package change request",
"i18n:govoplan-admin.select_an_eligible_request_optional.6bf3c026": "Select an eligible request (optional)",
"i18n:govoplan-admin.no_eligible_configuration_requests.6bf3c027": "No eligible configuration requests.",
"i18n:govoplan-admin.global.6bf3c028": "Global",
"i18n:govoplan-admin.tenant.6bf3c029": "Tenant",
"i18n:govoplan-admin.user.6bf3c030": "User",
"i18n:govoplan-admin.request_is_not_pending_approval.6bf3c031": "This request is not pending approval.",
"i18n:govoplan-admin.clear_saved_module_plan.6bf3c032": "Clear saved module plan",
"i18n:govoplan-admin.clear_all_saved_module_install_update_and_uninstall_items.6bf3c033": "Clear all saved module install, update, and uninstall items?",
"i18n:govoplan-admin.enable_maintenance_mode.6bf3c034": "Enable maintenance mode",
"i18n:govoplan-admin.restrict_normal_authenticated_access_while_module_work_runs.6bf3c035": "Restrict normal authenticated access while supervised module work runs?",
"i18n:govoplan-admin.cancel_installer_request.6bf3c036": "Cancel installer request",
"i18n:govoplan-admin.cancel_the_queued_request_before_the_daemon_accepts_it.6bf3c037": "Cancel this queued request before the installer daemon accepts it?",
"i18n:govoplan-admin.this_option_only_applies_to_uninstall_plans.6bf3c038": "This option only applies to uninstall plans.",
"i18n:govoplan-admin.this_option_only_applies_to_install_and_update_plans.6bf3c039": "This option only applies to install and update plans.",
"i18n:govoplan-admin.module_lifecycle_progress.f1a20201": "Module lifecycle progress",
"i18n:govoplan-admin.queue_supervised_run_unavailable.f1a20202": "Queueing a supervised run is unavailable",
"i18n:govoplan-admin.required_action.f1a20203": "Required action",
"i18n:govoplan-admin.who_can_fix_it.f1a20204": "Who can fix it",
"i18n:govoplan-admin.where_to_go.f1a20205": "Where to go",
"i18n:govoplan-admin.current_operator.f1a20206": "Current operator",
"i18n:govoplan-admin.system_administrator.f1a20207": "System administrator",
"i18n:govoplan-admin.module_write_access_is_required.f1a20208": "Module write access is required.",
"i18n:govoplan-admin.access_configuration_exported.098f200d": "Access configuration exported.", "i18n:govoplan-admin.access_configuration_exported.098f200d": "Access configuration exported.",
"i18n:govoplan-admin.action.97c89a4d": "Action", "i18n:govoplan-admin.action.97c89a4d": "Action",
"i18n:govoplan-admin.actions.c3cd636a": "Actions", "i18n:govoplan-admin.actions.c3cd636a": "Actions",
"i18n:govoplan-admin.activate_installs.731d22a1": "Activate installs", "i18n:govoplan-admin.activate_installs.731d22a1": "Activate installs",
"i18n:govoplan-admin.acknowledged.d08d7c6d": "Acknowledged",
"i18n:govoplan-admin.active_and_total_memberships.c0c20f10": "Active and total memberships.", "i18n:govoplan-admin.active_and_total_memberships.c0c20f10": "Active and total memberships.",
"i18n:govoplan-admin.active_tenant_automation_credentials.240c659e": "Active tenant automation credentials.", "i18n:govoplan-admin.active_tenant_automation_credentials.240c659e": "Active tenant automation credentials.",
"i18n:govoplan-admin.active_tenant.dfadf0aa": "Active tenant:", "i18n:govoplan-admin.active_tenant.dfadf0aa": "Active tenant:",
"i18n:govoplan-admin.active.a733b809": "Active", "i18n:govoplan-admin.active.a733b809": "Active",
"i18n:govoplan-admin.after.695c89be": "After:",
"i18n:govoplan-admin.add_group_template.b74d8f0f": "Add group template", "i18n:govoplan-admin.add_group_template.b74d8f0f": "Add group template",
"i18n:govoplan-admin.add_plan_item.fa55f028": "Add plan item", "i18n:govoplan-admin.add_plan_item.fa55f028": "Add plan item",
"i18n:govoplan-admin.add_tenant_role.fcd904ea": "Add tenant role", "i18n:govoplan-admin.add_tenant_role.fcd904ea": "Add role template",
"i18n:govoplan-admin.admin.4e7afebc": "Admin", "i18n:govoplan-admin.admin.4e7afebc": "Admin",
"i18n:govoplan-admin.administration.b8be3d12": "Administration", "i18n:govoplan-admin.administration.b8be3d12": "Administration",
"i18n:govoplan-admin.platform_administration": "Platform administration", "i18n:govoplan-admin.platform_administration": "Platform administration",
@@ -34,9 +83,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.audit.fa1703dd": "Audit", "i18n:govoplan-admin.audit.fa1703dd": "Audit",
"i18n:govoplan-admin.available.7c62a142": "Available", "i18n:govoplan-admin.available.7c62a142": "Available",
"i18n:govoplan-admin.blocked.99613c74": "Blocked", "i18n:govoplan-admin.blocked.99613c74": "Blocked",
"i18n:govoplan-admin.before.a11cf31f": "Before:",
"i18n:govoplan-admin.bridge_release.176cf241": "Bridge release",
"i18n:govoplan-admin.build_webui.ed5ef1fd": " --build-webui", "i18n:govoplan-admin.build_webui.ed5ef1fd": " --build-webui",
"i18n:govoplan-admin.build_webui.fe8ccad7": "Build WebUI", "i18n:govoplan-admin.build_webui.fe8ccad7": "Build WebUI",
"i18n:govoplan-admin.bypass_allowed.4e347c27": "Bypass allowed", "i18n:govoplan-admin.bypass_allowed.4e347c27": "Bypass allowed",
"i18n:govoplan-admin.catalog_metadata.9b96c41a": "Catalog metadata",
"i18n:govoplan-admin.bypass_denied.ab987400": "Bypass denied", "i18n:govoplan-admin.bypass_denied.ab987400": "Bypass denied",
"i18n:govoplan-admin.cached_from.d73c5b2a": "· cached from", "i18n:govoplan-admin.cached_from.d73c5b2a": "· cached from",
"i18n:govoplan-admin.cancel.77dfd213": "Cancel", "i18n:govoplan-admin.cancel.77dfd213": "Cancel",
@@ -70,10 +122,13 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.create_group_template.72407248": "Create group template", "i18n:govoplan-admin.create_group_template.72407248": "Create group template",
"i18n:govoplan-admin.create_request_and_apply.da79e153": "Create request and apply", "i18n:govoplan-admin.create_request_and_apply.da79e153": "Create request and apply",
"i18n:govoplan-admin.create_suspend_and_govern_tenant_spaces.77992a39": "Create, suspend and govern tenant spaces.", "i18n:govoplan-admin.create_suspend_and_govern_tenant_spaces.77992a39": "Create, suspend and govern tenant spaces.",
"i18n:govoplan-admin.create_tenant_role.f58db104": "Create tenant role", "i18n:govoplan-admin.create_tenant_role.f58db104": "Create role template",
"i18n:govoplan-admin.created.0c78dab1": "Created:", "i18n:govoplan-admin.created.0c78dab1": "Created:",
"i18n:govoplan-admin.created.accf40c8": "Created", "i18n:govoplan-admin.created.accf40c8": "Created",
"i18n:govoplan-admin.current_window.73e25c9f": "Current window:",
"i18n:govoplan-admin.automatic.d9a7260f": "Automatic",
"i18n:govoplan-admin.daemon_execution.cc0fad8d": "Daemon execution", "i18n:govoplan-admin.daemon_execution.cc0fad8d": "Daemon execution",
"i18n:govoplan-admin.data_safety_reviewed.b4774edc": "Data safety reviewed",
"i18n:govoplan-admin.daemon_offline.314f19a4": "Daemon offline", "i18n:govoplan-admin.daemon_offline.314f19a4": "Daemon offline",
"i18n:govoplan-admin.daemon_value.e54f5a31": "Daemon: {value0}", "i18n:govoplan-admin.daemon_value.e54f5a31": "Daemon: {value0}",
"i18n:govoplan-admin.db_backup_command.5bded3ac": "DB backup command", "i18n:govoplan-admin.db_backup_command.5bded3ac": "DB backup command",
@@ -92,11 +147,13 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.defaults_for_newly_created_tenants.9d0f4ccd": "Defaults for newly created tenants", "i18n:govoplan-admin.defaults_for_newly_created_tenants.9d0f4ccd": "Defaults for newly created tenants",
"i18n:govoplan-admin.delete_group_template.8745d842": "Delete group template", "i18n:govoplan-admin.delete_group_template.8745d842": "Delete group template",
"i18n:govoplan-admin.delete_template.399bf72a": "Delete template", "i18n:govoplan-admin.delete_template.399bf72a": "Delete template",
"i18n:govoplan-admin.delete_tenant_role.4efa0813": "Delete tenant role", "i18n:govoplan-admin.delete_tenant_role.4efa0813": "Delete role template",
"i18n:govoplan-admin.delete_value_removal_is_blocked_while_a_material.d4eba73d": "Delete {value0}? Removal is blocked while a materialized tenant definition still has members or assignments.", "i18n:govoplan-admin.delete_value_removal_is_blocked_while_a_material.d4eba73d": "Delete {value0}? Removal is blocked while a materialized tenant definition still has members or assignments.",
"i18n:govoplan-admin.delete_value.4d18989e": "Delete {value0}", "i18n:govoplan-admin.delete_value.4d18989e": "Delete {value0}",
"i18n:govoplan-admin.dependencies.9f4f78d1": "Dependencies:",
"i18n:govoplan-admin.dependents.072665c4": "Dependents:", "i18n:govoplan-admin.dependents.072665c4": "Dependents:",
"i18n:govoplan-admin.description.55f8ebc8": "Description", "i18n:govoplan-admin.description.55f8ebc8": "Description",
"i18n:govoplan-admin.destructive.0051026c": "Destructive",
"i18n:govoplan-admin.destroy_data.34557c3c": "Destroy data", "i18n:govoplan-admin.destroy_data.34557c3c": "Destroy data",
"i18n:govoplan-admin.disabled.f4f4473d": "Disabled", "i18n:govoplan-admin.disabled.f4f4473d": "Disabled",
"i18n:govoplan-admin.discovered_packages.660902de": "Discovered packages", "i18n:govoplan-admin.discovered_packages.660902de": "Discovered packages",
@@ -105,9 +162,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.dry_run_change_request_created_value_enable_main.49826d07": "Dry-run change request created: {value0}. Enable maintenance mode, then apply the request.", "i18n:govoplan-admin.dry_run_change_request_created_value_enable_main.49826d07": "Dry-run change request created: {value0}. Enable maintenance mode, then apply the request.",
"i18n:govoplan-admin.dry_run.3d14659c": "Dry-run", "i18n:govoplan-admin.dry_run.3d14659c": "Dry-run",
"i18n:govoplan-admin.edit_group_template.9bc72d21": "Edit group template", "i18n:govoplan-admin.edit_group_template.9bc72d21": "Edit group template",
"i18n:govoplan-admin.edit_tenant_role.c15a260d": "Edit tenant role", "i18n:govoplan-admin.edit_tenant_role.c15a260d": "Edit role template",
"i18n:govoplan-admin.edit_value.fad75899": "Edit {value0}", "i18n:govoplan-admin.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-admin.enable_maintenance_mode.75f98d57": "Enable maintenance mode", "i18n:govoplan-admin.enable_maintenance_mode.75f98d57": "Enable maintenance mode",
"i18n:govoplan-admin.enabled_modules.3c38e9ff": "Enabled modules:",
"i18n:govoplan-admin.executor_available.507de429": "Executor available",
"i18n:govoplan-admin.executor_pending.7633225b": "Executor pending",
"i18n:govoplan-admin.enabled.df174a3f": "Enabled", "i18n:govoplan-admin.enabled.df174a3f": "Enabled",
"i18n:govoplan-admin.enabling_maintenance_mode_requires_system_mainte.062d3968": "Enabling maintenance mode requires system:maintenance:access.", "i18n:govoplan-admin.enabling_maintenance_mode_requires_system_mainte.062d3968": "Enabling maintenance mode requires system:maintenance:access.",
"i18n:govoplan-admin.enabling.2b8e03e6": "Enabling...", "i18n:govoplan-admin.enabling.2b8e03e6": "Enabling...",
@@ -121,6 +181,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.exported_tenant_access_configuration.9122c85c": "Exported tenant access configuration", "i18n:govoplan-admin.exported_tenant_access_configuration.9122c85c": "Exported tenant access configuration",
"i18n:govoplan-admin.features.5df81ffa": "Features:", "i18n:govoplan-admin.features.5df81ffa": "Features:",
"i18n:govoplan-admin.file_connections.1e362326": "File connections", "i18n:govoplan-admin.file_connections.1e362326": "File connections",
"i18n:govoplan-admin.forward_only.6c107a46": "Forward-only",
"i18n:govoplan-admin.finished.4b52fe3f": "Finished:", "i18n:govoplan-admin.finished.4b52fe3f": "Finished:",
"i18n:govoplan-admin.fragment.3f19d616": "Fragment", "i18n:govoplan-admin.fragment.3f19d616": "Fragment",
"i18n:govoplan-admin.front_office.d9dcfee1": "Front office", "i18n:govoplan-admin.front_office.d9dcfee1": "Front office",
@@ -142,6 +203,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.history.90ccd649": "History", "i18n:govoplan-admin.history.90ccd649": "History",
"i18n:govoplan-admin.id.474ae526": "Id", "i18n:govoplan-admin.id.474ae526": "Id",
"i18n:govoplan-admin.import_preflight_approve_apply_and_export_module.29aec929": "Import, preflight, approve, apply and export module-owned configuration packages.", "i18n:govoplan-admin.import_preflight_approve_apply_and_export_module.29aec929": "Import, preflight, approve, apply and export module-owned configuration packages.",
"i18n:govoplan-admin.idempotent.47a0f2d6": "Idempotent",
"i18n:govoplan-admin.inactive.09af574c": "Inactive", "i18n:govoplan-admin.inactive.09af574c": "Inactive",
"i18n:govoplan-admin.inspect_value.9d5d1071": "Inspect {value0}", "i18n:govoplan-admin.inspect_value.9d5d1071": "Inspect {value0}",
"i18n:govoplan-admin.install.fd6c3ebf": "Install", "i18n:govoplan-admin.install.fd6c3ebf": "Install",
@@ -169,6 +231,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.license.de13bf1a": "License:", "i18n:govoplan-admin.license.de13bf1a": "License:",
"i18n:govoplan-admin.locked.a798882f": "Locked", "i18n:govoplan-admin.locked.a798882f": "Locked",
"i18n:govoplan-admin.mail_servers.d627326a": "Mail servers", "i18n:govoplan-admin.mail_servers.d627326a": "Mail servers",
"i18n:govoplan-admin.manifest_metadata.b9ae362a": "Manifest metadata",
"i18n:govoplan-admin.maintenance_message.ca62571f": "Maintenance message", "i18n:govoplan-admin.maintenance_message.ca62571f": "Maintenance message",
"i18n:govoplan-admin.maintenance_mode_enabled_apply_the_module_state_.dda13e5c": "Maintenance mode enabled. Apply the module-state request when ready.", "i18n:govoplan-admin.maintenance_mode_enabled_apply_the_module_state_.dda13e5c": "Maintenance mode enabled. Apply the module-state request when ready.",
"i18n:govoplan-admin.maintenance_mode_is_off_package_changes_should_b.1d262629": "Maintenance mode is off. Package changes should be applied only after enabling maintenance mode.", "i18n:govoplan-admin.maintenance_mode_is_off_package_changes_should_b.1d262629": "Maintenance mode is off. Package changes should be applied only after enabling maintenance mode.",
@@ -177,6 +240,10 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.maintenance.94de303b": "Maintenance", "i18n:govoplan-admin.maintenance.94de303b": "Maintenance",
"i18n:govoplan-admin.membership_status_groups_and_direct_roles_in_the.ab6c10b6": "Membership status, groups and direct roles in the active tenant.", "i18n:govoplan-admin.membership_status_groups_and_direct_roles_in_the.ab6c10b6": "Membership status, groups and direct roles in the active tenant.",
"i18n:govoplan-admin.message.68f4145f": "Message", "i18n:govoplan-admin.message.68f4145f": "Message",
"i18n:govoplan-admin.metadata_pending.25190d87": "Metadata pending",
"i18n:govoplan-admin.migration_plan.f42b9d90": "Migration plan",
"i18n:govoplan-admin.migration_safety.729bdb3c": "Migration safety:",
"i18n:govoplan-admin.migration_tasks.a4410d3a": "Migration tasks",
"i18n:govoplan-admin.minimal_office_access.af48f49a": "Minimal office access", "i18n:govoplan-admin.minimal_office_access.af48f49a": "Minimal office access",
"i18n:govoplan-admin.missing_entitlement.0cafa611": "Missing entitlement", "i18n:govoplan-admin.missing_entitlement.0cafa611": "Missing entitlement",
"i18n:govoplan-admin.missing.feb2bbaa": "Missing:", "i18n:govoplan-admin.missing.feb2bbaa": "Missing:",
@@ -204,6 +271,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.none.6eef6648": "None", "i18n:govoplan-admin.none.6eef6648": "None",
"i18n:govoplan-admin.not_available.d1a17af1": "Not available", "i18n:govoplan-admin.not_available.d1a17af1": "Not available",
"i18n:govoplan-admin.not_configured.811931bb": "Not configured", "i18n:govoplan-admin.not_configured.811931bb": "Not configured",
"i18n:govoplan-admin.not_idempotent.d3014c71": "Not idempotent",
"i18n:govoplan-admin.not_recorded.9925ee3c": "not recorded", "i18n:govoplan-admin.not_recorded.9925ee3c": "not recorded",
"i18n:govoplan-admin.notes.70440046": "Notes", "i18n:govoplan-admin.notes.70440046": "Notes",
"i18n:govoplan-admin.object.2883f191": "Object", "i18n:govoplan-admin.object.2883f191": "Object",
@@ -223,8 +291,14 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.package.7431e3df": "Package", "i18n:govoplan-admin.package.7431e3df": "Package",
"i18n:govoplan-admin.packages.0a999012": "Packages", "i18n:govoplan-admin.packages.0a999012": "Packages",
"i18n:govoplan-admin.pending.c515ec74": " pending", "i18n:govoplan-admin.pending.c515ec74": " pending",
"i18n:govoplan-admin.pending_metadata.a24cfeb0": "Pending metadata",
"i18n:govoplan-admin.permissions.d06d5557": "Permissions", "i18n:govoplan-admin.permissions.d06d5557": "Permissions",
"i18n:govoplan-admin.plan_install.e82bffe6": "Plan install", "i18n:govoplan-admin.plan_install.e82bffe6": "Plan install",
"i18n:govoplan-admin.plan_update.86e6857a": "Plan update",
"i18n:govoplan-admin.post_migration_backfill.21043d74": "Post-migration backfill",
"i18n:govoplan-admin.post_migration_verify.6af9f28b": "Post-migration verify",
"i18n:govoplan-admin.pre_migration_check.f654164a": "Pre-migration check",
"i18n:govoplan-admin.pre_migration_prepare.d18fdd26": "Pre-migration prepare",
"i18n:govoplan-admin.plan_package_installs_and_removals_here_then_app.4aeb03bf": "Plan package installs and removals here, then apply the rendered commands from an operator shell during maintenance mode.", "i18n:govoplan-admin.plan_package_installs_and_removals_here_then_app.4aeb03bf": "Plan package installs and removals here, then apply the rendered commands from an operator shell during maintenance mode.",
"i18n:govoplan-admin.plan_uninstall.e62804ab": "Plan uninstall", "i18n:govoplan-admin.plan_uninstall.e62804ab": "Plan uninstall",
"i18n:govoplan-admin.plan.ae2f98a0": "Plan", "i18n:govoplan-admin.plan.ae2f98a0": "Plan",
@@ -241,6 +315,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.queued_daemon_handoffs_created_from_the_admin_ui.d7ae7914": "Queued daemon handoffs created from the admin UI or CLI.", "i18n:govoplan-admin.queued_daemon_handoffs_created_from_the_admin_ui.d7ae7914": "Queued daemon handoffs created from the admin UI or CLI.",
"i18n:govoplan-admin.queueing_installer_requests_requires_maintenance.ae81f9ac": "Queueing installer requests requires maintenance access.", "i18n:govoplan-admin.queueing_installer_requests_requires_maintenance.ae81f9ac": "Queueing installer requests requires maintenance access.",
"i18n:govoplan-admin.recent_supervised_installer_records_and_the_curr.29860245": "Recent supervised installer records and the current package-install lock.", "i18n:govoplan-admin.recent_supervised_installer_records_and_the_curr.29860245": "Recent supervised installer records and the current package-install lock.",
"i18n:govoplan-admin.recovery_tested.e831e2f1": "Recovery tested",
"i18n:govoplan-admin.registered_tenant_spaces.61e17d70": "Registered tenant spaces.", "i18n:govoplan-admin.registered_tenant_spaces.61e17d70": "Registered tenant spaces.",
"i18n:govoplan-admin.reload.cce71553": "Reload", "i18n:govoplan-admin.reload.cce71553": "Reload",
"i18n:govoplan-admin.remove.e963907d": "Remove", "i18n:govoplan-admin.remove.e963907d": "Remove",
@@ -253,10 +328,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.required.d5793988": "Required:", "i18n:govoplan-admin.required.d5793988": "Required:",
"i18n:govoplan-admin.required.eed6bfb4": "Required", "i18n:govoplan-admin.required.eed6bfb4": "Required",
"i18n:govoplan-admin.requires.a4fc9357": "Requires:", "i18n:govoplan-admin.requires.a4fc9357": "Requires:",
"i18n:govoplan-admin.requires_review.8ba0b8c6": "Review required",
"i18n:govoplan-admin.restart_commands.035997f9": "Restart commands", "i18n:govoplan-admin.restart_commands.035997f9": "Restart commands",
"i18n:govoplan-admin.restart_reload_required_after_package_changes.3a99cd63": "Restart/reload required after package changes", "i18n:govoplan-admin.restart_reload_required_after_package_changes.3a99cd63": "Restart/reload required after package changes",
"i18n:govoplan-admin.restrict_authenticated_api_access_to_maintenance.2bc47195": "Restrict authenticated API access to maintenance operators", "i18n:govoplan-admin.restrict_authenticated_api_access_to_maintenance.2bc47195": "Restrict authenticated API access to maintenance operators",
"i18n:govoplan-admin.retention.c7199d9e": "Retention", "i18n:govoplan-admin.retention.c7199d9e": "Retention",
"i18n:govoplan-admin.retirement.6b680dd2": "Retirement",
"i18n:govoplan-admin.retry_of.3a6bb304": "Retry of:", "i18n:govoplan-admin.retry_of.3a6bb304": "Retry of:",
"i18n:govoplan-admin.retry.9f5cd8a2": "Retry", "i18n:govoplan-admin.retry.9f5cd8a2": "Retry",
"i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1": "Reusable encrypted SMTP/IMAP profiles and mail policy.", "i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1": "Reusable encrypted SMTP/IMAP profiles and mail policy.",
@@ -272,7 +349,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.save_plan.842dc280": "Save plan", "i18n:govoplan-admin.save_plan.842dc280": "Save plan",
"i18n:govoplan-admin.save_settings.913aba9f": "Save settings", "i18n:govoplan-admin.save_settings.913aba9f": "Save settings",
"i18n:govoplan-admin.save_template.0885fab2": "Save template", "i18n:govoplan-admin.save_template.0885fab2": "Save template",
"i18n:govoplan-admin.save_tenant_role.8fc0d37d": "Save tenant role", "i18n:govoplan-admin.save_tenant_role.8fc0d37d": "Save role template",
"i18n:govoplan-admin.save_the_install_plan_before_queueing_a_daemon_r.3ba00691": "Save the install plan before queueing a daemon request.", "i18n:govoplan-admin.save_the_install_plan_before_queueing_a_daemon_r.3ba00691": "Save the install plan before queueing a daemon request.",
"i18n:govoplan-admin.saved.c0ae8f6e": "Saved", "i18n:govoplan-admin.saved.c0ae8f6e": "Saved",
"i18n:govoplan-admin.saving.56a2285c": "Saving…", "i18n:govoplan-admin.saving.56a2285c": "Saving…",
@@ -302,6 +379,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.system_settings_saved.dac4f17b": "System settings saved.", "i18n:govoplan-admin.system_settings_saved.dac4f17b": "System settings saved.",
"i18n:govoplan-admin.system_wide_governance_and_tenant_local_access_m.cda72499": "System-wide governance and tenant-local access management, separated by scope and enforced by the backend.", "i18n:govoplan-admin.system_wide_governance_and_tenant_local_access_m.cda72499": "System-wide governance and tenant-local access management, separated by scope and enforced by the backend.",
"i18n:govoplan-admin.system.bc0792d8": "System", "i18n:govoplan-admin.system.bc0792d8": "System",
"i18n:govoplan-admin.target_plan.524ea0c8": "Target plan",
"i18n:govoplan-admin.task_version.f0f26b92": "Task version",
"i18n:govoplan-admin.target.61ad50a9": "Target", "i18n:govoplan-admin.target.61ad50a9": "Target",
"i18n:govoplan-admin.template_details.d5d75e4d": "Template details", "i18n:govoplan-admin.template_details.d5d75e4d": "Template details",
"i18n:govoplan-admin.tenant_administration_capabilities.5d265972": "Tenant administration capabilities", "i18n:govoplan-admin.tenant_administration_capabilities.5d265972": "Tenant administration capabilities",
@@ -315,9 +394,9 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.tenant_locale_and_tenant_specific_settings.ac49c83b": "Tenant locale and tenant-specific settings.", "i18n:govoplan-admin.tenant_locale_and_tenant_specific_settings.ac49c83b": "Tenant locale and tenant-specific settings.",
"i18n:govoplan-admin.tenant_memberships_and_inherited_roles.16eda9f6": "Tenant memberships and inherited roles.", "i18n:govoplan-admin.tenant_memberships_and_inherited_roles.16eda9f6": "Tenant memberships and inherited roles.",
"i18n:govoplan-admin.tenant_permission_bundles_and_system_managed_rol.bb563bea": "Tenant permission bundles and system-managed role copies.", "i18n:govoplan-admin.tenant_permission_bundles_and_system_managed_rol.bb563bea": "Tenant permission bundles and system-managed role copies.",
"i18n:govoplan-admin.tenant_permissions.246294bc": "Tenant permissions", "i18n:govoplan-admin.tenant_permissions.246294bc": "Permissions",
"i18n:govoplan-admin.tenant_role.6b53115d": "Tenant role", "i18n:govoplan-admin.tenant_role.6b53115d": "Role template",
"i18n:govoplan-admin.tenant_roles.51aca82d": "Tenant roles", "i18n:govoplan-admin.tenant_roles.51aca82d": "Role templates",
"i18n:govoplan-admin.tenant_users.cb800b38": "Tenant users", "i18n:govoplan-admin.tenant_users.cb800b38": "Tenant users",
"i18n:govoplan-admin.tenants.1f7ae776": "Tenants", "i18n:govoplan-admin.tenants.1f7ae776": "Tenants",
"i18n:govoplan-admin.these_settings_are_enforced_by_the_backend_centr.8112ed6f": "These settings are enforced by the backend. Central groups and tenant roles remain available even when local creation is disabled.", "i18n:govoplan-admin.these_settings_are_enforced_by_the_backend_centr.8112ed6f": "These settings are enforced by the backend. Central groups and tenant roles remain available even when local creation is disabled.",
@@ -328,6 +407,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.unlocked.b3da7025": "Unlocked", "i18n:govoplan-admin.unlocked.b3da7025": "Unlocked",
"i18n:govoplan-admin.unsigned.e91344ea": "Unsigned", "i18n:govoplan-admin.unsigned.e91344ea": "Unsigned",
"i18n:govoplan-admin.untrusted.cdc7838a": "Untrusted", "i18n:govoplan-admin.untrusted.cdc7838a": "Untrusted",
"i18n:govoplan-admin.upgrade.12c5007d": "Upgrade",
"i18n:govoplan-admin.update.503a059f": "Update",
"i18n:govoplan-admin.update_enabled_modules.9b7ae790": "Update enabled modules", "i18n:govoplan-admin.update_enabled_modules.9b7ae790": "Update enabled modules",
"i18n:govoplan-admin.updated.f2f8570d": "Updated", "i18n:govoplan-admin.updated.f2f8570d": "Updated",
"i18n:govoplan-admin.user_file_connector_policy_limits": "File connector policy limits for user-owned spaces.", "i18n:govoplan-admin.user_file_connector_policy_limits": "File connector policy limits for user-owned spaces.",
@@ -357,17 +438,66 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.working.049ac820": "Working..." "i18n:govoplan-admin.working.049ac820": "Working..."
}, },
"de": { "de": {
"i18n:govoplan-admin.administration_data_is_loading.6bf3c001": "Administrationsdaten werden geladen.",
"i18n:govoplan-admin.an_administration_operation_is_in_progress.6bf3c002": "Eine Administrationsaktion wird gerade ausgeführt.",
"i18n:govoplan-admin.system_administration_write_permission_is_required.6bf3c003": "Eine Schreibberechtigung für die Systemadministration ist erforderlich.",
"i18n:govoplan-admin.system_governance_write_permission_is_required.6bf3c004": "Eine Schreibberechtigung für die System-Governance ist erforderlich.",
"i18n:govoplan-admin.complete_the_required_fields_before_saving.6bf3c005": "Füllen Sie vor dem Speichern die Pflichtfelder aus.",
"i18n:govoplan-admin.make_a_change_before_saving.6bf3c006": "Nehmen Sie vor dem Speichern eine Änderung vor.",
"i18n:govoplan-admin.provide_valid_package_and_supplied_data_json.6bf3c007": "Geben Sie gültiges Paket- und Zusatzdaten-JSON ein.",
"i18n:govoplan-admin.provide_valid_package_json_before_requesting_approval.6bf3c008": "Geben Sie vor der Genehmigungsanfrage gültiges Paket-JSON ein.",
"i18n:govoplan-admin.add_at_least_one_valid_plan_item.6bf3c009": "Fügen Sie mindestens einen gültigen Planeintrag hinzu.",
"i18n:govoplan-admin.save_the_changed_plan_before_queueing_it.6bf3c010": "Speichern Sie den geänderten Plan vor dem Einreihen.",
"i18n:govoplan-admin.system_maintenance_authority_is_required.6bf3c011": "Die Berechtigung für die Systemwartung ist erforderlich.",
"i18n:govoplan-admin.enable_maintenance_mode_before_this_action.6bf3c012": "Aktivieren Sie vor dieser Aktion den Wartungsmodus.",
"i18n:govoplan-admin.this_protected_module_cannot_be_changed.6bf3c013": "Dieses geschützte Modul kann nicht geändert werden.",
"i18n:govoplan-admin.deactivate_the_module_before_planning_uninstall.6bf3c014": "Deaktivieren Sie das Modul, bevor Sie die Deinstallation planen.",
"i18n:govoplan-admin.resolve_the_catalog_license_or_action_blocker_first.6bf3c015": "Beheben Sie zuerst die Katalog-, Lizenz- oder Paketaktionssperre.",
"i18n:govoplan-admin.approve_configuration_change.6bf3c016": "Konfigurationsänderung genehmigen",
"i18n:govoplan-admin.approving_records_your_authority_and_may_unlock_application.6bf3c017": "Die Genehmigung dokumentiert Ihre Autorität und kann die Anwendung dieser Konfigurationsänderung freigeben.",
"i18n:govoplan-admin.apply_configuration_package.6bf3c018": "Konfigurationspaket anwenden",
"i18n:govoplan-admin.apply_the_current_package_to_the_selected_scope.6bf3c019": "Das aktuelle Paket auf den ausgewählten Bereich anwenden? Dadurch kann modulbezogene Konfiguration erstellt oder geändert werden.",
"i18n:govoplan-admin.apply_package.6bf3c020": "Paket anwenden",
"i18n:govoplan-admin.enter_reference_ids_manually.6bf3c021": "Referenz-IDs manuell eingeben",
"i18n:govoplan-admin.use_manual_ids_only_for_intentional_historical_references.6bf3c022": "Verwenden Sie manuelle IDs nur, wenn ein Paket absichtlich auf ein nicht mehr im Live-Katalog enthaltenes Ziel verweist.",
"i18n:govoplan-admin.configuration_package_tenant.6bf3c023": "Mandant des Konfigurationspakets",
"i18n:govoplan-admin.select_a_tenant.6bf3c024": "Mandanten auswählen",
"i18n:govoplan-admin.configuration_package_change_request.6bf3c025": "Änderungsantrag für das Konfigurationspaket",
"i18n:govoplan-admin.select_an_eligible_request_optional.6bf3c026": "Geeigneten Antrag auswählen (optional)",
"i18n:govoplan-admin.no_eligible_configuration_requests.6bf3c027": "Keine geeigneten Konfigurationsanträge vorhanden.",
"i18n:govoplan-admin.global.6bf3c028": "Global",
"i18n:govoplan-admin.tenant.6bf3c029": "Mandant",
"i18n:govoplan-admin.user.6bf3c030": "Benutzer",
"i18n:govoplan-admin.request_is_not_pending_approval.6bf3c031": "Dieser Antrag wartet nicht auf eine Genehmigung.",
"i18n:govoplan-admin.clear_saved_module_plan.6bf3c032": "Gespeicherten Modulplan leeren",
"i18n:govoplan-admin.clear_all_saved_module_install_update_and_uninstall_items.6bf3c033": "Alle gespeicherten Installations-, Aktualisierungs- und Deinstallationseinträge löschen?",
"i18n:govoplan-admin.enable_maintenance_mode.6bf3c034": "Wartungsmodus aktivieren",
"i18n:govoplan-admin.restrict_normal_authenticated_access_while_module_work_runs.6bf3c035": "Den normalen authentifizierten Zugriff während der beaufsichtigten Modularbeiten einschränken?",
"i18n:govoplan-admin.cancel_installer_request.6bf3c036": "Installationsantrag abbrechen",
"i18n:govoplan-admin.cancel_the_queued_request_before_the_daemon_accepts_it.6bf3c037": "Diesen eingereihten Antrag abbrechen, bevor der Installationsdienst ihn annimmt?",
"i18n:govoplan-admin.this_option_only_applies_to_uninstall_plans.6bf3c038": "Diese Option gilt nur für Deinstallationspläne.",
"i18n:govoplan-admin.this_option_only_applies_to_install_and_update_plans.6bf3c039": "Diese Option gilt nur für Installations- und Aktualisierungspläne.",
"i18n:govoplan-admin.module_lifecycle_progress.f1a20201": "Fortschritt des Modullebenszyklus",
"i18n:govoplan-admin.queue_supervised_run_unavailable.f1a20202": "Ein überwachter Lauf kann nicht eingereiht werden",
"i18n:govoplan-admin.required_action.f1a20203": "Erforderliche Maßnahme",
"i18n:govoplan-admin.who_can_fix_it.f1a20204": "Zuständig",
"i18n:govoplan-admin.where_to_go.f1a20205": "Ziel",
"i18n:govoplan-admin.current_operator.f1a20206": "Aktuell ausführende Person",
"i18n:govoplan-admin.system_administrator.f1a20207": "Systemadministration",
"i18n:govoplan-admin.module_write_access_is_required.f1a20208": "Schreibzugriff auf Module ist erforderlich.",
"i18n:govoplan-admin.access_configuration_exported.098f200d": "Access configuration exported.", "i18n:govoplan-admin.access_configuration_exported.098f200d": "Access configuration exported.",
"i18n:govoplan-admin.action.97c89a4d": "Action", "i18n:govoplan-admin.action.97c89a4d": "Action",
"i18n:govoplan-admin.actions.c3cd636a": "Aktionen", "i18n:govoplan-admin.actions.c3cd636a": "Aktionen",
"i18n:govoplan-admin.activate_installs.731d22a1": "Installationen aktivieren", "i18n:govoplan-admin.activate_installs.731d22a1": "Installationen aktivieren",
"i18n:govoplan-admin.acknowledged.d08d7c6d": "Bestaetigt",
"i18n:govoplan-admin.active_and_total_memberships.c0c20f10": "Active and total memberships.", "i18n:govoplan-admin.active_and_total_memberships.c0c20f10": "Active and total memberships.",
"i18n:govoplan-admin.active_tenant_automation_credentials.240c659e": "Active tenant automation credentials.", "i18n:govoplan-admin.active_tenant_automation_credentials.240c659e": "Active tenant automation credentials.",
"i18n:govoplan-admin.active_tenant.dfadf0aa": "Active tenant:", "i18n:govoplan-admin.active_tenant.dfadf0aa": "Active tenant:",
"i18n:govoplan-admin.active.a733b809": "Aktiv", "i18n:govoplan-admin.active.a733b809": "Aktiv",
"i18n:govoplan-admin.after.695c89be": "Nach:",
"i18n:govoplan-admin.add_group_template.b74d8f0f": "Add group template", "i18n:govoplan-admin.add_group_template.b74d8f0f": "Add group template",
"i18n:govoplan-admin.add_plan_item.fa55f028": "Add plan item", "i18n:govoplan-admin.add_plan_item.fa55f028": "Add plan item",
"i18n:govoplan-admin.add_tenant_role.fcd904ea": "Add tenant role", "i18n:govoplan-admin.add_tenant_role.fcd904ea": "Rollenvorlage hinzufügen",
"i18n:govoplan-admin.admin.4e7afebc": "Administration", "i18n:govoplan-admin.admin.4e7afebc": "Administration",
"i18n:govoplan-admin.administration.b8be3d12": "Administration", "i18n:govoplan-admin.administration.b8be3d12": "Administration",
"i18n:govoplan-admin.platform_administration": "Plattformadministration", "i18n:govoplan-admin.platform_administration": "Plattformadministration",
@@ -389,9 +519,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.audit.fa1703dd": "Audit", "i18n:govoplan-admin.audit.fa1703dd": "Audit",
"i18n:govoplan-admin.available.7c62a142": "Verfügbar", "i18n:govoplan-admin.available.7c62a142": "Verfügbar",
"i18n:govoplan-admin.blocked.99613c74": "Blockiert", "i18n:govoplan-admin.blocked.99613c74": "Blockiert",
"i18n:govoplan-admin.before.a11cf31f": "Vor:",
"i18n:govoplan-admin.bridge_release.176cf241": "Brueckenrelease",
"i18n:govoplan-admin.build_webui.ed5ef1fd": " --build-webui", "i18n:govoplan-admin.build_webui.ed5ef1fd": " --build-webui",
"i18n:govoplan-admin.build_webui.fe8ccad7": "WebUI bauen", "i18n:govoplan-admin.build_webui.fe8ccad7": "WebUI bauen",
"i18n:govoplan-admin.bypass_allowed.4e347c27": "Bypass allowed", "i18n:govoplan-admin.bypass_allowed.4e347c27": "Bypass allowed",
"i18n:govoplan-admin.catalog_metadata.9b96c41a": "Katalogmetadaten",
"i18n:govoplan-admin.bypass_denied.ab987400": "Bypass denied", "i18n:govoplan-admin.bypass_denied.ab987400": "Bypass denied",
"i18n:govoplan-admin.cached_from.d73c5b2a": "· cached from", "i18n:govoplan-admin.cached_from.d73c5b2a": "· cached from",
"i18n:govoplan-admin.cancel.77dfd213": "Abbrechen", "i18n:govoplan-admin.cancel.77dfd213": "Abbrechen",
@@ -425,10 +558,13 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.create_group_template.72407248": "Create group template", "i18n:govoplan-admin.create_group_template.72407248": "Create group template",
"i18n:govoplan-admin.create_request_and_apply.da79e153": "Create request and apply", "i18n:govoplan-admin.create_request_and_apply.da79e153": "Create request and apply",
"i18n:govoplan-admin.create_suspend_and_govern_tenant_spaces.77992a39": "Create, suspend and govern tenant spaces.", "i18n:govoplan-admin.create_suspend_and_govern_tenant_spaces.77992a39": "Create, suspend and govern tenant spaces.",
"i18n:govoplan-admin.create_tenant_role.f58db104": "Create tenant role", "i18n:govoplan-admin.create_tenant_role.f58db104": "Rollenvorlage erstellen",
"i18n:govoplan-admin.created.0c78dab1": "Created:", "i18n:govoplan-admin.created.0c78dab1": "Created:",
"i18n:govoplan-admin.created.accf40c8": "Erstellt", "i18n:govoplan-admin.created.accf40c8": "Erstellt",
"i18n:govoplan-admin.current_window.73e25c9f": "Aktuelles Fenster:",
"i18n:govoplan-admin.automatic.d9a7260f": "Automatisch",
"i18n:govoplan-admin.daemon_execution.cc0fad8d": "Daemon-Ausführung", "i18n:govoplan-admin.daemon_execution.cc0fad8d": "Daemon-Ausführung",
"i18n:govoplan-admin.data_safety_reviewed.b4774edc": "Datensicherheit geprueft",
"i18n:govoplan-admin.daemon_offline.314f19a4": "Daemon offline", "i18n:govoplan-admin.daemon_offline.314f19a4": "Daemon offline",
"i18n:govoplan-admin.daemon_value.e54f5a31": "Daemon: {value0}", "i18n:govoplan-admin.daemon_value.e54f5a31": "Daemon: {value0}",
"i18n:govoplan-admin.db_backup_command.5bded3ac": "DB-Backup-Befehl", "i18n:govoplan-admin.db_backup_command.5bded3ac": "DB-Backup-Befehl",
@@ -447,11 +583,13 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.defaults_for_newly_created_tenants.9d0f4ccd": "Standards für neu erstellte Mandanten", "i18n:govoplan-admin.defaults_for_newly_created_tenants.9d0f4ccd": "Standards für neu erstellte Mandanten",
"i18n:govoplan-admin.delete_group_template.8745d842": "Delete group template", "i18n:govoplan-admin.delete_group_template.8745d842": "Delete group template",
"i18n:govoplan-admin.delete_template.399bf72a": "Delete template", "i18n:govoplan-admin.delete_template.399bf72a": "Delete template",
"i18n:govoplan-admin.delete_tenant_role.4efa0813": "Delete tenant role", "i18n:govoplan-admin.delete_tenant_role.4efa0813": "Rollenvorlage löschen",
"i18n:govoplan-admin.delete_value_removal_is_blocked_while_a_material.d4eba73d": "Delete {value0}? Removal is blocked while a materialized tenant definition still has members or assignments.", "i18n:govoplan-admin.delete_value_removal_is_blocked_while_a_material.d4eba73d": "Delete {value0}? Removal is blocked while a materialized tenant definition still has members or assignments.",
"i18n:govoplan-admin.delete_value.4d18989e": "Delete {value0}", "i18n:govoplan-admin.delete_value.4d18989e": "Delete {value0}",
"i18n:govoplan-admin.dependencies.9f4f78d1": "Abhaengigkeiten:",
"i18n:govoplan-admin.dependents.072665c4": "Dependents:", "i18n:govoplan-admin.dependents.072665c4": "Dependents:",
"i18n:govoplan-admin.description.55f8ebc8": "Beschreibung", "i18n:govoplan-admin.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-admin.destructive.0051026c": "Destruktiv",
"i18n:govoplan-admin.destroy_data.34557c3c": "Daten zerstören", "i18n:govoplan-admin.destroy_data.34557c3c": "Daten zerstören",
"i18n:govoplan-admin.disabled.f4f4473d": "Disabled", "i18n:govoplan-admin.disabled.f4f4473d": "Disabled",
"i18n:govoplan-admin.discovered_packages.660902de": "Discovered packages", "i18n:govoplan-admin.discovered_packages.660902de": "Discovered packages",
@@ -460,9 +598,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.dry_run_change_request_created_value_enable_main.49826d07": "Dry-run change request created: {value0}. Enable maintenance mode, then apply the request.", "i18n:govoplan-admin.dry_run_change_request_created_value_enable_main.49826d07": "Dry-run change request created: {value0}. Enable maintenance mode, then apply the request.",
"i18n:govoplan-admin.dry_run.3d14659c": "Dry-run", "i18n:govoplan-admin.dry_run.3d14659c": "Dry-run",
"i18n:govoplan-admin.edit_group_template.9bc72d21": "Edit group template", "i18n:govoplan-admin.edit_group_template.9bc72d21": "Edit group template",
"i18n:govoplan-admin.edit_tenant_role.c15a260d": "Edit tenant role", "i18n:govoplan-admin.edit_tenant_role.c15a260d": "Rollenvorlage bearbeiten",
"i18n:govoplan-admin.edit_value.fad75899": "Edit {value0}", "i18n:govoplan-admin.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-admin.enable_maintenance_mode.75f98d57": "Enable maintenance mode", "i18n:govoplan-admin.enable_maintenance_mode.75f98d57": "Enable maintenance mode",
"i18n:govoplan-admin.enabled_modules.3c38e9ff": "Aktive Module:",
"i18n:govoplan-admin.executor_available.507de429": "Ausfuehrer verfuegbar",
"i18n:govoplan-admin.executor_pending.7633225b": "Ausfuehrer ausstehend",
"i18n:govoplan-admin.enabled.df174a3f": "Aktiviert", "i18n:govoplan-admin.enabled.df174a3f": "Aktiviert",
"i18n:govoplan-admin.enabling_maintenance_mode_requires_system_mainte.062d3968": "Enabling maintenance mode requires system:maintenance:access.", "i18n:govoplan-admin.enabling_maintenance_mode_requires_system_mainte.062d3968": "Enabling maintenance mode requires system:maintenance:access.",
"i18n:govoplan-admin.enabling.2b8e03e6": "Enabling...", "i18n:govoplan-admin.enabling.2b8e03e6": "Enabling...",
@@ -476,6 +617,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.exported_tenant_access_configuration.9122c85c": "Exported tenant access configuration", "i18n:govoplan-admin.exported_tenant_access_configuration.9122c85c": "Exported tenant access configuration",
"i18n:govoplan-admin.features.5df81ffa": "Features:", "i18n:govoplan-admin.features.5df81ffa": "Features:",
"i18n:govoplan-admin.file_connections.1e362326": "Dateiverbindungen", "i18n:govoplan-admin.file_connections.1e362326": "Dateiverbindungen",
"i18n:govoplan-admin.forward_only.6c107a46": "Nur vorwaerts",
"i18n:govoplan-admin.finished.4b52fe3f": "Finished:", "i18n:govoplan-admin.finished.4b52fe3f": "Finished:",
"i18n:govoplan-admin.fragment.3f19d616": "Fragment", "i18n:govoplan-admin.fragment.3f19d616": "Fragment",
"i18n:govoplan-admin.front_office.d9dcfee1": "Front office", "i18n:govoplan-admin.front_office.d9dcfee1": "Front office",
@@ -497,6 +639,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.history.90ccd649": "History", "i18n:govoplan-admin.history.90ccd649": "History",
"i18n:govoplan-admin.id.474ae526": "Id", "i18n:govoplan-admin.id.474ae526": "Id",
"i18n:govoplan-admin.import_preflight_approve_apply_and_export_module.29aec929": "Modul-eigene Konfigurationspakete importieren, prüfen, freigeben, anwenden und exportieren.", "i18n:govoplan-admin.import_preflight_approve_apply_and_export_module.29aec929": "Modul-eigene Konfigurationspakete importieren, prüfen, freigeben, anwenden und exportieren.",
"i18n:govoplan-admin.idempotent.47a0f2d6": "Idempotent",
"i18n:govoplan-admin.inactive.09af574c": "Inaktiv", "i18n:govoplan-admin.inactive.09af574c": "Inaktiv",
"i18n:govoplan-admin.inspect_value.9d5d1071": "Inspect {value0}", "i18n:govoplan-admin.inspect_value.9d5d1071": "Inspect {value0}",
"i18n:govoplan-admin.install.fd6c3ebf": "Installieren", "i18n:govoplan-admin.install.fd6c3ebf": "Installieren",
@@ -524,6 +667,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.license.de13bf1a": "License:", "i18n:govoplan-admin.license.de13bf1a": "License:",
"i18n:govoplan-admin.locked.a798882f": "Gesperrt", "i18n:govoplan-admin.locked.a798882f": "Gesperrt",
"i18n:govoplan-admin.mail_servers.d627326a": "Mail servers", "i18n:govoplan-admin.mail_servers.d627326a": "Mail servers",
"i18n:govoplan-admin.manifest_metadata.b9ae362a": "Manifestmetadaten",
"i18n:govoplan-admin.maintenance_message.ca62571f": "Wartungsmeldung", "i18n:govoplan-admin.maintenance_message.ca62571f": "Wartungsmeldung",
"i18n:govoplan-admin.maintenance_mode_enabled_apply_the_module_state_.dda13e5c": "Maintenance mode enabled. Apply the module-state request when ready.", "i18n:govoplan-admin.maintenance_mode_enabled_apply_the_module_state_.dda13e5c": "Maintenance mode enabled. Apply the module-state request when ready.",
"i18n:govoplan-admin.maintenance_mode_is_off_package_changes_should_b.1d262629": "Maintenance mode is off. Package changes should be applied only after enabling maintenance mode.", "i18n:govoplan-admin.maintenance_mode_is_off_package_changes_should_b.1d262629": "Maintenance mode is off. Package changes should be applied only after enabling maintenance mode.",
@@ -532,6 +676,10 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.maintenance.94de303b": "Wartung", "i18n:govoplan-admin.maintenance.94de303b": "Wartung",
"i18n:govoplan-admin.membership_status_groups_and_direct_roles_in_the.ab6c10b6": "Membership status, groups and direct roles in the active tenant.", "i18n:govoplan-admin.membership_status_groups_and_direct_roles_in_the.ab6c10b6": "Membership status, groups and direct roles in the active tenant.",
"i18n:govoplan-admin.message.68f4145f": "Nachricht", "i18n:govoplan-admin.message.68f4145f": "Nachricht",
"i18n:govoplan-admin.metadata_pending.25190d87": "Metadaten ausstehend",
"i18n:govoplan-admin.migration_plan.f42b9d90": "Migrationsplan",
"i18n:govoplan-admin.migration_safety.729bdb3c": "Migrationssicherheit:",
"i18n:govoplan-admin.migration_tasks.a4410d3a": "Migrationsaufgaben",
"i18n:govoplan-admin.minimal_office_access.af48f49a": "Minimal office access", "i18n:govoplan-admin.minimal_office_access.af48f49a": "Minimal office access",
"i18n:govoplan-admin.missing_entitlement.0cafa611": "Missing entitlement", "i18n:govoplan-admin.missing_entitlement.0cafa611": "Missing entitlement",
"i18n:govoplan-admin.missing.feb2bbaa": "Missing:", "i18n:govoplan-admin.missing.feb2bbaa": "Missing:",
@@ -559,6 +707,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.none.6eef6648": "Keine", "i18n:govoplan-admin.none.6eef6648": "Keine",
"i18n:govoplan-admin.not_available.d1a17af1": "Not available", "i18n:govoplan-admin.not_available.d1a17af1": "Not available",
"i18n:govoplan-admin.not_configured.811931bb": "Nicht konfiguriert", "i18n:govoplan-admin.not_configured.811931bb": "Nicht konfiguriert",
"i18n:govoplan-admin.not_idempotent.d3014c71": "Nicht idempotent",
"i18n:govoplan-admin.not_recorded.9925ee3c": "not recorded", "i18n:govoplan-admin.not_recorded.9925ee3c": "not recorded",
"i18n:govoplan-admin.notes.70440046": "Notes", "i18n:govoplan-admin.notes.70440046": "Notes",
"i18n:govoplan-admin.object.2883f191": "Object", "i18n:govoplan-admin.object.2883f191": "Object",
@@ -578,8 +727,14 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.package.7431e3df": "Package", "i18n:govoplan-admin.package.7431e3df": "Package",
"i18n:govoplan-admin.packages.0a999012": "Pakete", "i18n:govoplan-admin.packages.0a999012": "Pakete",
"i18n:govoplan-admin.pending.c515ec74": " pending", "i18n:govoplan-admin.pending.c515ec74": " pending",
"i18n:govoplan-admin.pending_metadata.a24cfeb0": "Ausstehende Metadaten",
"i18n:govoplan-admin.permissions.d06d5557": "Berechtigungen", "i18n:govoplan-admin.permissions.d06d5557": "Berechtigungen",
"i18n:govoplan-admin.plan_install.e82bffe6": "Plan install", "i18n:govoplan-admin.plan_install.e82bffe6": "Plan install",
"i18n:govoplan-admin.plan_update.86e6857a": "Aktualisierung planen",
"i18n:govoplan-admin.post_migration_backfill.21043d74": "Nach-Migration Backfill",
"i18n:govoplan-admin.post_migration_verify.6af9f28b": "Nach-Migration Pruefung",
"i18n:govoplan-admin.pre_migration_check.f654164a": "Vor-Migration Check",
"i18n:govoplan-admin.pre_migration_prepare.d18fdd26": "Vor-Migration Vorbereitung",
"i18n:govoplan-admin.plan_package_installs_and_removals_here_then_app.4aeb03bf": "Plan package installs and removals here, then apply the rendered commands from an operator shell during maintenance mode.", "i18n:govoplan-admin.plan_package_installs_and_removals_here_then_app.4aeb03bf": "Plan package installs and removals here, then apply the rendered commands from an operator shell during maintenance mode.",
"i18n:govoplan-admin.plan_uninstall.e62804ab": "Plan uninstall", "i18n:govoplan-admin.plan_uninstall.e62804ab": "Plan uninstall",
"i18n:govoplan-admin.plan.ae2f98a0": "Plan", "i18n:govoplan-admin.plan.ae2f98a0": "Plan",
@@ -596,6 +751,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.queued_daemon_handoffs_created_from_the_admin_ui.d7ae7914": "Queued daemon handoffs created from the admin UI or CLI.", "i18n:govoplan-admin.queued_daemon_handoffs_created_from_the_admin_ui.d7ae7914": "Queued daemon handoffs created from the admin UI or CLI.",
"i18n:govoplan-admin.queueing_installer_requests_requires_maintenance.ae81f9ac": "Das Einreihen von Installer-Anfragen erfordert Wartungszugriff.", "i18n:govoplan-admin.queueing_installer_requests_requires_maintenance.ae81f9ac": "Das Einreihen von Installer-Anfragen erfordert Wartungszugriff.",
"i18n:govoplan-admin.recent_supervised_installer_records_and_the_curr.29860245": "Aktuelle überwachte Installer-Datensätze und die aktuelle Paketinstallationssperre.", "i18n:govoplan-admin.recent_supervised_installer_records_and_the_curr.29860245": "Aktuelle überwachte Installer-Datensätze und die aktuelle Paketinstallationssperre.",
"i18n:govoplan-admin.recovery_tested.e831e2f1": "Recovery getestet",
"i18n:govoplan-admin.registered_tenant_spaces.61e17d70": "Registered tenant spaces.", "i18n:govoplan-admin.registered_tenant_spaces.61e17d70": "Registered tenant spaces.",
"i18n:govoplan-admin.reload.cce71553": "Neu laden", "i18n:govoplan-admin.reload.cce71553": "Neu laden",
"i18n:govoplan-admin.remove.e963907d": "Entfernen", "i18n:govoplan-admin.remove.e963907d": "Entfernen",
@@ -608,10 +764,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.required.d5793988": "Required:", "i18n:govoplan-admin.required.d5793988": "Required:",
"i18n:govoplan-admin.required.eed6bfb4": "Required", "i18n:govoplan-admin.required.eed6bfb4": "Required",
"i18n:govoplan-admin.requires.a4fc9357": "Requires:", "i18n:govoplan-admin.requires.a4fc9357": "Requires:",
"i18n:govoplan-admin.requires_review.8ba0b8c6": "Pruefung erforderlich",
"i18n:govoplan-admin.restart_commands.035997f9": "Neustartbefehle", "i18n:govoplan-admin.restart_commands.035997f9": "Neustartbefehle",
"i18n:govoplan-admin.restart_reload_required_after_package_changes.3a99cd63": "Restart/reload required after package changes", "i18n:govoplan-admin.restart_reload_required_after_package_changes.3a99cd63": "Restart/reload required after package changes",
"i18n:govoplan-admin.restrict_authenticated_api_access_to_maintenance.2bc47195": "Authentifizierten API-Zugriff auf Wartungsoperatoren beschränken", "i18n:govoplan-admin.restrict_authenticated_api_access_to_maintenance.2bc47195": "Authentifizierten API-Zugriff auf Wartungsoperatoren beschränken",
"i18n:govoplan-admin.retention.c7199d9e": "Retention", "i18n:govoplan-admin.retention.c7199d9e": "Retention",
"i18n:govoplan-admin.retirement.6b680dd2": "Stilllegung",
"i18n:govoplan-admin.retry_of.3a6bb304": "Retry of:", "i18n:govoplan-admin.retry_of.3a6bb304": "Retry of:",
"i18n:govoplan-admin.retry.9f5cd8a2": "Erneut versuchen", "i18n:govoplan-admin.retry.9f5cd8a2": "Erneut versuchen",
"i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1": "Reusable encrypted SMTP/IMAP profiles and mail policy.", "i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1": "Reusable encrypted SMTP/IMAP profiles and mail policy.",
@@ -627,7 +785,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.save_plan.842dc280": "Save plan", "i18n:govoplan-admin.save_plan.842dc280": "Save plan",
"i18n:govoplan-admin.save_settings.913aba9f": "Einstellungen speichern", "i18n:govoplan-admin.save_settings.913aba9f": "Einstellungen speichern",
"i18n:govoplan-admin.save_template.0885fab2": "Save template", "i18n:govoplan-admin.save_template.0885fab2": "Save template",
"i18n:govoplan-admin.save_tenant_role.8fc0d37d": "Save tenant role", "i18n:govoplan-admin.save_tenant_role.8fc0d37d": "Rollenvorlage speichern",
"i18n:govoplan-admin.save_the_install_plan_before_queueing_a_daemon_r.3ba00691": "Speichern Sie den Installationsplan, bevor eine Daemon-Anfrage eingereiht wird.", "i18n:govoplan-admin.save_the_install_plan_before_queueing_a_daemon_r.3ba00691": "Speichern Sie den Installationsplan, bevor eine Daemon-Anfrage eingereiht wird.",
"i18n:govoplan-admin.saved.c0ae8f6e": "Gespeichert", "i18n:govoplan-admin.saved.c0ae8f6e": "Gespeichert",
"i18n:govoplan-admin.saving.56a2285c": "Saving…", "i18n:govoplan-admin.saving.56a2285c": "Saving…",
@@ -657,6 +815,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.system_settings_saved.dac4f17b": "Systemeinstellungen gespeichert.", "i18n:govoplan-admin.system_settings_saved.dac4f17b": "Systemeinstellungen gespeichert.",
"i18n:govoplan-admin.system_wide_governance_and_tenant_local_access_m.cda72499": "System-wide governance and tenant-local access management, separated by scope and enforced by the backend.", "i18n:govoplan-admin.system_wide_governance_and_tenant_local_access_m.cda72499": "System-wide governance and tenant-local access management, separated by scope and enforced by the backend.",
"i18n:govoplan-admin.system.bc0792d8": "System", "i18n:govoplan-admin.system.bc0792d8": "System",
"i18n:govoplan-admin.target_plan.524ea0c8": "Zielplan",
"i18n:govoplan-admin.task_version.f0f26b92": "Aufgabenversion",
"i18n:govoplan-admin.target.61ad50a9": "Target", "i18n:govoplan-admin.target.61ad50a9": "Target",
"i18n:govoplan-admin.template_details.d5d75e4d": "Template details", "i18n:govoplan-admin.template_details.d5d75e4d": "Template details",
"i18n:govoplan-admin.tenant_administration_capabilities.5d265972": "Tenant administration capabilities", "i18n:govoplan-admin.tenant_administration_capabilities.5d265972": "Tenant administration capabilities",
@@ -670,9 +830,9 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.tenant_locale_and_tenant_specific_settings.ac49c83b": "Tenant locale and tenant-specific settings.", "i18n:govoplan-admin.tenant_locale_and_tenant_specific_settings.ac49c83b": "Tenant locale and tenant-specific settings.",
"i18n:govoplan-admin.tenant_memberships_and_inherited_roles.16eda9f6": "Tenant memberships and inherited roles.", "i18n:govoplan-admin.tenant_memberships_and_inherited_roles.16eda9f6": "Tenant memberships and inherited roles.",
"i18n:govoplan-admin.tenant_permission_bundles_and_system_managed_rol.bb563bea": "Tenant permission bundles and system-managed role copies.", "i18n:govoplan-admin.tenant_permission_bundles_and_system_managed_rol.bb563bea": "Tenant permission bundles and system-managed role copies.",
"i18n:govoplan-admin.tenant_permissions.246294bc": "Tenant permissions", "i18n:govoplan-admin.tenant_permissions.246294bc": "Berechtigungen",
"i18n:govoplan-admin.tenant_role.6b53115d": "Tenant role", "i18n:govoplan-admin.tenant_role.6b53115d": "Rollenvorlage",
"i18n:govoplan-admin.tenant_roles.51aca82d": "Mandantenrollen", "i18n:govoplan-admin.tenant_roles.51aca82d": "Rollenvorlagen",
"i18n:govoplan-admin.tenant_users.cb800b38": "Mandantenbenutzer", "i18n:govoplan-admin.tenant_users.cb800b38": "Mandantenbenutzer",
"i18n:govoplan-admin.tenants.1f7ae776": "Mandanten", "i18n:govoplan-admin.tenants.1f7ae776": "Mandanten",
"i18n:govoplan-admin.these_settings_are_enforced_by_the_backend_centr.8112ed6f": "Diese Einstellungen werden vom Backend erzwungen. Zentrale Gruppen und Mandantenrollen bleiben verfügbar, auch wenn lokale Erstellung deaktiviert ist.", "i18n:govoplan-admin.these_settings_are_enforced_by_the_backend_centr.8112ed6f": "Diese Einstellungen werden vom Backend erzwungen. Zentrale Gruppen und Mandantenrollen bleiben verfügbar, auch wenn lokale Erstellung deaktiviert ist.",
@@ -683,6 +843,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.unlocked.b3da7025": "Entsperrt", "i18n:govoplan-admin.unlocked.b3da7025": "Entsperrt",
"i18n:govoplan-admin.unsigned.e91344ea": "Unsigniert", "i18n:govoplan-admin.unsigned.e91344ea": "Unsigniert",
"i18n:govoplan-admin.untrusted.cdc7838a": "Nicht vertrauenswürdig", "i18n:govoplan-admin.untrusted.cdc7838a": "Nicht vertrauenswürdig",
"i18n:govoplan-admin.upgrade.12c5007d": "Upgrade",
"i18n:govoplan-admin.update.503a059f": "Aktualisieren",
"i18n:govoplan-admin.update_enabled_modules.9b7ae790": "Update enabled modules", "i18n:govoplan-admin.update_enabled_modules.9b7ae790": "Update enabled modules",
"i18n:govoplan-admin.updated.f2f8570d": "Aktualisiert", "i18n:govoplan-admin.updated.f2f8570d": "Aktualisiert",
"i18n:govoplan-admin.user_file_connector_policy_limits": "Dateiverbindungsrichtlinien fuer benutzereigene Bereiche.", "i18n:govoplan-admin.user_file_connector_policy_limits": "Dateiverbindungsrichtlinien fuer benutzereigene Bereiche.",
+6
View File
@@ -3,6 +3,12 @@ export * from "./module";
export * from "./api/admin"; export * from "./api/admin";
export { default as AdminOverviewPanel } from "./features/admin/AdminOverviewPanel"; export { default as AdminOverviewPanel } from "./features/admin/AdminOverviewPanel";
export { default as ConfigurationPackagesPanel } from "./features/admin/ConfigurationPackagesPanel"; export { default as ConfigurationPackagesPanel } from "./features/admin/ConfigurationPackagesPanel";
export {
configurationReferenceSelectors,
createChangeRequestReferenceProvider,
createTenantReferenceProvider
} from "./features/admin/configurationReferenceProviders";
export { default as GovernanceTemplatesPanel } from "./features/admin/GovernanceTemplatesPanel"; export { default as GovernanceTemplatesPanel } from "./features/admin/GovernanceTemplatesPanel";
export { default as TenantModuleManagementPanel } from "./features/admin/TenantModuleManagementPanel";
export { default as SystemSettingsPanel } from "./features/admin/SystemSettingsPanel"; export { default as SystemSettingsPanel } from "./features/admin/SystemSettingsPanel";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui"; export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
+70 -4
View File
@@ -2,12 +2,14 @@ import { createElement, lazy } from "react";
import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui"; import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import { adminReadScopes, hasScope } from "@govoplan/core-webui"; import { adminReadScopes, hasScope } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
import { configurationReferenceSelectors } from "./features/admin/configurationReferenceProviders";
const AdminOverviewPanel = lazy(() => import("./features/admin/AdminOverviewPanel")); const AdminOverviewPanel = lazy(() => import("./features/admin/AdminOverviewPanel"));
const ConfigurationChangesPanel = lazy(() => import("./features/admin/ConfigurationChangesPanel")); const ConfigurationChangesPanel = lazy(() => import("./features/admin/ConfigurationChangesPanel"));
const ConfigurationPackagesPanel = lazy(() => import("./features/admin/ConfigurationPackagesPanel")); const ConfigurationPackagesPanel = lazy(() => import("./features/admin/ConfigurationPackagesPanel"));
const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceTemplatesPanel")); const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceTemplatesPanel"));
const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel")); const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel"));
const TenantModuleManagementPanel = lazy(() => import("./features/admin/TenantModuleManagementPanel"));
const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel")); const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel"));
const translations = { const translations = {
@@ -19,6 +21,9 @@ const adminSections: AdminSectionsUiCapability = {
sections: [ sections: [
{ {
id: "overview", id: "overview",
moduleId: "admin",
kind: "management",
surfaceId: "admin.section.overview",
label: "i18n:govoplan-admin.overview.0efc2e6b", label: "i18n:govoplan-admin.overview.0efc2e6b",
group: "ROOT", group: "ROOT",
order: 0, order: 0,
@@ -31,6 +36,9 @@ const adminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "system-settings", id: "system-settings",
moduleId: "admin",
kind: "settings",
surfaceId: "admin.section.system-settings",
label: "i18n:govoplan-admin.general.9239ee2c", label: "i18n:govoplan-admin.general.9239ee2c",
group: "SYSTEM", group: "SYSTEM",
order: 10, order: 10,
@@ -43,6 +51,9 @@ const adminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "system-configuration-changes", id: "system-configuration-changes",
moduleId: "admin",
kind: "management",
surfaceId: "admin.section.system-configuration-changes",
label: "i18n:govoplan-admin.changes.8aa57de6", label: "i18n:govoplan-admin.changes.8aa57de6",
group: "SYSTEM", group: "SYSTEM",
order: 20, order: 20,
@@ -54,17 +65,24 @@ const adminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "system-configuration-packages", id: "system-configuration-packages",
label: "i18n:govoplan-admin.packages.0a999012", moduleId: "admin",
kind: "management",
surfaceId: "admin.section.system-configuration-packages",
label: "i18n:govoplan-admin.configuration_packages.eb2f05f1",
group: "SYSTEM", group: "SYSTEM",
order: 30, order: 30,
allOf: ["system:settings:read"], allOf: ["system:settings:read"],
render: ({ settings, auth }) => createElement(ConfigurationPackagesPanel, { render: ({ settings, auth }) => createElement(ConfigurationPackagesPanel, {
settings, settings,
auth,
canWrite: hasScope(auth, "system:settings:write") canWrite: hasScope(auth, "system:settings:write")
}) })
}, },
{ {
id: "system-role-templates", id: "system-role-templates",
moduleId: "admin",
kind: "management",
surfaceId: "admin.section.system-role-templates",
label: "i18n:govoplan-admin.tenant_roles.51aca82d", label: "i18n:govoplan-admin.tenant_roles.51aca82d",
group: "SYSTEM", group: "SYSTEM",
order: 40, order: 40,
@@ -78,6 +96,9 @@ const adminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "system-modules", id: "system-modules",
moduleId: "admin",
kind: "management",
surfaceId: "admin.section.system-modules",
label: "i18n:govoplan-admin.modules.04e9462c", label: "i18n:govoplan-admin.modules.04e9462c",
group: "SYSTEM", group: "SYSTEM",
order: 85, order: 85,
@@ -88,8 +109,41 @@ const adminSections: AdminSectionsUiCapability = {
canAccessMaintenance: hasScope(auth, "system:maintenance:access") canAccessMaintenance: hasScope(auth, "system:maintenance:access")
}) })
}, },
{
id: "system-tenant-modules",
moduleId: "admin",
kind: "management",
surfaceId: "admin.section.system-tenant-modules",
label: "Tenant modules",
group: "SYSTEM",
order: 86,
allOf: ["system:settings:read"],
render: ({ settings, auth }) => createElement(TenantModuleManagementPanel, {
settings,
scope: "system",
canWrite: hasScope(auth, "system:settings:write")
})
},
{
id: "tenant-modules",
moduleId: "admin",
kind: "management",
surfaceId: "admin.section.tenant-modules",
label: "Modules",
group: "TENANT",
order: 60,
anyOf: ["admin:module:read", "admin:module:write", "system:settings:read"],
render: ({ settings, auth }) => createElement(TenantModuleManagementPanel, {
settings,
scope: "tenant",
canWrite: hasScope(auth, "admin:module:write") || hasScope(auth, "system:settings:write")
})
},
{ {
id: "system-groups", id: "system-groups",
moduleId: "admin",
kind: "management",
surfaceId: "admin.section.system-groups",
label: "i18n:govoplan-admin.groups.ae9629f4", label: "i18n:govoplan-admin.groups.ae9629f4",
group: "SYSTEM", group: "SYSTEM",
order: 50, order: 50,
@@ -107,12 +161,24 @@ const adminSections: AdminSectionsUiCapability = {
export const adminModule: PlatformWebModule = { export const adminModule: PlatformWebModule = {
id: "admin", id: "admin",
label: "i18n:govoplan-admin.admin.4e7afebc", label: "i18n:govoplan-admin.admin.4e7afebc",
version: "1.0.0", version: "0.1.8",
dependencies: ["access"], dependencies: ["access"],
translations, translations,
viewSurfaces: [
{ id: "admin.section.overview", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.overview.0efc2e6b", order: 0 },
{ id: "admin.section.system-settings", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.system_general_settings.7cd662ed", order: 10 },
{ id: "admin.section.system-configuration-changes", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.configuration_changes.82933bbb", order: 20 },
{ id: "admin.section.system-configuration-packages", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.configuration_packages.eb2f05f1", order: 30 },
{ id: "admin.section.system-role-templates", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.tenant_roles.51aca82d", order: 40 },
{ id: "admin.section.system-groups", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.central_groups.5c9b5b66", order: 50 },
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.modules.04e9462c", order: 85 },
{ id: "admin.section.system-tenant-modules", moduleId: "admin", kind: "section", label: "Tenant modules", order: 86 },
{ id: "admin.section.tenant-modules", moduleId: "admin", kind: "section", label: "Modules", order: 60 }
],
uiCapabilities: { uiCapabilities: {
"admin.sections": adminSections "admin.sections": adminSections,
"admin.configurationReferences": configurationReferenceSelectors
} }
}; };
export default adminModule; export default adminModule;
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
installerRequestMatchesPlan,
moduleInstallerQueueBlock,
moduleInstallerWorkflowStages,
type ModuleInstallerWorkflowInput
} from "../src/features/admin/moduleInstallerWorkflow.ts";
const ready: ModuleInstallerWorkflowInput = {
planItemCount: 1,
planDirty: false,
planValid: true,
preflightAllowed: true,
maintenanceEnabled: true,
canWrite: true,
canAccessMaintenance: true
};
test("keeps the operator on the earliest incomplete installer stage", () => {
assert.equal(moduleInstallerWorkflowStages({ ...ready, planItemCount: 0 })[0].current, true);
assert.equal(moduleInstallerWorkflowStages({ ...ready, planDirty: true })[0].current, true);
assert.equal(moduleInstallerWorkflowStages({ ...ready, preflightAllowed: false })[1].state, "blocked");
assert.equal(moduleInstallerWorkflowStages({ ...ready, maintenanceEnabled: false })[2].state, "blocked");
assert.equal(moduleInstallerWorkflowStages(ready)[2].state, "current");
});
test("moves queued work through execution to durable evidence", () => {
const queued = moduleInstallerWorkflowStages({ ...ready, requestStatus: "queued" });
assert.equal(queued[2].state, "complete");
assert.equal(queued[3].state, "current");
const completed = moduleInstallerWorkflowStages({
...ready,
requestStatus: "completed",
runStatus: "completed"
});
assert.deepEqual(completed.map((stage) => stage.state), [
"complete",
"complete",
"complete",
"complete",
"current"
]);
const failed = moduleInstallerWorkflowStages({
...ready,
requestStatus: "failed",
runStatus: "rollback_failed"
});
assert.equal(failed[4].state, "failed");
});
test("reports one actionable queue blocker in deterministic order", () => {
assert.equal(moduleInstallerQueueBlock({ ...ready, canWrite: false }), "write_access");
assert.equal(moduleInstallerQueueBlock({ ...ready, planItemCount: 0 }), "empty_plan");
assert.equal(moduleInstallerQueueBlock({ ...ready, planValid: false }), "invalid_plan");
assert.equal(moduleInstallerQueueBlock({ ...ready, planDirty: true }), "unsaved_plan");
assert.equal(moduleInstallerQueueBlock({ ...ready, preflightAllowed: false }), "preflight");
assert.equal(moduleInstallerQueueBlock({ ...ready, maintenanceEnabled: false }), "maintenance_mode");
assert.equal(moduleInstallerQueueBlock({ ...ready, canAccessMaintenance: false }), "maintenance_access");
assert.equal(
moduleInstallerQueueBlock({ ...ready, maintenanceEnabled: false, canAccessMaintenance: false }),
"maintenance_access"
);
assert.equal(moduleInstallerQueueBlock(ready), null);
});
test("does not present an older installer request as evidence for a newer plan", () => {
assert.equal(installerRequestMatchesPlan(null, "2026-08-03T10:00:00Z"), true);
assert.equal(
installerRequestMatchesPlan("2026-08-03T10:00:00Z", "2026-08-03T10:00:01Z"),
true
);
assert.equal(
installerRequestMatchesPlan("2026-08-03T10:00:00Z", "2026-08-03T09:59:59Z"),
false
);
assert.equal(installerRequestMatchesPlan("invalid", "2026-08-03T10:00:00Z"), false);
});