feat: add governed configuration reference selectors
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<ModuleIns
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -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<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 [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<DryRunResult | null>(null);
|
||||
const [applyResult, setApplyResult] = useState<ApplyResult | null>(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
|
||||
|
||||
<Card title="i18n:govoplan-admin.package.7431e3df">
|
||||
<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>
|
||||
<label className="wide"><span>i18n:govoplan-admin.change_request_id.96ee3239</span><input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." /></label>
|
||||
<div className="wide">
|
||||
<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.supplied_data_json.6932bfb7</span><textarea rows={6} value={suppliedDataText} onChange={(event) => setSuppliedDataText(event.target.value)} /></label>
|
||||
</div>
|
||||
|
||||
191
webui/src/features/admin/configurationReferenceProviders.ts
Normal file
191
webui/src/features/admin/configurationReferenceProviders.ts
Normal 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");
|
||||
}
|
||||
@@ -3,6 +3,11 @@ export * from "./module";
|
||||
export * from "./api/admin";
|
||||
export { default as AdminOverviewPanel } from "./features/admin/AdminOverviewPanel";
|
||||
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 SystemSettingsPanel } from "./features/admin/SystemSettingsPanel";
|
||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createElement, lazy } from "react";
|
||||
import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { adminReadScopes, hasScope } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import { configurationReferenceSelectors } from "./features/admin/configurationReferenceProviders";
|
||||
|
||||
const AdminOverviewPanel = lazy(() => import("./features/admin/AdminOverviewPanel"));
|
||||
const ConfigurationChangesPanel = lazy(() => import("./features/admin/ConfigurationChangesPanel"));
|
||||
@@ -64,6 +65,7 @@ const adminSections: AdminSectionsUiCapability = {
|
||||
allOf: ["system:settings:read"],
|
||||
render: ({ settings, auth }) => createElement(ConfigurationPackagesPanel, {
|
||||
settings,
|
||||
auth,
|
||||
canWrite: hasScope(auth, "system:settings:write")
|
||||
})
|
||||
},
|
||||
@@ -127,7 +129,8 @@ export const adminModule: PlatformWebModule = {
|
||||
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "Modules", order: 85 }
|
||||
],
|
||||
uiCapabilities: {
|
||||
"admin.sections": adminSections
|
||||
"admin.sections": adminSections,
|
||||
"admin.configurationReferences": configurationReferenceSelectors
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user