diff --git a/src/govoplan_admin/backend/api/v1/routes.py b/src/govoplan_admin/backend/api/v1/routes.py index a7711b1..bb1b842 100644 --- a/src/govoplan_admin/backend/api/v1/routes.py +++ b/src/govoplan_admin/backend/api/v1/routes.py @@ -123,6 +123,8 @@ SYSTEM_SETTINGS_COLLECTION = "admin.system_settings" SYSTEM_SETTINGS_RESOURCE = "system_settings_section" GOVERNANCE_TEMPLATES_COLLECTION = "admin.governance_templates" 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_REQUESTS_CURSOR_SCOPE = "admin.installer.requests.v1" DEFAULT_INSTALLER_HISTORY_PAGE_SIZE = 25 @@ -322,10 +324,16 @@ def _record_system_settings_section_changes( ) -def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem: - assignments = session.query(GovernanceTemplateAssignment).filter( - GovernanceTemplateAssignment.template_id == item.id - ).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all() +def _governance_template_item( + session: Session, + item: GovernanceTemplate, + *, + 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( id=item.id, kind=item.kind, @@ -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: record_change( session, @@ -374,6 +453,11 @@ def _module_catalog_response(session: Session, request: Request, *, notes: list[ current_set = set(current_enabled) desired_set = set(desired_enabled) protected_set = set(PROTECTED_MODULES) + live_apply_enabled = ( + lifecycle.live_apply_enabled() + if isinstance(lifecycle, ModuleLifecycleManager) + else False + ) items: list[ModuleCatalogItem] = [] for module_id, manifest in sorted(available.items(), key=lambda item: item[0]): 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, migration_module_id=migration.module_id 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, )) restart_required = current_set != desired_set @@ -409,15 +493,22 @@ def _module_catalog_response(session: Session, request: Request, *, notes: list[ configured_enabled=configured_enabled, protected_modules=list(PROTECTED_MODULES), restart_required=restart_required, - runtime_toggle_supported=True, + runtime_toggle_supported=live_apply_enabled, install_uninstall_supported=True, install_plan_supported=True, package_mutation_supported=False, maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()), - notes=notes or [ - "Enable/disable changes are applied to the running server and saved as startup state.", - "Installing or uninstalling Python/WebUI packages is planned here and applied by the separate installer daemon during maintenance mode.", - ], + notes=notes or ( + [ + "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.", + ] + ), ) @@ -616,7 +707,7 @@ def admin_overview( tenant = session.get(Tenant, principal.tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") - counts = tenant_counts(session, tenant.id) + counts = tenant_counts(session, tenant.id, module_ids=()) access_admin = _access_administration() capabilities = [item.scope for item in ALL_PERMISSIONS if has_scope(principal, item.scope)] return AdminOverviewResponse( @@ -630,7 +721,7 @@ def admin_overview( active_user_count=counts["active_users"], group_count=counts["groups"], role_count=access_admin.role_count_for_tenant(session, tenant.id), - active_api_key_count=access_admin.active_api_key_count_for_tenant(session, tenant.id), + active_api_key_count=counts["active_api_keys"], capabilities=capabilities, ) @@ -1014,11 +1105,23 @@ def update_system_modules( plan = plan_desired_enabled_modules(payload.enabled_modules, available) except ModuleManagementError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - try: - result = lifecycle.apply_enabled_modules(plan.enabled_modules, protected_modules=PROTECTED_MODULES) - except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - desired = save_desired_enabled_modules(session, result.enabled_modules) + live_apply_enabled = lifecycle.live_apply_enabled() + result = None + if live_apply_enabled: + try: + result = lifecycle.apply_enabled_modules( + plan.enabled_modules, + protected_modules=PROTECTED_MODULES, + ) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_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( session, principal, @@ -1027,12 +1130,12 @@ def update_system_modules( object_type="module_state", object_id="global", details=audit_operation_context( - outcome="applied", + outcome="applied" if live_apply_enabled else "restart_required", desired_enabled=list(desired), added_dependencies=list(plan.added_dependencies), - activated=list(result.activated_modules), - deactivated=list(result.deactivated_modules), - mounted=list(result.mounted_modules), + activated=activated, + deactivated=deactivated, + mounted=mounted, ), ) record_configuration_change_applied( @@ -1046,13 +1149,19 @@ def update_system_modules( audit_event="module_management.updated", ) 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: notes.append("Required dependencies added automatically: " + ", ".join(plan.added_dependencies)) - if result.activated_modules: - notes.append("Activated: " + ", ".join(result.activated_modules)) - if result.deactivated_modules: - notes.append("Deactivated: " + ", ".join(result.deactivated_modules)) + if activated: + notes.append("Activated: " + ", ".join(activated)) + if deactivated: + notes.append("Deactivated: " + ", ".join(deactivated)) return _module_catalog_response(session, request, notes=notes) @@ -1301,6 +1410,8 @@ def write_system_settings( @router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse) def list_governance_templates( 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), principal: ApiPrincipal = Depends(require_scope("system:governance:read")), ): @@ -1308,18 +1419,63 @@ def list_governance_templates( query = session.query(GovernanceTemplate) if kind: query = query.filter(GovernanceTemplate.kind == kind) - items = query.order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() - return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items]) + items, pagination = _governance_template_page( + 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: - items = session.query(GovernanceTemplate).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() +def _full_governance_template_delta_response( + 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( - templates=[_governance_template_item(session, item) for item in items], + templates=_governance_template_items(session, items), deleted=[], - watermark=_admin_delta_watermark(session, (GOVERNANCE_TEMPLATES_COLLECTION,)), - has_more=False, + watermark=watermark, + has_more=has_more, full=True, + **pagination, ) @@ -1331,18 +1487,26 @@ def list_governance_templates_delta( principal: ApiPrincipal = Depends(require_scope("system:governance:read")), ): del principal - if since is None: - return _full_governance_template_delta_response(session) + full_cursor = _decode_admin_full_cursor( + 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) 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"] items = [] if changed_ids: 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} 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), watermark=_admin_delta_response_watermark(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), entries=entries, has_more=has_more), has_more=has_more, diff --git a/src/govoplan_admin/backend/api/v1/schemas.py b/src/govoplan_admin/backend/api/v1/schemas.py index 6eeabf4..be50a5b 100644 --- a/src/govoplan_admin/backend/api/v1/schemas.py +++ b/src/govoplan_admin/backend/api/v1/schemas.py @@ -437,6 +437,10 @@ class GovernanceTemplateItem(BaseModel): class GovernanceTemplateListResponse(BaseModel): templates: list[GovernanceTemplateItem] + total: int = 0 + page: int = 1 + page_size: int = 500 + pages: int = 1 class GovernanceTemplateListDeltaResponse(BaseModel): @@ -445,6 +449,10 @@ class GovernanceTemplateListDeltaResponse(BaseModel): watermark: str | None = None has_more: bool = False full: bool = False + total: int = 0 + page: int = 1 + page_size: int = 500 + pages: int = 1 class GovernanceTemplateCreateRequest(BaseModel): diff --git a/webui/package.json b/webui/package.json index de85f4b..335770b 100644 --- a/webui/package.json +++ b/webui/package.json @@ -17,7 +17,7 @@ "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1" + "react-router-dom": ">=7.18.2 <8" }, "peerDependenciesMeta": { "@govoplan/core-webui": { diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index 88c074f..ab0c8c6 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -1,5 +1,5 @@ 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 type { AdminOverview, @@ -555,7 +555,25 @@ export function clearModuleInstallPlan(settings: ApiSettings): Promise { - return apiGetList(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 & { change_request_id?: string | null }): Promise { diff --git a/webui/src/features/admin/ConfigurationPackagesPanel.tsx b/webui/src/features/admin/ConfigurationPackagesPanel.tsx index 6f76cf7..689b3da 100644 --- a/webui/src/features/admin/ConfigurationPackagesPanel.tsx +++ b/webui/src/features/admin/ConfigurationPackagesPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { Check, Download, Play, RefreshCw } from "lucide-react"; -import type { ApiSettings } from "@govoplan/core-webui"; -import { AdminPageLayout, Button, Card, DataGrid, StatusBadge, adminErrorMessage, i18nMessage, type DataGridColumn } from "@govoplan/core-webui"; +import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; +import { AdminPageLayout, Button, Card, DataGrid, FormField, ReferenceSelect, StatusBadge, ToggleSwitch, adminErrorMessage, i18nMessage, type DataGridColumn } from "@govoplan/core-webui"; import { applyConfigurationPackage, createConfigurationChangeRequest, @@ -13,6 +13,10 @@ import { type ConfigurationPackagePlanItem, type ConfigurationPackageRequiredData } from "../../api/admin"; +import { + createChangeRequestReferenceProvider, + createTenantReferenceProvider +} from "./configurationReferenceProviders"; const SAMPLE_ACCESS_PACKAGE = { package_id: "govoplan.access.minimal-office", @@ -60,12 +64,14 @@ type ApplyResult = { updated_refs: Record; }; -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 | null>(null); 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 [changeRequestId, setChangeRequestId] = useState(""); + const [manualReferences, setManualReferences] = useState(false); const [dryRun, setDryRun] = useState(null); const [applyResult, setApplyResult] = useState(null); const [exportText, setExportText] = useState(""); @@ -78,6 +84,22 @@ export default function ConfigurationPackagesPanel({ settings, canWrite }: {sett const parsedPackage = useMemo(() => parseObject(packageText), [packageText]); const parsedSuppliedData = useMemo(() => parseObject(suppliedDataText), [suppliedDataText]); 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() { setLoading(true); @@ -93,6 +115,9 @@ export default function ConfigurationPackagesPanel({ settings, canWrite }: {sett } useEffect(() => {void loadCatalog();}, [settings.accessToken, settings.apiBaseUrl]); + useEffect(() => { + setTenantId((current) => current || activeTenantId); + }, [activeTenantId]); async function runDryRun() { const manifest = requireParsedPackage(); @@ -228,8 +253,50 @@ export default function ConfigurationPackagesPanel({ settings, canWrite }: {sett
- - +
+ +
+
+ + {manualReferences ? ( + setTenantId(event.target.value)} placeholder="active tenant" /> + ) : ( + { + setTenantId(value); + setChangeRequestId(""); + }} + provider={tenantProvider} + aria-label="Configuration package tenant" + placeholder="Select a tenant" + disabled={Boolean(busy)} + /> + )} + +
+
+ + {manualReferences ? ( + setChangeRequestId(event.target.value)} placeholder="cfgreq-..." /> + ) : ( + setChangeRequestId(value)} + provider={changeRequestProvider} + aria-label="Configuration package change request" + placeholder="Select an eligible request (optional)" + emptyText="No eligible configuration requests." + disabled={Boolean(busy)} + /> + )} + +