Compare commits
8 Commits
c9e1fb287f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f3e69b97ee | |||
| 36291a57c1 | |||
| e8a3e1c18f | |||
| 158b59dfb1 | |||
| 0fcd4dc06f | |||
| e8cb9d4fb2 | |||
| fcf93f438d | |||
| f0ff4ee51d |
@@ -18,7 +18,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -486,42 +577,73 @@ def _catalog_plan_item(
|
|||||||
validation: dict[str, object] | None = None,
|
validation: dict[str, object] | None = None,
|
||||||
) -> tuple[ModuleInstallPlanItem, dict[str, object]]:
|
) -> tuple[ModuleInstallPlanItem, dict[str, object]]:
|
||||||
result = validation or validate_module_package_catalog()
|
result = validation or validate_module_package_catalog()
|
||||||
|
_require_valid_module_catalog(result)
|
||||||
|
raw_item = _catalog_install_or_update_item(result, module_id)
|
||||||
|
action = _catalog_plan_action(raw_item, module_id, available_module_ids)
|
||||||
|
license_decision = module_license_decision(_catalog_license_features(raw_item))
|
||||||
|
_require_catalog_action_license(license_decision, action=action, module_id=module_id)
|
||||||
|
return ModuleInstallPlanItem(
|
||||||
|
module_id=str(raw_item["module_id"]),
|
||||||
|
action=action,
|
||||||
|
source="catalog",
|
||||||
|
catalog=_catalog_plan_metadata(result),
|
||||||
|
python_package=_catalog_string(raw_item, "python_package"),
|
||||||
|
python_ref=_catalog_string(raw_item, "python_ref"),
|
||||||
|
webui_package=_catalog_string(raw_item, "webui_package"),
|
||||||
|
webui_ref=_catalog_string(raw_item, "webui_ref"),
|
||||||
|
notes=_catalog_plan_notes(raw_item, license_decision),
|
||||||
|
), result
|
||||||
|
|
||||||
|
|
||||||
|
def _require_valid_module_catalog(result: dict[str, object]) -> None:
|
||||||
if not result.get("valid"):
|
if not result.get("valid"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail=str(result.get("error") or "Module package catalog is invalid."),
|
detail=str(result.get("error") or "Module package catalog is invalid."),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_install_or_update_item(result: dict[str, object], module_id: str) -> dict[str, object]:
|
||||||
for raw_item in result.get("modules", []):
|
for raw_item in result.get("modules", []):
|
||||||
if not isinstance(raw_item, dict):
|
if not isinstance(raw_item, dict):
|
||||||
continue
|
continue
|
||||||
if raw_item.get("module_id") != module_id or raw_item.get("action") not in {"install", "update"}:
|
if raw_item.get("module_id") != module_id or raw_item.get("action") not in {"install", "update"}:
|
||||||
continue
|
continue
|
||||||
raw_action = raw_item.get("action")
|
return raw_item
|
||||||
action = "update" if raw_action == "update" or module_id in available_module_ids else "install"
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install/update entry not found: {module_id}")
|
||||||
license_decision = module_license_decision(_catalog_license_features(raw_item))
|
|
||||||
if not license_decision.get("allowed"):
|
|
||||||
|
def _catalog_plan_action(raw_item: dict[str, object], module_id: str, available_module_ids: set[str]) -> str:
|
||||||
|
return "update" if raw_item.get("action") == "update" or module_id in available_module_ids else "install"
|
||||||
|
|
||||||
|
|
||||||
|
def _require_catalog_action_license(
|
||||||
|
license_decision: dict[str, object],
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
module_id: str,
|
||||||
|
) -> None:
|
||||||
|
if license_decision.get("allowed"):
|
||||||
|
return
|
||||||
missing = license_decision.get("missing_features")
|
missing = license_decision.get("missing_features")
|
||||||
missing_text = ", ".join(str(item) for item in missing) if isinstance(missing, list) else "required feature"
|
missing_text = ", ".join(str(item) for item in missing) if isinstance(missing, list) else "required feature"
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"License does not allow {action} for {module_id}: {missing_text}.",
|
detail=f"License does not allow {action} for {module_id}: {missing_text}.",
|
||||||
)
|
)
|
||||||
notes = raw_item.get("notes") if isinstance(raw_item.get("notes"), str) else None
|
|
||||||
if license_decision.get("reason") and license_decision.get("missing_features"):
|
|
||||||
|
def _catalog_plan_notes(raw_item: dict[str, object], license_decision: dict[str, object]) -> str | None:
|
||||||
|
notes = _catalog_string(raw_item, "notes")
|
||||||
|
if not (license_decision.get("reason") and license_decision.get("missing_features")):
|
||||||
|
return notes
|
||||||
prefix = f"{notes}\n" if notes else ""
|
prefix = f"{notes}\n" if notes else ""
|
||||||
notes = f"{prefix}License warning: {license_decision['reason']}"
|
return f"{prefix}License warning: {license_decision['reason']}"
|
||||||
return ModuleInstallPlanItem(
|
|
||||||
module_id=str(raw_item["module_id"]),
|
|
||||||
action=action,
|
def _catalog_string(raw_item: dict[str, object], field: str) -> str | None:
|
||||||
source="catalog",
|
value = raw_item.get(field)
|
||||||
catalog=_catalog_plan_metadata(result),
|
return value if isinstance(value, str) else None
|
||||||
python_package=raw_item.get("python_package") if isinstance(raw_item.get("python_package"), str) else None,
|
|
||||||
python_ref=raw_item.get("python_ref") if isinstance(raw_item.get("python_ref"), str) else None,
|
|
||||||
webui_package=raw_item.get("webui_package") if isinstance(raw_item.get("webui_package"), str) else None,
|
|
||||||
webui_ref=raw_item.get("webui_ref") if isinstance(raw_item.get("webui_ref"), str) else None,
|
|
||||||
notes=notes,
|
|
||||||
), result
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install/update entry not found: {module_id}")
|
|
||||||
|
|
||||||
|
|
||||||
def _catalog_plan_metadata(validation: dict[str, object]) -> dict[str, object]:
|
def _catalog_plan_metadata(validation: dict[str, object]) -> dict[str, object]:
|
||||||
@@ -585,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(
|
||||||
@@ -599,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -983,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,
|
||||||
@@ -996,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(
|
||||||
@@ -1015,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -1270,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")),
|
||||||
):
|
):
|
||||||
@@ -1277,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1300,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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""GovOPlaN Admin backend tests."""
|
||||||
108
tests/test_catalog_plan.py
Normal file
108
tests/test_catalog_plan.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_admin.backend.api.v1.routes import _catalog_plan_item
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogPlanItemTests(unittest.TestCase):
|
||||||
|
def test_builds_catalog_item_and_preserves_catalog_metadata(self) -> None:
|
||||||
|
validation: dict[str, object] = {
|
||||||
|
"valid": True,
|
||||||
|
"source": "https://catalog.example.test/modules.json",
|
||||||
|
"source_type": "remote",
|
||||||
|
"channel": "stable",
|
||||||
|
"signed": True,
|
||||||
|
"trusted": True,
|
||||||
|
"modules": [
|
||||||
|
"ignored",
|
||||||
|
{"module_id": "other", "action": "install"},
|
||||||
|
{
|
||||||
|
"module_id": "calendar",
|
||||||
|
"action": "install",
|
||||||
|
"python_package": "govoplan-calendar",
|
||||||
|
"python_ref": 42,
|
||||||
|
"webui_package": "@govoplan/calendar-webui",
|
||||||
|
"notes": "Catalog note",
|
||||||
|
"license_features": ["calendar.sync", "", 7],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
decision = {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "Feature expires soon.",
|
||||||
|
"missing_features": ["calendar.future"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_admin.backend.api.v1.routes.module_license_decision",
|
||||||
|
return_value=decision,
|
||||||
|
) as license_decision:
|
||||||
|
item, returned_validation = _catalog_plan_item(
|
||||||
|
"calendar",
|
||||||
|
{"calendar"},
|
||||||
|
validation=validation,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(returned_validation, validation)
|
||||||
|
self.assertEqual(item.module_id, "calendar")
|
||||||
|
self.assertEqual(item.action, "update")
|
||||||
|
self.assertEqual(item.source, "catalog")
|
||||||
|
self.assertEqual(item.python_package, "govoplan-calendar")
|
||||||
|
self.assertIsNone(item.python_ref)
|
||||||
|
self.assertEqual(item.webui_package, "@govoplan/calendar-webui")
|
||||||
|
self.assertIsNone(item.webui_ref)
|
||||||
|
self.assertEqual(item.notes, "Catalog note\nLicense warning: Feature expires soon.")
|
||||||
|
self.assertEqual(item.catalog["source"], "https://catalog.example.test/modules.json")
|
||||||
|
self.assertEqual(item.catalog["channel"], "stable")
|
||||||
|
self.assertTrue(item.catalog["signed"])
|
||||||
|
license_decision.assert_called_once_with(["calendar.sync", "7"])
|
||||||
|
|
||||||
|
def test_rejects_catalog_action_when_license_is_missing(self) -> None:
|
||||||
|
validation: dict[str, object] = {
|
||||||
|
"valid": True,
|
||||||
|
"modules": [{"module_id": "calendar", "action": "install", "license_features": ["calendar.sync"]}],
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"govoplan_admin.backend.api.v1.routes.module_license_decision",
|
||||||
|
return_value={"allowed": False, "missing_features": ["calendar.sync", "calendar.write"]},
|
||||||
|
), self.assertRaises(HTTPException) as raised:
|
||||||
|
_catalog_plan_item("calendar", set(), validation=validation)
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 403)
|
||||||
|
self.assertEqual(
|
||||||
|
raised.exception.detail,
|
||||||
|
"License does not allow install for calendar: calendar.sync, calendar.write.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_invalid_or_missing_catalog_entries(self) -> None:
|
||||||
|
with self.subTest("invalid catalog"), self.assertRaises(HTTPException) as invalid:
|
||||||
|
_catalog_plan_item(
|
||||||
|
"calendar",
|
||||||
|
set(),
|
||||||
|
validation={"valid": False, "error": "Signature is invalid."},
|
||||||
|
)
|
||||||
|
self.assertEqual(invalid.exception.status_code, 422)
|
||||||
|
self.assertEqual(invalid.exception.detail, "Signature is invalid.")
|
||||||
|
|
||||||
|
with self.subTest("entry not found"), self.assertRaises(HTTPException) as missing:
|
||||||
|
_catalog_plan_item(
|
||||||
|
"calendar",
|
||||||
|
set(),
|
||||||
|
validation={
|
||||||
|
"valid": True,
|
||||||
|
"modules": [
|
||||||
|
{"module_id": "calendar", "action": "remove"},
|
||||||
|
{"module_id": "other", "action": "install"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(missing.exception.status_code, 404)
|
||||||
|
self.assertEqual(missing.exception.detail, "Catalog install/update entry not found: calendar")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -13,11 +13,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
import { fetchAdminOverview, type AdminOverview } from "../../api/admin";
|
import { fetchAdminOverview, type AdminOverview } from "../../api/admin";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card, MetricCard } from "@govoplan/core-webui";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ function hasAnySection(sections: ReadonlySet<string>, candidates: readonly strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ title, value, text }: {title: string;value: string | number;text: string;}) {
|
function Metric({ title, value, text }: {title: string;value: string | number;text: string;}) {
|
||||||
return <Card title={title}><strong className="module-big-number">{value}</strong><p className="muted">{text}</p></Card>;
|
return <MetricCard label={title} value={value} detail={text} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AreaLink({ title, text, onClick }: {title: string;text: string;onClick: () => void;}) {
|
function AreaLink({ title, text, onClick }: {title: string;text: string;onClick: () => void;}) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Check, RefreshCw } from "lucide-react";
|
import { Check, RefreshCw } from "lucide-react";
|
||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
import { AdminPageLayout, Button, Card, StatusBadge, adminErrorMessage, formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks } from "@govoplan/core-webui";
|
import { AdminPageLayout, Button, Card, DataGrid, StatusBadge, TableActionGroup, adminErrorMessage, formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
approveConfigurationChangeRequest,
|
approveConfigurationChangeRequest,
|
||||||
fetchConfigurationChangesDelta,
|
fetchConfigurationChangesDelta,
|
||||||
@@ -72,6 +72,47 @@ export default function ConfigurationChangesPanel({ settings, canApprove }: {set
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pending = useMemo(() => requests.filter((item) => item.status !== "applied" && item.status !== "rejected"), [requests]);
|
const pending = useMemo(() => requests.filter((item) => item.status !== "applied" && item.status !== "rejected"), [requests]);
|
||||||
|
const requestColumns: DataGridColumn<ConfigurationChangeRequest>[] = [
|
||||||
|
{
|
||||||
|
id: "setting",
|
||||||
|
header: "i18n:govoplan-admin.setting.fb449f71",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
minWidth: 180,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (request) => `${request.label || request.key} ${request.key}`,
|
||||||
|
render: (request) => <div><strong>{request.label || request.key}</strong><span className="muted block">{request.key}</span></div>
|
||||||
|
},
|
||||||
|
{ id: "status", header: "i18n:govoplan-admin.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (request) => request.status, render: (request) => <StatusBadge status={statusTone(request.status)} label={request.status} /> },
|
||||||
|
{ id: "requested", header: "i18n:govoplan-admin.requested.c26bf60f", width: 180, sortable: true, value: (request) => request.requested_at, render: (request) => formatDateTime(request.requested_at) },
|
||||||
|
{ id: "approvals", header: "i18n:govoplan-admin.approvals.deb9d03c", width: 110, sortable: true, value: (request) => request.approvals.length },
|
||||||
|
{ id: "target", header: "i18n:govoplan-admin.target.61ad50a9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, filterable: true, value: (request) => targetLabel(request.target), render: (request) => targetLabel(request.target) },
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "i18n:govoplan-admin.action.97c89a4d",
|
||||||
|
width: 72,
|
||||||
|
sticky: "end",
|
||||||
|
align: "right",
|
||||||
|
render: (request) => <TableActionGroup actions={[{
|
||||||
|
id: "approve",
|
||||||
|
label: "i18n:govoplan-admin.approve.7b2c7f14",
|
||||||
|
icon: <Check size={16} aria-hidden="true" />,
|
||||||
|
applicable: canApprove && request.status === "pending_approval",
|
||||||
|
disabled: busyId === request.id,
|
||||||
|
onClick: () => void approve(request)
|
||||||
|
}]} />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const historyColumns: DataGridColumn<ConfigurationChangeRecord>[] = [
|
||||||
|
{ id: "version", header: "i18n:govoplan-admin.version.2da600bf", width: 100, sortable: true, value: (record) => record.version, render: (record) => `#${record.version}` },
|
||||||
|
{ id: "setting", header: "i18n:govoplan-admin.setting.fb449f71", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (record) => `${record.key} ${record.id}`, render: (record) => <div><strong>{record.key}</strong><span className="muted block">{record.id}</span></div> },
|
||||||
|
{ id: "status", header: "i18n:govoplan-admin.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (record) => record.status, render: (record) => <StatusBadge status={statusTone(record.status)} label={record.status} /> },
|
||||||
|
{ id: "applied", header: "i18n:govoplan-admin.applied.a3e4a569", width: 180, sortable: true, value: (record) => record.created_at, render: (record) => formatDateTime(record.created_at) },
|
||||||
|
{ id: "approvers", header: "i18n:govoplan-admin.approvers.0e2de1fb", width: 120, sortable: true, value: (record) => record.approval_user_ids.length, render: (record) => record.approval_user_ids.length || "-" },
|
||||||
|
{ id: "target", header: "i18n:govoplan-admin.target.61ad50a9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, filterable: true, value: (record) => targetLabel(record.target), render: (record) => targetLabel(record.target) }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminPageLayout
|
<AdminPageLayout
|
||||||
@@ -83,67 +124,23 @@ export default function ConfigurationChangesPanel({ settings, canApprove }: {set
|
|||||||
actions={<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} /> i18n:govoplan-admin.reload.cce71553</Button>}>
|
actions={<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} /> i18n:govoplan-admin.reload.cce71553</Button>}>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-admin.requests.f7194e6a">
|
<Card title="i18n:govoplan-admin.requests.f7194e6a">
|
||||||
<div className="admin-table-wrap">
|
<DataGrid
|
||||||
<table className="admin-table">
|
id="admin-configuration-change-requests"
|
||||||
<thead>
|
rows={pending}
|
||||||
<tr>
|
columns={requestColumns}
|
||||||
<th>i18n:govoplan-admin.setting.fb449f71</th>
|
getRowKey={(request) => request.id}
|
||||||
<th>i18n:govoplan-admin.status.bae7d5be</th>
|
emptyText="i18n:govoplan-admin.no_open_requests.580c95f9"
|
||||||
<th>i18n:govoplan-admin.requested.c26bf60f</th>
|
/>
|
||||||
<th>i18n:govoplan-admin.approvals.deb9d03c</th>
|
|
||||||
<th>i18n:govoplan-admin.target.61ad50a9</th>
|
|
||||||
<th className="actions">i18n:govoplan-admin.action.97c89a4d</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{pending.map((request) =>
|
|
||||||
<tr key={request.id}>
|
|
||||||
<td><strong>{request.label || request.key}</strong><span className="muted block">{request.key}</span></td>
|
|
||||||
<td><StatusBadge status={statusTone(request.status)} label={request.status} /></td>
|
|
||||||
<td>{formatDateTime(request.requested_at)}</td>
|
|
||||||
<td>{request.approvals.length}</td>
|
|
||||||
<td>{targetLabel(request.target)}</td>
|
|
||||||
<td className="actions">
|
|
||||||
{canApprove && request.status === "pending_approval" &&
|
|
||||||
<Button onClick={() => void approve(request)} disabled={busyId === request.id}><Check size={16} /> i18n:govoplan-admin.approve.7b2c7f14</Button>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!pending.length && <tr><td colSpan={6} className="muted">i18n:govoplan-admin.no_open_requests.580c95f9</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-admin.history.90ccd649">
|
<Card title="i18n:govoplan-admin.history.90ccd649">
|
||||||
<div className="admin-table-wrap">
|
<DataGrid
|
||||||
<table className="admin-table">
|
id="admin-configuration-change-history"
|
||||||
<thead>
|
rows={history}
|
||||||
<tr>
|
columns={historyColumns}
|
||||||
<th>i18n:govoplan-admin.version.2da600bf</th>
|
getRowKey={(record) => record.id}
|
||||||
<th>i18n:govoplan-admin.setting.fb449f71</th>
|
emptyText="i18n:govoplan-admin.no_applied_configuration_changes.6a3ae4a7"
|
||||||
<th>i18n:govoplan-admin.status.bae7d5be</th>
|
/>
|
||||||
<th>i18n:govoplan-admin.applied.a3e4a569</th>
|
|
||||||
<th>i18n:govoplan-admin.approvers.0e2de1fb</th>
|
|
||||||
<th>i18n:govoplan-admin.target.61ad50a9</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{history.map((record) =>
|
|
||||||
<tr key={record.id}>
|
|
||||||
<td>#{record.version}</td>
|
|
||||||
<td><strong>{record.key}</strong><span className="muted block">{record.id}</span></td>
|
|
||||||
<td><StatusBadge status={statusTone(record.status)} label={record.status} /></td>
|
|
||||||
<td>{formatDateTime(record.created_at)}</td>
|
|
||||||
<td>{record.approval_user_ids.length || "-"}</td>
|
|
||||||
<td>{targetLabel(record.target)}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!history.length && <tr><td colSpan={6} className="muted">i18n:govoplan-admin.no_applied_configuration_changes.6a3ae4a7</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</AdminPageLayout>);
|
</AdminPageLayout>);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Check, Download, Play, RefreshCw } from "lucide-react";
|
import { Check, Download, Play, RefreshCw } from "lucide-react";
|
||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
import { AdminPageLayout, Button, Card, StatusBadge, adminErrorMessage, i18nMessage } from "@govoplan/core-webui";
|
import { AdminPageLayout, Button, Card, 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>
|
||||||
@@ -273,89 +340,68 @@ function parseObject(text: string): {value: Record<string, unknown> | null;error
|
|||||||
|
|
||||||
function PackageDiagnostics({ diagnostics }: {diagnostics: ConfigurationPackageDiagnostic[];}) {
|
function PackageDiagnostics({ diagnostics }: {diagnostics: ConfigurationPackageDiagnostic[];}) {
|
||||||
if (diagnostics.length === 0) return <p className="muted">i18n:govoplan-admin.no_diagnostics.2b6e2630</p>;
|
if (diagnostics.length === 0) return <p className="muted">i18n:govoplan-admin.no_diagnostics.2b6e2630</p>;
|
||||||
|
const columns: DataGridColumn<ConfigurationPackageDiagnostic>[] = [
|
||||||
|
{ id: "severity", header: "i18n:govoplan-admin.severity.de314fa0", width: 130, sortable: true, filterable: true, value: (item) => item.severity, render: (item) => <StatusBadge status={diagnosticTone(item.severity)} label={item.severity} /> },
|
||||||
|
{ id: "code", header: "i18n:govoplan-admin.code.adac6937", width: 190, sortable: true, filterable: true, value: (item) => item.code, render: (item) => <code>{item.code}</code> },
|
||||||
|
{ id: "owner", header: "i18n:govoplan-admin.owner.89ff3122", width: 150, sortable: true, filterable: true, value: (item) => item.module_id || "-", render: (item) => item.module_id || "-" },
|
||||||
|
{ id: "object", header: "i18n:govoplan-admin.object.2883f191", width: 180, sortable: true, filterable: true, value: (item) => item.object_ref || "-", render: (item) => item.object_ref || "-" },
|
||||||
|
{ id: "message", header: "i18n:govoplan-admin.message.68f4145f", width: "minmax(260px, 1fr)", minWidth: 220, resizable: true, filterable: true, value: (item) => `${item.message} ${item.resolution || ""}`, render: (item) => <div>{item.message}{item.resolution ? <span className="muted block">{item.resolution}</span> : null}</div> }
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<div className="admin-table-wrap">
|
<DataGrid
|
||||||
<table className="admin-table">
|
id="admin-configuration-package-diagnostics"
|
||||||
<thead><tr><th>i18n:govoplan-admin.severity.de314fa0</th><th>i18n:govoplan-admin.code.adac6937</th><th>i18n:govoplan-admin.owner.89ff3122</th><th>i18n:govoplan-admin.object.2883f191</th><th>i18n:govoplan-admin.message.68f4145f</th></tr></thead>
|
rows={diagnostics}
|
||||||
<tbody>
|
columns={columns}
|
||||||
{diagnostics.map((item, index) =>
|
getRowKey={(item, index) => `${item.code}-${index}`}
|
||||||
<tr key={`${item.code}-${index}`}>
|
/>);
|
||||||
<td><StatusBadge status={diagnosticTone(item.severity)} label={item.severity} /></td>
|
|
||||||
<td><code>{item.code}</code></td>
|
|
||||||
<td>{item.module_id || "-"}</td>
|
|
||||||
<td>{item.object_ref || "-"}</td>
|
|
||||||
<td>{item.message}{item.resolution ? <span className="muted block">{item.resolution}</span> : null}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RequiredData({ items }: {items: ConfigurationPackageRequiredData[];}) {
|
function RequiredData({ items }: {items: ConfigurationPackageRequiredData[];}) {
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
const columns: DataGridColumn<ConfigurationPackageRequiredData>[] = [
|
||||||
|
{ id: "key", header: "i18n:govoplan-admin.key.c67dd20e", width: "minmax(200px, 1fr)", minWidth: 170, resizable: true, sortable: true, filterable: true, value: (item) => item.key, render: (item) => <code>{item.key}</code> },
|
||||||
|
{ id: "label", header: "i18n:govoplan-admin.label.74341e3c", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (item) => item.label },
|
||||||
|
{ id: "type", header: "i18n:govoplan-admin.type.3deb7456", width: 140, sortable: true, filterable: true, value: (item) => item.data_type },
|
||||||
|
{ id: "required", header: "i18n:govoplan-admin.required.eed6bfb4", width: 110, sortable: true, value: (item) => item.required, render: (item) => item.required ? "yes" : "no" },
|
||||||
|
{ id: "secret", header: "i18n:govoplan-admin.secret.f4e7a874", width: 100, sortable: true, value: (item) => item.secret, render: (item) => item.secret ? "yes" : "no" }
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3>i18n:govoplan-admin.required_data.1b1c1b34</h3>
|
<h3>i18n:govoplan-admin.required_data.1b1c1b34</h3>
|
||||||
<div className="admin-table-wrap">
|
<DataGrid id="admin-configuration-package-required-data" rows={items} columns={columns} getRowKey={(item) => item.key} />
|
||||||
<table className="admin-table">
|
|
||||||
<thead><tr><th>i18n:govoplan-admin.key.c67dd20e</th><th>i18n:govoplan-admin.label.74341e3c</th><th>i18n:govoplan-admin.type.3deb7456</th><th>i18n:govoplan-admin.required.eed6bfb4</th><th>i18n:govoplan-admin.secret.f4e7a874</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((item) =>
|
|
||||||
<tr key={item.key}>
|
|
||||||
<td><code>{item.key}</code></td>
|
|
||||||
<td>{item.label}</td>
|
|
||||||
<td>{item.data_type}</td>
|
|
||||||
<td>{item.required ? "yes" : "no"}</td>
|
|
||||||
<td>{item.secret ? "yes" : "no"}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>);
|
</>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PackagePlan({ items }: {items: ConfigurationPackagePlanItem[];}) {
|
function PackagePlan({ items }: {items: ConfigurationPackagePlanItem[];}) {
|
||||||
if (items.length === 0) return <p className="muted">i18n:govoplan-admin.no_plan_items.7108c582</p>;
|
if (items.length === 0) return <p className="muted">i18n:govoplan-admin.no_plan_items.7108c582</p>;
|
||||||
|
const columns: DataGridColumn<ConfigurationPackagePlanItem>[] = [
|
||||||
|
{ id: "action", header: "i18n:govoplan-admin.action.97c89a4d", width: 130, sortable: true, filterable: true, value: (item) => item.action, render: (item) => <StatusBadge status={planTone(item.action)} label={item.action} /> },
|
||||||
|
{ id: "module", header: "i18n:govoplan-admin.module.b8ff0289", width: 170, sortable: true, filterable: true, value: (item) => item.module_id },
|
||||||
|
{ id: "fragment", header: "i18n:govoplan-admin.fragment.3f19d616", width: 170, sortable: true, filterable: true, value: (item) => item.fragment_type },
|
||||||
|
{ id: "id", header: "i18n:govoplan-admin.id.474ae526", width: 180, sortable: true, filterable: true, value: (item) => item.fragment_id || "-", render: (item) => item.fragment_id || "-" },
|
||||||
|
{ id: "summary", header: "i18n:govoplan-admin.summary.12b71c3e", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (item) => item.summary || "-", render: (item) => item.summary || "-" }
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3>i18n:govoplan-admin.plan.ae2f98a0</h3>
|
<h3>i18n:govoplan-admin.plan.ae2f98a0</h3>
|
||||||
<div className="admin-table-wrap">
|
<DataGrid id="admin-configuration-package-plan" rows={items} columns={columns} getRowKey={(item, index) => `${item.module_id}-${item.fragment_type}-${item.fragment_id ?? index}`} />
|
||||||
<table className="admin-table">
|
|
||||||
<thead><tr><th>i18n:govoplan-admin.action.97c89a4d</th><th>i18n:govoplan-admin.module.b8ff0289</th><th>i18n:govoplan-admin.fragment.3f19d616</th><th>i18n:govoplan-admin.id.474ae526</th><th>i18n:govoplan-admin.summary.12b71c3e</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((item, index) =>
|
|
||||||
<tr key={`${item.module_id}-${item.fragment_type}-${item.fragment_id ?? index}`}>
|
|
||||||
<td><StatusBadge status={planTone(item.action)} label={item.action} /></td>
|
|
||||||
<td>{item.module_id}</td>
|
|
||||||
<td>{item.fragment_type}</td>
|
|
||||||
<td>{item.fragment_id || "-"}</td>
|
|
||||||
<td>{item.summary || "-"}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>);
|
</>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReferenceMap({ title, refs }: {title: string;refs: Record<string, string>;}) {
|
function ReferenceMap({ title, refs }: {title: string;refs: Record<string, string>;}) {
|
||||||
const entries = Object.entries(refs);
|
const entries = Object.entries(refs).map(([key, value]) => ({ key, value }));
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
|
const columns: DataGridColumn<{key: string;value: string;}>[] = [
|
||||||
|
{ id: "key", header: "i18n:govoplan-admin.key.c67dd20e", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (item) => item.key, render: (item) => <code>{item.key}</code> },
|
||||||
|
{ id: "value", header: "Value", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (item) => item.value }
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<div className="admin-table-wrap">
|
<DataGrid id={`admin-configuration-package-refs-${title}`} rows={entries} columns={columns} getRowKey={(item) => item.key} />
|
||||||
<table className="admin-table">
|
|
||||||
<tbody>
|
|
||||||
{entries.map(([key, value]) => <tr key={key}><td><code>{key}</code></td><td>{value}</td></tr>)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>);
|
</>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
type PermissionItem,
|
type PermissionItem,
|
||||||
type TenantAdminItem } from
|
type TenantAdminItem } from
|
||||||
"../../api/admin";
|
"../../api/admin";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
|
|
||||||
const emptyDraft = {
|
const emptyDraft = {
|
||||||
slug: "",
|
slug: "",
|
||||||
@@ -187,11 +187,11 @@ export default function GovernanceTemplatesPanel({
|
|||||||
{ id: "status", header: "i18n:govoplan-admin.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
{ id: "status", header: "i18n:govoplan-admin.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||||
{
|
{
|
||||||
id: "actions", header: "i18n:govoplan-admin.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right",
|
id: "actions", header: "i18n:govoplan-admin.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right",
|
||||||
render: (row) => <div className="admin-icon-actions">
|
render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-admin.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-admin.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-admin.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-admin.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canWrite, onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-admin.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
|
{ id: "delete", label: i18nMessage("i18n:govoplan-admin.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, onClick: () => setDeleting(row) }
|
||||||
</div>
|
]} />
|
||||||
}],
|
}],
|
||||||
[canWrite, kind, tenants]);
|
[canWrite, kind, tenants]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
import { AdminPageLayout, adminErrorMessage, Button, dispatchPlatformModulesChanged, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminPageLayout, adminErrorMessage, Button, dispatchPlatformModulesChanged, formatDateTime, MetricCard, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type FormatDateTimeOptions } from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
cancelModuleInstallerRequest,
|
cancelModuleInstallerRequest,
|
||||||
clearModuleInstallPlan,
|
clearModuleInstallPlan,
|
||||||
@@ -323,11 +323,11 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
|||||||
|
|
||||||
{catalog && <>
|
{catalog && <>
|
||||||
<div className="metric-grid module-management-metrics">
|
<div className="metric-grid module-management-metrics">
|
||||||
<ModuleMetric label="i18n:govoplan-admin.installed.7bb4405c" value={catalog.modules.length} detail="i18n:govoplan-admin.discovered_packages.660902de" />
|
<MetricCard label="i18n:govoplan-admin.installed.7bb4405c" value={catalog.modules.length} detail="i18n:govoplan-admin.discovered_packages.660902de" tone="info" />
|
||||||
<ModuleMetric label="i18n:govoplan-admin.active.a733b809" value={activeCount} detail="i18n:govoplan-admin.running_registry.5274b24b" />
|
<MetricCard label="i18n:govoplan-admin.active.a733b809" value={activeCount} detail="i18n:govoplan-admin.running_registry.5274b24b" tone="info" />
|
||||||
<ModuleMetric label="i18n:govoplan-admin.saved.c0ae8f6e" value={desiredCount} detail="i18n:govoplan-admin.startup_state.d1300be6" />
|
<MetricCard label="i18n:govoplan-admin.saved.c0ae8f6e" value={desiredCount} detail="i18n:govoplan-admin.startup_state.d1300be6" tone="info" />
|
||||||
<ModuleMetric label="i18n:govoplan-admin.drift.4876f7b9" value={pendingCount} detail={pendingCount ? "i18n:govoplan-admin.runtime_differs.43d1fd78" : "i18n:govoplan-admin.runtime_matches.a84afa48"} tone={pendingCount ? "warning" : "good"} />
|
<MetricCard label="i18n:govoplan-admin.drift.4876f7b9" value={pendingCount} detail={pendingCount ? "i18n:govoplan-admin.runtime_differs.43d1fd78" : "i18n:govoplan-admin.runtime_matches.a84afa48"} tone={pendingCount ? "warning" : "good"} />
|
||||||
<ModuleMetric label="i18n:govoplan-admin.maintenance.94de303b" value={maintenanceEnabled ? "i18n:govoplan-admin.on.e0049a66" : "i18n:govoplan-admin.off.e3de5ab0"} detail={canAccessMaintenance ? "i18n:govoplan-admin.bypass_allowed.4e347c27" : "i18n:govoplan-admin.bypass_denied.ab987400"} tone={maintenanceEnabled ? "warning" : "info"} />
|
<MetricCard label="i18n:govoplan-admin.maintenance.94de303b" value={maintenanceEnabled ? "i18n:govoplan-admin.on.e0049a66" : "i18n:govoplan-admin.off.e3de5ab0"} detail={canAccessMaintenance ? "i18n:govoplan-admin.bypass_allowed.4e347c27" : "i18n:govoplan-admin.bypass_denied.ab987400"} tone={maintenanceEnabled ? "warning" : "info"} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dirty && <div className="module-management-notes">
|
{dirty && <div className="module-management-notes">
|
||||||
@@ -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>
|
||||||
@@ -596,10 +596,10 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
|||||||
<StatusBadge status={statusTone(request.status)} label={request.status} />
|
<StatusBadge status={statusTone(request.status)} label={request.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="module-management-details">
|
<div className="module-management-details">
|
||||||
<span>i18n:govoplan-admin.created.0c78dab1 {formatDateTime(request.created_at)}</span>
|
<span>i18n:govoplan-admin.created.0c78dab1 {formatDateTime(request.created_at, ADMIN_DATE_TIME_OPTIONS)}</span>
|
||||||
<span>i18n:govoplan-admin.started.fe3824e9 {formatDateTime(request.started_at)}</span>
|
<span>i18n:govoplan-admin.started.fe3824e9 {formatDateTime(request.started_at, ADMIN_DATE_TIME_OPTIONS)}</span>
|
||||||
<span>i18n:govoplan-admin.finished.4b52fe3f {formatDateTime(request.finished_at)}</span>
|
<span>i18n:govoplan-admin.finished.4b52fe3f {formatDateTime(request.finished_at, ADMIN_DATE_TIME_OPTIONS)}</span>
|
||||||
{request.cancelled_at && <span>i18n:govoplan-admin.cancelled.8c9dcfa8 {formatDateTime(request.cancelled_at)}</span>}
|
{request.cancelled_at && <span>i18n:govoplan-admin.cancelled.8c9dcfa8 {formatDateTime(request.cancelled_at, ADMIN_DATE_TIME_OPTIONS)}</span>}
|
||||||
{request.retry_of && <span>i18n:govoplan-admin.retry_of.3a6bb304 {request.retry_of}</span>}
|
{request.retry_of && <span>i18n:govoplan-admin.retry_of.3a6bb304 {request.retry_of}</span>}
|
||||||
{traceId(request.trace) && <span>i18n:govoplan-admin.trace.04a75036 <code>{traceId(request.trace)}</code></span>}
|
{traceId(request.trace) && <span>i18n:govoplan-admin.trace.04a75036 <code>{traceId(request.trace)}</code></span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -639,8 +639,8 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
|||||||
{run.request_id && <span>i18n:govoplan-admin.request.43c2e7a7 {run.request_id}</span>}
|
{run.request_id && <span>i18n:govoplan-admin.request.43c2e7a7 {run.request_id}</span>}
|
||||||
{traceId(run.trace) && <span>i18n:govoplan-admin.trace.04a75036 <code>{traceId(run.trace)}</code></span>}
|
{traceId(run.trace) && <span>i18n:govoplan-admin.trace.04a75036 <code>{traceId(run.trace)}</code></span>}
|
||||||
<span>i18n:govoplan-admin.commands.f6a34aed {run.commands_count}</span>
|
<span>i18n:govoplan-admin.commands.f6a34aed {run.commands_count}</span>
|
||||||
<span>i18n:govoplan-admin.started.fe3824e9 {formatDateTime(run.started_at)}</span>
|
<span>i18n:govoplan-admin.started.fe3824e9 {formatDateTime(run.started_at, ADMIN_DATE_TIME_OPTIONS)}</span>
|
||||||
<span>i18n:govoplan-admin.finished.4b52fe3f {formatDateTime(run.finished_at)}</span>
|
<span>i18n:govoplan-admin.finished.4b52fe3f {formatDateTime(run.finished_at, ADMIN_DATE_TIME_OPTIONS)}</span>
|
||||||
</div>
|
</div>
|
||||||
{run.error && <p className="module-installer-run-error">{run.error}</p>}
|
{run.error && <p className="module-installer-run-error">{run.error}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -654,10 +654,6 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModuleMetric({ label, value, detail, tone = "info" }: {label: string;value: string | number;detail: string;tone?: "info" | "good" | "warning";}) {
|
|
||||||
return <div className={`metric-card metric-${tone}`}><div className="metric-label">{label}</div><div className="metric-value">{value}</div><div className="metric-detail">{detail}</div></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LicenseStatus({ license }: {license: ModuleLicenseDiagnostics;}) {
|
function LicenseStatus({ license }: {license: ModuleLicenseDiagnostics;}) {
|
||||||
const label = license.license_id ? `${license.license_id}${license.subject ? ` · ${license.subject}` : ""}` : license.configured ? "i18n:govoplan-admin.configured_license.3e73f8f5" : "i18n:govoplan-admin.no_license_configured.693cea1a";
|
const label = license.license_id ? `${license.license_id}${license.subject ? ` · ${license.subject}` : ""}` : license.configured ? "i18n:govoplan-admin.configured_license.3e73f8f5" : "i18n:govoplan-admin.no_license_configured.693cea1a";
|
||||||
return (
|
return (
|
||||||
@@ -669,7 +665,7 @@ function LicenseStatus({ license }: {license: ModuleLicenseDiagnostics;}) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="module-package-catalog-description">{label}</p>
|
<p className="module-package-catalog-description">{label}</p>
|
||||||
<div className="module-management-details">
|
<div className="module-management-details">
|
||||||
<span>i18n:govoplan-admin.valid.b374b8f9 {formatDateTime(license.valid_from)} to {formatDateTime(license.valid_until)}</span>
|
<span>i18n:govoplan-admin.valid.b374b8f9 {formatDateTime(license.valid_from, ADMIN_DATE_TIME_OPTIONS)} to {formatDateTime(license.valid_until, ADMIN_DATE_TIME_OPTIONS)}</span>
|
||||||
<span>i18n:govoplan-admin.features.5df81ffa {license.features.length ? license.features.join(", ") : "none"}</span>
|
<span>i18n:govoplan-admin.features.5df81ffa {license.features.length ? license.features.join(", ") : "none"}</span>
|
||||||
{license.required_features.length > 0 && <span>i18n:govoplan-admin.required.d5793988 {license.required_features.join(", ")}</span>}
|
{license.required_features.length > 0 && <span>i18n:govoplan-admin.required.d5793988 {license.required_features.join(", ")}</span>}
|
||||||
{license.missing_features.length > 0 && <span>i18n:govoplan-admin.missing.feb2bbaa {license.missing_features.join(", ")}</span>}
|
{license.missing_features.length > 0 && <span>i18n:govoplan-admin.missing.feb2bbaa {license.missing_features.join(", ")}</span>}
|
||||||
@@ -851,12 +847,16 @@ function licenseLabel(license: ModuleLicenseDiagnostics): string {
|
|||||||
return "i18n:govoplan-admin.unsigned.e91344ea";
|
return "i18n:govoplan-admin.unsigned.e91344ea";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value?: string | null): string {
|
const ADMIN_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
|
||||||
if (!value) return "i18n:govoplan-admin.not_recorded.9925ee3c";
|
fallback: "i18n:govoplan-admin.not_recorded.9925ee3c",
|
||||||
const date = new Date(value);
|
year: "numeric",
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
month: "numeric",
|
||||||
return date.toLocaleString();
|
day: "numeric",
|
||||||
}
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
second: "numeric",
|
||||||
|
timeZoneName: undefined
|
||||||
|
};
|
||||||
|
|
||||||
function traceId(value?: Record<string, unknown> | null): string {
|
function traceId(value?: Record<string, unknown> | null): string {
|
||||||
const correlationId = value?.correlation_id;
|
const correlationId = value?.correlation_id;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Card } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { fetchSystemSettingsDelta, updateSystemSettings, type LanguagePackage, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsDeltaSections, type SystemSettingsItem } from "../../api/admin";
|
import { fetchSystemSettingsDelta, updateSystemSettings, type LanguagePackage, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsDeltaSections, type SystemSettingsItem } from "../../api/admin";
|
||||||
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminPageLayout, AdminSelectionList, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
|
|
||||||
const DELTA_KEY = "admin:system-settings";
|
const DELTA_KEY = "admin:system-settings";
|
||||||
|
|
||||||
@@ -112,10 +112,8 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint
|
|||||||
{setBusy(false);}
|
{setBusy(false);}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLanguage(code: string, checked: boolean) {
|
function setEnabledLanguages(selected: string[]) {
|
||||||
const enabled = new Set(draft.enabled_language_codes);
|
const enabled = new Set(selected);
|
||||||
if (checked) enabled.add(code);
|
|
||||||
else enabled.delete(code);
|
|
||||||
const nextEnabled = draft.available_languages.map((item) => item.code).filter((item) => enabled.has(item));
|
const nextEnabled = draft.available_languages.map((item) => item.code).filter((item) => enabled.has(item));
|
||||||
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
|
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
|
||||||
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
|
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
|
||||||
@@ -159,18 +157,11 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint
|
|||||||
</FormField>
|
</FormField>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="i18n:govoplan-admin.language_packages">
|
<Card title="i18n:govoplan-admin.language_packages">
|
||||||
<div className="settings-list">
|
<AdminSelectionList
|
||||||
{draft.available_languages.map((item) => (
|
options={draft.available_languages.map((item) => ({ id: item.code, label: item.code.toUpperCase(), description: languageOptionLabel(item), disabled: !canWrite || busy || item.code === draft.default_locale }))}
|
||||||
<label className="admin-inline-check" key={item.code}>
|
selected={draft.enabled_language_codes}
|
||||||
<input
|
onChange={setEnabledLanguages}
|
||||||
type="checkbox"
|
/>
|
||||||
checked={draft.enabled_language_codes.includes(item.code)}
|
|
||||||
disabled={!canWrite || busy || item.code === draft.default_locale}
|
|
||||||
onChange={(event) => toggleLanguage(item.code, event.target.checked)} />
|
|
||||||
<span><strong>{item.code.toUpperCase()}</strong> {languageOptionLabel(item)}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<FormField label="i18n:govoplan-admin.language_code">
|
<FormField label="i18n:govoplan-admin.language_code">
|
||||||
<input value={packageDraft.code} disabled={!canWrite || busy} placeholder="fr" onChange={(event) => setPackageDraft({ ...packageDraft, code: event.target.value })} />
|
<input value={packageDraft.code} disabled={!canWrite || busy} placeholder="fr" onChange={(event) => setPackageDraft({ ...packageDraft, code: event.target.value })} />
|
||||||
|
|||||||
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 * 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";
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user