Compare commits

2 Commits

10 changed files with 536 additions and 50 deletions

View File

@@ -123,6 +123,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
@@ -322,7 +324,13 @@ def _record_system_settings_section_changes(
) )
def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem: def _governance_template_item(
session: Session,
item: GovernanceTemplate,
*,
assignments: list[GovernanceTemplateAssignment] | None = None,
) -> GovernanceTemplateItem:
if assignments is None:
assignments = session.query(GovernanceTemplateAssignment).filter( assignments = session.query(GovernanceTemplateAssignment).filter(
GovernanceTemplateAssignment.template_id == item.id GovernanceTemplateAssignment.template_id == item.id
).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all() ).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all()
@@ -341,6 +349,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,
@@ -374,6 +453,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
@@ -397,7 +481,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
@@ -409,15 +493,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.", [
"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.", "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.",
]
),
) )
@@ -616,7 +707,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(
@@ -630,7 +721,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,
) )
@@ -1014,11 +1105,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
live_apply_enabled = lifecycle.live_apply_enabled()
result = None
if live_apply_enabled:
try: try:
result = lifecycle.apply_enabled_modules(plan.enabled_modules, protected_modules=PROTECTED_MODULES) result = lifecycle.apply_enabled_modules(
plan.enabled_modules,
protected_modules=PROTECTED_MODULES,
)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc raise HTTPException(
desired = save_desired_enabled_modules(session, result.enabled_modules) 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,
@@ -1027,12 +1130,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(
@@ -1046,13 +1149,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)
@@ -1301,6 +1410,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")),
): ):
@@ -1308,18 +1419,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,
) )
@@ -1331,18 +1487,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,

View File

@@ -437,6 +437,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):
@@ -445,6 +449,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):

View File

@@ -3,7 +3,8 @@ 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 FrontendModule, MigrationSpec, ModuleContext, ModuleManifest
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -20,6 +21,19 @@ manifest = ModuleManifest(
version="0.1.8", version="0.1.8",
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,
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),
),
),
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(

View File

@@ -17,7 +17,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {

View File

@@ -1,5 +1,5 @@
import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy } from "@govoplan/core-webui"; import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy } from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPath, apiQuery } from "@govoplan/core-webui"; import { apiFetch, apiPath, apiQuery } from "@govoplan/core-webui";
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui"; export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
export type { export type {
AdminOverview, AdminOverview,
@@ -555,7 +555,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[]> {
return apiGetList<GovernanceTemplateItem, "templates">(settings, "/api/v1/admin/system/governance-templates", "templates", { kind }); const pageSize = 500;
const templates: GovernanceTemplateItem[] = [];
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> {

View File

@@ -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, DataGrid, StatusBadge, adminErrorMessage, i18nMessage, type DataGridColumn } from "@govoplan/core-webui"; import { AdminPageLayout, Button, Card, DataGrid, FormField, ReferenceSelect, StatusBadge, ToggleSwitch, adminErrorMessage, i18nMessage, type DataGridColumn } from "@govoplan/core-webui";
import { import {
applyConfigurationPackage, applyConfigurationPackage,
createConfigurationChangeRequest, createConfigurationChangeRequest,
@@ -13,6 +13,10 @@ import {
type ConfigurationPackagePlanItem, type ConfigurationPackagePlanItem,
type ConfigurationPackageRequiredData } from type ConfigurationPackageRequiredData } from
"../../api/admin"; "../../api/admin";
import {
createChangeRequestReferenceProvider,
createTenantReferenceProvider
} from "./configurationReferenceProviders";
const SAMPLE_ACCESS_PACKAGE = { const SAMPLE_ACCESS_PACKAGE = {
package_id: "govoplan.access.minimal-office", package_id: "govoplan.access.minimal-office",
@@ -60,12 +64,14 @@ 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("");
@@ -78,6 +84,22 @@ export default function ConfigurationPackagesPanel({ settings, canWrite }: {sett
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 +115,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();
@@ -228,8 +253,50 @@ 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="Enter reference IDs manually"
help="Use only when importing a package that intentionally references a target no longer returned by the live catalog."
/>
</div>
<div className="wide">
<FormField label="i18n:govoplan-admin.tenant_id.59eba244">
{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="Configuration package tenant"
placeholder="Select a tenant"
disabled={Boolean(busy)}
/>
)}
</FormField>
</div>
<div className="wide">
<FormField label="i18n:govoplan-admin.change_request_id.96ee3239">
{manualReferences ? (
<input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." />
) : (
<ReferenceSelect
value={changeRequestId}
onChange={(value) => setChangeRequestId(value)}
provider={changeRequestProvider}
aria-label="Configuration package change request"
placeholder="Select an eligible request (optional)"
emptyText="No eligible configuration requests."
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>

View File

@@ -355,8 +355,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>

View File

@@ -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");
}

View File

@@ -3,6 +3,11 @@ 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 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";

View File

@@ -2,6 +2,7 @@ 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"));
@@ -19,6 +20,7 @@ const adminSections: AdminSectionsUiCapability = {
sections: [ sections: [
{ {
id: "overview", id: "overview",
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 +33,7 @@ const adminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "system-settings", id: "system-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 +46,7 @@ const adminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "system-configuration-changes", id: "system-configuration-changes",
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 +58,20 @@ const adminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "system-configuration-packages", id: "system-configuration-packages",
surfaceId: "admin.section.system-configuration-packages",
label: "i18n:govoplan-admin.configuration_packages.eb2f05f1", 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",
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 +85,7 @@ const adminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "system-modules", id: "system-modules",
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,
@@ -90,6 +98,7 @@ const adminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "system-groups", id: "system-groups",
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,
@@ -110,8 +119,18 @@ export const adminModule: PlatformWebModule = {
version: "1.0.0", version: "1.0.0",
dependencies: ["access"], dependencies: ["access"],
translations, translations,
viewSurfaces: [
{ id: "admin.section.overview", moduleId: "admin", kind: "section", label: "Administration overview", order: 0 },
{ id: "admin.section.system-settings", moduleId: "admin", kind: "section", label: "System settings", order: 10 },
{ id: "admin.section.system-configuration-changes", moduleId: "admin", kind: "section", label: "Configuration changes", order: 20 },
{ id: "admin.section.system-configuration-packages", moduleId: "admin", kind: "section", label: "Configuration packages", order: 30 },
{ id: "admin.section.system-role-templates", moduleId: "admin", kind: "section", label: "Role templates", order: 40 },
{ id: "admin.section.system-groups", moduleId: "admin", kind: "section", label: "Group templates", order: 50 },
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "Modules", order: 85 }
],
uiCapabilities: { uiCapabilities: {
"admin.sections": adminSections "admin.sections": adminSections,
"admin.configurationReferences": configurationReferenceSelectors
} }
}; };