Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
commit 2defb89bc1
55 changed files with 3361 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
import { fetchAdminOverview, type AdminOverview } from "../../api/admin";
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
export default function AdminOverviewPanel({ settings, onSelect, availableSections }: { settings: ApiSettings; onSelect: (section: string) => void; availableSections: ReadonlySet<string> }) {
const [overview, setOverview] = useState<AdminOverview | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setError("");
try { setOverview(await fetchAdminOverview(settings)); }
catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
const hasSystemMetrics = Boolean(overview && [
overview.tenant_count,
overview.system_account_count,
overview.system_group_template_count,
overview.system_role_template_count
].some((value) => value !== null && value !== undefined));
return (
<AdminPageLayout title="Administration" description="System-wide governance and tenant-local access management, separated by scope and enforced by the backend." loading={loading} error={error} actions={<Button onClick={() => void load()} disabled={loading}>Reload</Button>}>
{overview && <>
{hasSystemMetrics && <>
<div className="admin-overview-section-label">System</div>
<div className="metric-grid">
<Metric title="Tenants" value={overview.tenant_count ?? "—"} text="Registered tenant spaces." />
<Metric title="Users" value={overview.system_account_count ?? "—"} text="Global login accounts across all tenants." />
<Metric title="Central groups" value={overview.system_group_template_count ?? "—"} text="System-governed group definitions." />
<Metric title="Tenant roles" value={overview.system_role_template_count ?? "—"} text="Centrally governed tenant roles." />
</div>
</>}
{hasSystemArea(availableSections) && <Card title="System administration">
<div className="admin-overview-grid">
{availableSections.has("system-settings") && <AreaLink title="General" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
{availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />}
{availableSections.has("system-roles") && <AreaLink title="System roles" text="Instance-wide roles assigned directly to global accounts." onClick={() => onSelect("system-roles")} />}
{availableSections.has("system-role-templates") && <AreaLink title="Tenant roles" text="Centrally governed tenant roles and their availability across tenants." onClick={() => onSelect("system-role-templates")} />}
{availableSections.has("system-groups") && <AreaLink title="Groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
{availableSections.has("system-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("system-mail-servers")} />}
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
{availableSections.has("system-modules") && <AreaLink title="Modules" text="Installed modules, runtime state and startup state." onClick={() => onSelect("system-modules")} />}
{availableSections.has("system-audit") && <AreaLink title="Audit" text="System-level administrative history." onClick={() => onSelect("system-audit")} />}
</div>
</Card>}
<div className="admin-overview-section-label">Active tenant: {overview.active_tenant_name}</div>
<div className="metric-grid">
<Metric title="Tenant users" value={`${overview.active_user_count}/${overview.user_count}`} text="Active and total memberships." />
<Metric title="Groups" value={overview.group_count} text="Tenant-local and centrally managed groups." />
<Metric title="Roles" value={overview.role_count} text="Tenant-local and centrally managed roles." />
<Metric title="API keys" value={overview.active_api_key_count} text="Active tenant automation credentials." />
</div>
<Card title="Tenant administration">
<div className="admin-overview-grid">
{availableSections.has("tenant-settings") && <AreaLink title="General" text="Tenant locale and tenant-specific settings." onClick={() => onSelect("tenant-settings")} />}
{availableSections.has("tenant-roles") && <AreaLink title="Roles" text="Tenant permission bundles and system-managed role copies." onClick={() => onSelect("tenant-roles")} />}
{availableSections.has("tenant-groups") && <AreaLink title="Groups" text="Tenant memberships and inherited roles." onClick={() => onSelect("tenant-groups")} />}
{availableSections.has("tenant-users") && <AreaLink title="Users" text="Membership status, groups and direct roles in the active tenant." onClick={() => onSelect("tenant-users")} />}
{availableSections.has("tenant-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("tenant-mail-servers")} />}
{availableSections.has("tenant-retention") && <AreaLink title="Retention" text="Tenant-level privacy retention limits inherited by owned objects." onClick={() => onSelect("tenant-retention")} />}
{availableSections.has("tenant-api-keys") && <AreaLink title="API keys" text="Scoped automation credentials capped by owner permissions." onClick={() => onSelect("tenant-api-keys")} />}
{availableSections.has("tenant-audit") && <AreaLink title="Audit" text="Tenant-level administrative history only." onClick={() => onSelect("tenant-audit")} />}
</div>
</Card>
</>}
</AdminPageLayout>
);
}
function hasSystemArea(sections: ReadonlySet<string>): boolean {
return ["system-settings", "system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-mail-servers", "system-retention", "system-modules", "system-audit"].some((section) => sections.has(section));
}
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>;
}
function AreaLink({ title, text, onClick }: { title: string; text: string; onClick: () => void }) {
return <button className="admin-overview-link" onClick={onClick}><strong>{title}</strong><span>{text}</span></button>;
}

View File

@@ -0,0 +1,229 @@
import { useEffect, useMemo, useState } from "react";
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import {
createGovernanceTemplate,
deleteGovernanceTemplate,
fetchGovernanceTemplates,
fetchPermissionCatalog,
fetchTenants,
updateGovernanceTemplate,
type GovernanceAssignment,
type GovernanceTemplateItem,
type PermissionItem,
type TenantAdminItem
} from "../../api/admin";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels } from "@govoplan/core-webui";
const emptyDraft = {
slug: "",
name: "",
description: "",
isActive: true,
permissions: [] as string[],
assignments: [] as GovernanceAssignment[]
};
export default function GovernanceTemplatesPanel({
settings,
kind,
canWrite,
onAuthRefresh
}: {
settings: ApiSettings;
kind: "group" | "role";
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) {
const [items, setItems] = useState<GovernanceTemplateItem[]>([]);
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const [editing, setEditing] = useState<GovernanceTemplateItem | "new" | null>(null);
const [viewing, setViewing] = useState<GovernanceTemplateItem | null>(null);
const [deleting, setDeleting] = useState<GovernanceTemplateItem | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function load() {
setLoading(true);
setError("");
try {
const [nextItems, nextTenants, nextPermissions] = await Promise.all([
fetchGovernanceTemplates(settings, kind),
fetchTenants(settings),
kind === "role" ? fetchPermissionCatalog(settings) : Promise.resolve([])
]);
setItems(nextItems);
setTenants(nextTenants);
setPermissions(nextPermissions.filter((item) => item.level === "tenant"));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, kind]);
function openCreate() {
setDraft(emptyDraft);
setEditing("new");
}
function openEdit(item: GovernanceTemplateItem) {
setDraft({
slug: item.slug,
name: item.name,
description: item.description || "",
isActive: item.is_active,
permissions: item.permissions,
assignments: item.assignments
});
setEditing(item);
}
function assignmentMode(tenantId: string): "none" | "available" | "required" {
return draft.assignments.find((item) => item.tenant_id === tenantId)?.mode ?? "none";
}
function setAssignment(tenantId: string, mode: "none" | "available" | "required") {
setDraft((current) => ({
...current,
assignments: mode === "none"
? current.assignments.filter((item) => item.tenant_id !== tenantId)
: [...current.assignments.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, mode }]
}));
}
async function save() {
setBusy(true);
setError("");
try {
if (editing === "new") {
await createGovernanceTemplate(settings, {
kind,
slug: draft.slug,
name: draft.name,
description: draft.description || null,
permissions: kind === "role" ? draft.permissions : [],
is_active: draft.isActive,
assignments: draft.assignments
});
setSuccess(`${kind === "group" ? "Group" : "Role"} template created.`);
} else if (editing) {
await updateGovernanceTemplate(settings, editing.id, {
name: draft.name,
description: draft.description || null,
permissions: kind === "role" ? draft.permissions : [],
is_active: draft.isActive,
assignments: draft.assignments
});
setSuccess(`${draft.name} updated and synchronized to assigned tenants.`);
}
setEditing(null);
await load();
await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
}
async function remove() {
if (!deleting) return;
setBusy(true);
setError("");
try {
await deleteGovernanceTemplate(settings, deleting.id);
setSuccess(`${deleting.name} deleted.`);
setDeleting(null);
await load();
await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
}
const columns = useMemo<DataGridColumn<GovernanceTemplateItem>[]>(() => [
{
id: "template", header: kind === "group" ? "Group template" : "Tenant role", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true,
value: (row) => `${row.name} ${row.slug}`,
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
},
{
id: "tenants", header: "Tenant availability", width: 320, minWidth: 210, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true,
value: (row) => row.assignments.map((assignment) => tenants.find((tenant) => tenant.id === assignment.tenant_id)?.name || assignment.tenant_id).join(", ") || "—",
render: (row) => row.assignments.length ? row.assignments.map((assignment) => {
const tenant = tenants.find((item) => item.id === assignment.tenant_id);
return `${tenant?.name || assignment.tenant_id} (${assignment.mode})`;
}).join(", ") : "—"
},
...(kind === "role" ? [{
id: "permissions", header: "Permissions", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer" as const,
value: (row: GovernanceTemplateItem) => row.effective_permission_count
}] : []),
{ id: "status", header: "Status", 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: "Actions", width: 150, sticky: "end", resizable: false, align: "right",
render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
</div>
}
], [canWrite, kind, tenants]);
const title = kind === "group" ? "Central groups" : "Tenant roles";
const description = kind === "group"
? "Centrally defined group identities that are provisioned into selected tenants as available or required definitions. Membership remains tenant-local."
: "Centrally governed tenant roles that are provisioned into selected tenants as available or required definitions.";
return (
<>
<AdminPageLayout
title={title}
description={description}
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label={kind === "group" ? "Add group template" : "Add tenant role"} icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}
>
<div className="admin-table-surface">
<DataGrid id={`admin-system-${kind}-templates-v3`} rows={items} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText={`No central ${kind} templates found.`} />
</div>
</AdminPageLayout>
<Dialog
open={editing !== null}
title={editing === "new" ? (kind === "group" ? "Create group template" : "Create tenant role") : (kind === "group" ? "Edit group template" : "Edit tenant role")}
onClose={() => !busy && setEditing(null)}
className="admin-dialog admin-dialog-wide"
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : kind === "group" ? "Save template" : "Save tenant role"}</Button></>}
>
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div>
{kind === "role" && <div className="form-field"><span className="form-label">Tenant permissions</span><AdminSelectionList options={permissions.map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))} selected={draft.permissions} onChange={(next) => setDraft({ ...draft, permissions: next })} /></div>}
<div className="form-field">
<span className="form-label">Tenant availability</span>
<div className="admin-selection-list admin-governance-mode">
{tenants.map((tenant) => <div className="admin-tenant-assignment-row" key={tenant.id}><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span><select value={assignmentMode(tenant.id)} onChange={(event) => setAssignment(tenant.id, event.target.value as "none" | "available" | "required")}><option value="none">Not available</option><option value="available">Available</option><option value="required">Required</option></select></div>)}
</div>
<p className="muted small-note">Required means the definition must remain present and system-controlled. It does not automatically assign users or grant permissions.</p>
</div>
</Dialog>
<Dialog open={Boolean(viewing)} title={viewing?.name || "Template details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>Kind</dt><dd>{viewing.kind}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Tenants</dt><dd>{viewing.assignments.length || "None"}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Permissions</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
</Dialog>
<ConfirmDialog open={Boolean(deleting)} title={kind === "group" ? "Delete group template" : "Delete tenant role"} message={`Delete ${deleting?.name}? Removal is blocked while a materialized tenant definition still has members or assignments.`} confirmLabel={kind === "group" ? "Delete template" : "Delete tenant role"} tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
</>
);
}

View File

@@ -0,0 +1,638 @@
import { useEffect, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage, Button, dispatchPlatformModulesChanged, StatusBadge, ToggleSwitch } from "@govoplan/core-webui";
import {
cancelModuleInstallerRequest,
clearModuleInstallPlan,
createModuleInstallerRequest,
fetchModuleCatalog,
fetchModuleInstallPlan,
fetchModuleInstallerRequests,
fetchModuleInstallerRuns,
fetchModulePackageCatalog,
planModuleInstallFromCatalog,
planModuleUninstall,
retryModuleInstallerRequest,
updateModuleInstallPlan,
updateModuleState,
type ModuleCatalogItem,
type ModuleCatalogResponse,
type ModuleInstallerRequestListResponse,
type ModuleInstallerRequestOptions,
type ModuleInstallerRunListResponse,
type ModuleInstallPlanItem,
type ModuleInstallPlanResponse,
type ModulePackageCatalogResponse,
type ModulePackageCatalogItem
} from "../../api/admin";
export default function ModuleManagementPanel({ settings, canWrite, canAccessMaintenance }: { settings: ApiSettings; canWrite: boolean; canAccessMaintenance: boolean }) {
const [catalog, setCatalog] = useState<ModuleCatalogResponse | null>(null);
const [installPlan, setInstallPlan] = useState<ModuleInstallPlanResponse | null>(null);
const [installerRuns, setInstallerRuns] = useState<ModuleInstallerRunListResponse | null>(null);
const [installerRequests, setInstallerRequests] = useState<ModuleInstallerRequestListResponse | null>(null);
const [packageCatalog, setPackageCatalog] = useState<ModulePackageCatalogResponse | null>(null);
const [draftEnabled, setDraftEnabled] = useState<Set<string> | null>(null);
const [draftPlanItems, setDraftPlanItems] = useState<ModuleInstallPlanItem[]>([]);
const [requestOptions, setRequestOptions] = useState<InstallerRequestFormOptions>(() => defaultInstallerRequestOptions());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [planBusy, setPlanBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function load() {
setLoading(true);
setError("");
try {
const [loaded, loadedPlan, loadedRuns, loadedRequests, loadedPackageCatalog] = await Promise.all([
fetchModuleCatalog(settings),
fetchModuleInstallPlan(settings),
fetchModuleInstallerRuns(settings),
fetchModuleInstallerRequests(settings),
fetchModulePackageCatalog(settings)
]);
setCatalog(loaded);
setInstallPlan(loadedPlan);
setInstallerRuns(loadedRuns);
setInstallerRequests(loadedRequests);
setPackageCatalog(loadedPackageCatalog);
setDraftEnabled(new Set(loaded.desired_enabled));
setDraftPlanItems(loadedPlan.items);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
const desired = draftEnabled ?? new Set(catalog?.desired_enabled ?? []);
const dirty = Boolean(catalog && !sameSet(desired, new Set(catalog.desired_enabled)));
const pendingCount = catalog?.modules.filter((module) => module.current_enabled !== desired.has(module.id)).length ?? 0;
const activeCount = catalog?.modules.filter((module) => module.current_enabled).length ?? 0;
const desiredCount = desired.size;
const maintenanceEnabled = Boolean(catalog?.maintenance_mode.enabled || installPlan?.maintenance_mode.enabled);
const planDirty = Boolean(installPlan && JSON.stringify(normalizePlanItems(draftPlanItems)) !== JSON.stringify(normalizePlanItems(installPlan.items)));
const planValid = planValidationError(draftPlanItems) === "";
function setModuleDesired(module: ModuleCatalogItem, enabled: boolean) {
if (module.protected) return;
setSuccess("");
setDraftEnabled((current) => {
const next = new Set(current ?? catalog?.desired_enabled ?? []);
if (enabled) next.add(module.id);
else next.delete(module.id);
return next;
});
}
async function save() {
if (!draftEnabled) return;
setBusy(true);
setError("");
setSuccess("");
try {
const updated = await updateModuleState(settings, Array.from(draftEnabled).sort());
setCatalog(updated);
setDraftEnabled(new Set(updated.desired_enabled));
setSuccess(updated.notes[0] ?? "Module state saved.");
dispatchPlatformModulesChanged();
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
async function savePlan() {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const updated = await updateModuleInstallPlan(settings, normalizePlanItems(draftPlanItems));
setInstallPlan(updated);
setDraftPlanItems(updated.items);
setSuccess(updated.notes[0] ?? "Module install plan saved.");
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
async function clearPlan() {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const updated = await clearModuleInstallPlan(settings);
setInstallPlan(updated);
setDraftPlanItems([]);
setSuccess(updated.notes[0] ?? "Module install plan cleared.");
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
function updatePlanItem(index: number, patch: Partial<ModuleInstallPlanItem>) {
setSuccess("");
setDraftPlanItems((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
}
function addPlanItem() {
setDraftPlanItems((items) => [...items, emptyPlanItem()]);
}
async function addCatalogItem(item: ModulePackageCatalogItem) {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const updated = await planModuleInstallFromCatalog(settings, item.module_id);
setInstallPlan(updated);
setDraftPlanItems(updated.items);
setSuccess(updated.notes[0] ?? "Catalog install entry planned.");
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
async function addUninstallPlan(module: ModuleCatalogItem) {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const updated = await planModuleUninstall(settings, module.id);
setInstallPlan(updated);
setDraftPlanItems(updated.items);
setSuccess(updated.notes[0] ?? "Package uninstall planned.");
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
async function queueInstallerRequest() {
if (!installPlan) return;
setPlanBusy(true);
setError("");
setSuccess("");
try {
const request = await createModuleInstallerRequest(settings, normalizeRequestOptions(requestOptions, installPlan));
setSuccess(`Installer request queued: ${request.request_id}`);
const [loadedRuns, loadedRequests] = await Promise.all([
fetchModuleInstallerRuns(settings),
fetchModuleInstallerRequests(settings)
]);
setInstallerRuns(loadedRuns);
setInstallerRequests(loadedRequests);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
async function cancelInstallerRequest(requestId: string) {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const request = await cancelModuleInstallerRequest(settings, requestId);
setSuccess(`Installer request cancelled: ${request.request_id}`);
setInstallerRequests(await fetchModuleInstallerRequests(settings));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
async function retryInstallerRequest(requestId: string) {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const request = await retryModuleInstallerRequest(settings, requestId);
setSuccess(`Installer request queued: ${request.request_id}`);
setInstallerRequests(await fetchModuleInstallerRequests(settings));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
return (
<AdminPageLayout
title="System modules"
description="Installed modules, active runtime state, and saved startup state."
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading || busy}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}>{busy ? "Applying..." : "Save and apply"}</Button></>}
>
{catalog && <>
<div className="metric-grid module-management-metrics">
<ModuleMetric label="Installed" value={catalog.modules.length} detail="Discovered packages" />
<ModuleMetric label="Active" value={activeCount} detail="Running registry" />
<ModuleMetric label="Saved" value={desiredCount} detail="Startup state" />
<ModuleMetric label="Drift" value={pendingCount} detail={pendingCount ? "Runtime differs" : "Runtime matches"} tone={pendingCount ? "warning" : "good"} />
<ModuleMetric label="Maintenance" value={maintenanceEnabled ? "On" : "Off"} detail={canAccessMaintenance ? "Bypass allowed" : "Bypass denied"} tone={maintenanceEnabled ? "warning" : "info"} />
</div>
<div className="module-management-list">
{catalog.modules.map((module) => {
const desiredEnabled = desired.has(module.id);
return (
<div key={module.id} className={`module-management-row${module.restart_required || module.current_enabled !== desiredEnabled ? " pending" : ""}`}>
<div className="module-management-main">
<div className="module-management-title">
<strong>{module.name}</strong>
<code>{module.id}</code>
<span>v{module.version}</span>
</div>
<div className="module-management-meta">
<ModuleStatus module={module} desiredEnabled={desiredEnabled} />
{module.protected && <StatusBadge status="locked" label="Locked" />}
{module.frontend_package && <span>{module.frontend_package}</span>}
{module.migration_module_id && <span>DB: {module.migration_module_id}</span>}
</div>
<div className="module-management-details">
<span>Requires: {module.dependencies.length ? module.dependencies.join(", ") : "none"}</span>
<span>Optional: {module.optional_dependencies.length ? module.optional_dependencies.join(", ") : "none"}</span>
<span>Dependents: {module.dependents.length ? module.dependents.join(", ") : "none"}</span>
</div>
</div>
<div className="module-management-toggle">
<ToggleSwitch
label={desiredEnabled ? "Enabled" : "Disabled"}
checked={desiredEnabled}
disabled={!canWrite || module.protected || busy}
onChange={(checked) => setModuleDesired(module, checked)}
/>
{module.install_uninstall_supported && <Button onClick={() => void addUninstallPlan(module)} disabled={!canWrite || planBusy || module.current_enabled || module.desired_enabled}>Plan uninstall</Button>}
</div>
</div>
);
})}
</div>
{catalog.notes.length > 0 && <div className="module-management-notes">
{catalog.notes.map((note) => <p key={note}>{note}</p>)}
</div>}
{packageCatalog && <div className="module-package-catalog">
<div className="module-install-plan-heading">
<div>
<h2>Package catalog</h2>
<p>Approved package references that can be added to the operator install plan.</p>
</div>
<div className="button-row compact-actions">
{packageCatalog.channel && <StatusBadge status="inactive" label={`Channel: ${packageCatalog.channel}`} />}
{packageCatalog.sequence !== null && packageCatalog.sequence !== undefined && <StatusBadge status="inactive" label={`Seq ${packageCatalog.sequence}`} />}
<StatusBadge
status={packageCatalog.trusted ? "success" : packageCatalog.signed ? "warning" : "inactive"}
label={packageCatalog.trusted ? "Signed and trusted" : packageCatalog.signed ? "Signed, untrusted" : "Unsigned"}
/>
</div>
</div>
{(packageCatalog.source || packageCatalog.path) && <p className="module-package-catalog-description">Catalog {packageCatalog.source_type ?? "source"}: <code>{packageCatalog.source ?? packageCatalog.path}</code></p>}
{(packageCatalog.generated_at || packageCatalog.expires_at) && <p className="module-package-catalog-description">Generated: {packageCatalog.generated_at ?? "unknown"} · Expires: {packageCatalog.expires_at ?? "unknown"}</p>}
{packageCatalog.error && <p className="alert warning">{packageCatalog.error}</p>}
{packageCatalog.warnings.map((warning) => <p key={warning} className="alert warning">{warning}</p>)}
{!packageCatalog.configured && <div className="module-install-plan-empty">No package catalog configured. Set GOVOPLAN_MODULE_PACKAGE_CATALOG or GOVOPLAN_MODULE_PACKAGE_CATALOG_URL to enable curated install entries.</div>}
{packageCatalog.configured && packageCatalog.modules.length === 0 && !packageCatalog.error && <div className="module-install-plan-empty">Package catalog is configured but contains no entries.</div>}
{packageCatalog.modules.length > 0 && <div className="module-package-catalog-list">
{packageCatalog.modules.map((item) => (
<div key={`${item.module_id}-${item.version ?? "latest"}`} className="module-package-catalog-row">
<div className="module-management-main">
<div className="module-management-title">
<strong>{item.name}</strong>
<code>{item.module_id}</code>
{item.version && <span>v{item.version}</span>}
</div>
<div className="module-management-details">
{item.python_package && <span>{item.python_package}</span>}
{item.webui_package && <span>{item.webui_package}</span>}
{item.tags.length > 0 && <span>{item.tags.join(", ")}</span>}
{item.license_features.length > 0 && <span>License: {item.license_features.join(", ")}</span>}
{item.license_missing_features.length > 0 && <span>Missing: {item.license_missing_features.join(", ")}</span>}
</div>
{item.description && <p className="module-package-catalog-description">{item.description}</p>}
{item.license_reason && <p className={`module-package-catalog-description${item.license_allowed ? "" : " alert warning"}`}>{item.license_reason}</p>}
</div>
<Button onClick={() => void addCatalogItem(item)} disabled={!canWrite || planBusy || !packageCatalog.valid || !item.license_allowed || item.action !== "install"}>Plan install</Button>
</div>
))}
</div>}
</div>}
{installPlan && <div className="module-install-plan-panel">
<div className="module-install-plan-heading">
<div>
<h2>Operator install plan</h2>
<p>Plan package installs and removals here, then apply the rendered commands from an operator shell during maintenance mode.</p>
</div>
<div className="button-row compact-actions">
<Button onClick={addPlanItem} disabled={!canWrite || planBusy}>Add plan item</Button>
<Button onClick={() => void clearPlan()} disabled={!canWrite || planBusy || draftPlanItems.length === 0}>Clear</Button>
<Button variant="primary" onClick={() => void savePlan()} disabled={!canWrite || planBusy || !planDirty || !planValid}>{planBusy ? "Saving..." : "Save plan"}</Button>
</div>
</div>
{planValidationError(draftPlanItems) && <p className="alert warning">{planValidationError(draftPlanItems)}</p>}
{!maintenanceEnabled && <p className="alert warning">Maintenance mode is off. Package changes should be applied only after enabling maintenance mode.</p>}
{installPlan.preflight && <div className={`module-install-preflight ${installPlan.preflight.allowed ? "is-allowed" : "is-blocked"}`}>
<div className="module-install-preflight-title">
<strong>{installPlan.preflight.allowed ? "Installer preflight passed" : "Installer preflight blocked"}</strong>
<span>{installPlan.preflight.restart_required ? "Restart/reload required after package changes" : "No package restart pending"}</span>
{installPlan.preflight.frontend_rebuild_required && <span>WebUI rebuild required</span>}
</div>
{installPlan.preflight.issues.length > 0 && <div className="module-install-preflight-issues">
{installPlan.preflight.issues.map((issue, index) => (
<div key={`${issue.code}-${issue.module_id ?? "global"}-${index}`} className={`module-install-preflight-issue severity-${issue.severity}`}>
<strong>{issue.severity}</strong>
<span>{issue.module_id ? `${issue.module_id}: ` : ""}{issue.message}</span>
</div>
))}
</div>}
{installPlan.preflight.checklist.length > 0 && <div className="module-install-checklist">
{installPlan.preflight.checklist.map((item) => (
<div key={item.id} className={`module-install-checklist-item status-${item.status}`}>
<strong>{item.label}</strong>
<span>{item.status}</span>
{item.detail && <p>{item.detail}</p>}
</div>
))}
</div>}
</div>}
<div className="module-install-plan-list">
{draftPlanItems.length === 0 && <div className="module-install-plan-empty">No package changes planned.</div>}
{draftPlanItems.map((item, index) => (
<div className="module-install-plan-row" key={`${item.module_id}-${index}`}>
<label><span>Action</span><select value={item.action} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { action: event.target.value as ModuleInstallPlanItem["action"] })}><option value="install">Install</option><option value="uninstall">Uninstall</option></select></label>
<label><span>Module</span><input value={item.module_id} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { module_id: event.target.value })} placeholder="files" /></label>
<label><span>Python package</span><input value={item.python_package ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { python_package: event.target.value })} placeholder="govoplan-files" /></label>
<ToggleSwitch label="Destroy data" checked={Boolean(item.destroy_data)} onChange={(checked) => updatePlanItem(index, { destroy_data: checked })} disabled={!canWrite || planBusy || item.action !== "uninstall"} />
<label className="wide"><span>Python ref</span><input value={item.python_ref ?? ""} disabled={!canWrite || planBusy || item.action === "uninstall"} onChange={(event) => updatePlanItem(index, { python_ref: event.target.value })} placeholder="govoplan-files @ git+ssh://...@v0.1.4" /></label>
<label><span>WebUI package</span><input value={item.webui_package ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { webui_package: event.target.value })} placeholder="@govoplan/files-webui" /></label>
<label className="wide"><span>WebUI ref</span><input value={item.webui_ref ?? ""} disabled={!canWrite || planBusy || item.action === "uninstall"} onChange={(event) => updatePlanItem(index, { webui_ref: event.target.value })} placeholder="git+ssh://...#v0.1.4" /></label>
<label><span>Status</span><select value={item.status} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { status: event.target.value as ModuleInstallPlanItem["status"] })}><option value="planned">Planned</option><option value="applied">Applied</option><option value="blocked">Blocked</option></select></label>
<label className="wide"><span>Notes</span><input value={item.notes ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { notes: event.target.value })} /></label>
<div className="module-install-plan-actions"><Button onClick={() => setDraftPlanItems((items) => items.filter((_, itemIndex) => itemIndex !== index))} disabled={!canWrite || planBusy}>Remove</Button></div>
</div>
))}
</div>
<pre className="code-panel module-install-plan-commands">{installerCommand(installPlan.preflight?.frontend_rebuild_required ?? false)}</pre>
{installPlan.preflight?.commands.length ? <pre className="code-panel module-install-plan-commands">{installPlan.preflight.commands.join("\n")}</pre> : installPlan.commands.length > 0 && <pre className="code-panel module-install-plan-commands">{installPlan.commands.join("\n")}</pre>}
{installPlan.notes.length > 0 && <div className="module-management-notes">{installPlan.notes.map((note) => <p key={note}>{note}</p>)}</div>}
<div className="module-installer-request-panel">
<div className="module-install-plan-heading">
<div>
<h2>Daemon execution</h2>
<p>Queue the saved plan for a separate installer daemon process.</p>
</div>
<Button variant="primary" onClick={() => void queueInstallerRequest()} disabled={!canWrite || !canAccessMaintenance || planBusy || planDirty || !maintenanceEnabled || !installPlan.preflight?.allowed}>
Queue supervised run
</Button>
</div>
{planDirty && <p className="alert warning">Save the install plan before queueing a daemon request.</p>}
{!maintenanceEnabled && <p className="alert warning">Maintenance mode must be enabled before queueing a daemon request.</p>}
{!canAccessMaintenance && <p className="alert warning">Queueing installer requests requires maintenance access.</p>}
<div className="module-installer-request-grid">
<ToggleSwitch label="Run migrations" checked={requestOptions.migrateDatabase} onChange={(checked) => setRequestOptions((current) => ({ ...current, migrateDatabase: checked }))} disabled={!canWrite || planBusy} />
<ToggleSwitch label="Build WebUI" checked={requestOptions.buildWebui} onChange={(checked) => setRequestOptions((current) => ({ ...current, buildWebui: checked }))} disabled={!canWrite || planBusy} />
<ToggleSwitch label="Activate installs" checked={requestOptions.activateInstalledModules} onChange={(checked) => setRequestOptions((current) => ({ ...current, activateInstalledModules: checked }))} disabled={!canWrite || planBusy} />
<ToggleSwitch label="Drop uninstalls from startup" checked={requestOptions.removeUninstalledModulesFromDesired} onChange={(checked) => setRequestOptions((current) => ({ ...current, removeUninstalledModulesFromDesired: checked }))} disabled={!canWrite || planBusy} />
<label><span>Health URLs</span><textarea value={requestOptions.healthUrls} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, healthUrls: event.target.value }))} placeholder="http://127.0.0.1:8000/health" /></label>
<label><span>Restart commands</span><textarea value={requestOptions.restartCommands} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, restartCommands: event.target.value }))} placeholder="systemctl restart govoplan.service" /></label>
<label><span>DB backup command</span><textarea value={requestOptions.databaseBackupCommand} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, databaseBackupCommand: event.target.value }))} placeholder={"pg_dump --format=custom \"$GOVOPLAN_DATABASE_URL\" > \"$GOVOPLAN_DATABASE_BACKUP_PATH\""} /></label>
<label><span>DB restore command</span><textarea value={requestOptions.databaseRestoreCommand} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, databaseRestoreCommand: event.target.value }))} placeholder={"pg_restore --clean --if-exists --dbname \"$GOVOPLAN_DATABASE_URL\" \"$GOVOPLAN_DATABASE_BACKUP_PATH\""} /></label>
<label><span>DB restore-check command</span><textarea value={requestOptions.databaseRestoreCheckCommand} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, databaseRestoreCheckCommand: event.target.value }))} placeholder={"pg_restore --list \"$GOVOPLAN_DATABASE_BACKUP_PATH\" >/dev/null"} /></label>
</div>
</div>
</div>}
{installerRequests && <div className="module-installer-runs">
<div className="module-installer-runs-heading">
<div>
<h2>Installer requests</h2>
<p>Queued daemon handoffs created from the admin UI or CLI.</p>
</div>
<StatusBadge
status={installerRequests.daemon.running ? statusTone(installerRequests.daemon.status) : "inactive"}
label={installerRequests.daemon.running ? `Daemon: ${installerRequests.daemon.status}` : "Daemon offline"}
/>
</div>
{installerRequests.requests.length === 0 && <div className="module-install-plan-empty">No installer requests recorded yet.</div>}
{installerRequests.requests.length > 0 && <div className="module-installer-run-list">
{installerRequests.requests.map((request) => (
<div key={request.request_id} className="module-installer-run-row">
<div className="module-installer-run-main">
<div className="module-installer-run-title">
<strong>{request.request_id}</strong>
<StatusBadge status={statusTone(request.status)} label={request.status} />
</div>
<div className="module-management-details">
<span>Created: {formatDateTime(request.created_at)}</span>
<span>Started: {formatDateTime(request.started_at)}</span>
<span>Finished: {formatDateTime(request.finished_at)}</span>
{request.cancelled_at && <span>Cancelled: {formatDateTime(request.cancelled_at)}</span>}
{request.retry_of && <span>Retry of: {request.retry_of}</span>}
</div>
{request.error && <p className="module-installer-run-error">{request.error}</p>}
</div>
<div className="module-installer-run-actions">
{request.status === "queued" && <Button onClick={() => void cancelInstallerRequest(request.request_id)} disabled={!canWrite || !canAccessMaintenance || !maintenanceEnabled || planBusy}>Cancel</Button>}
{(request.status === "failed" || request.status === "cancelled") && <Button onClick={() => void retryInstallerRequest(request.request_id)} disabled={!canWrite || !canAccessMaintenance || !maintenanceEnabled || planBusy}>Retry</Button>}
{request.record_path && <code>{request.record_path}</code>}
</div>
</div>
))}
</div>}
</div>}
{installerRuns && <div className="module-installer-runs">
<div className="module-installer-runs-heading">
<div>
<h2>Installer runs</h2>
<p>Recent supervised installer records and the current package-install lock.</p>
</div>
<StatusBadge status={installerRuns.lock.locked ? "warning" : "success"} label={installerRuns.lock.locked ? "Locked" : "Unlocked"} />
</div>
{installerRuns.runs.length === 0 && <div className="module-install-plan-empty">No installer runs recorded yet.</div>}
{installerRuns.runs.length > 0 && <div className="module-installer-run-list">
{installerRuns.runs.map((run) => (
<div key={run.run_id} className="module-installer-run-row">
<div className="module-installer-run-main">
<div className="module-installer-run-title">
<strong>{run.run_id}</strong>
<StatusBadge status={statusTone(run.status)} label={run.status} />
{run.supervisor_status && <StatusBadge status={statusTone(run.supervisor_status)} label={`Supervisor: ${run.supervisor_status}`} />}
{run.rollback_status && <StatusBadge status={statusTone(run.rollback_status)} label={`Rollback: ${run.rollback_status}`} />}
</div>
<div className="module-management-details">
<span>Modules: {run.planned_modules.length ? run.planned_modules.join(", ") : "none"}</span>
<span>Commands: {run.commands_count}</span>
<span>Started: {formatDateTime(run.started_at)}</span>
<span>Finished: {formatDateTime(run.finished_at)}</span>
</div>
{run.error && <p className="module-installer-run-error">{run.error}</p>}
</div>
<code>{run.record_path}</code>
</div>
))}
</div>}
</div>}
</>}
</AdminPageLayout>
);
}
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 ModuleStatus({ module, desiredEnabled }: { module: ModuleCatalogItem; desiredEnabled: boolean }) {
if (module.current_enabled && !desiredEnabled) return <StatusBadge status="warning" label="Will disable" />;
if (!module.current_enabled && desiredEnabled) return <StatusBadge status="warning" label="Will enable" />;
if (module.current_enabled) return <StatusBadge status="success" label="Active" />;
return <StatusBadge status="inactive" label="Inactive" />;
}
function sameSet(left: ReadonlySet<string>, right: ReadonlySet<string>) {
if (left.size !== right.size) return false;
for (const item of left) {
if (!right.has(item)) return false;
}
return true;
}
function emptyPlanItem(): ModuleInstallPlanItem {
return {
module_id: "",
action: "install",
python_package: "",
python_ref: "",
webui_package: "",
webui_ref: "",
destroy_data: false,
status: "planned",
notes: ""
};
}
function normalizePlanItems(items: ModuleInstallPlanItem[]): ModuleInstallPlanItem[] {
return items
.map((item) => ({
module_id: item.module_id.trim(),
action: item.action,
python_package: cleanOptional(item.python_package),
python_ref: item.action === "install" ? cleanOptional(item.python_ref) : null,
webui_package: cleanOptional(item.webui_package),
webui_ref: item.action === "install" ? cleanOptional(item.webui_ref) : null,
destroy_data: item.action === "uninstall" && Boolean(item.destroy_data),
status: item.status,
notes: cleanOptional(item.notes)
}))
.filter((item) => item.module_id || item.python_package || item.python_ref || item.webui_package || item.webui_ref || item.notes);
}
function cleanOptional(value?: string | null): string | null {
const cleaned = (value ?? "").trim();
return cleaned || null;
}
function planValidationError(items: ModuleInstallPlanItem[]): string {
for (const item of normalizePlanItems(items)) {
if (!item.module_id) return "Every plan item needs a module id.";
if (item.action === "install" && !item.python_ref) return `${item.module_id} needs a Python package reference.`;
if (item.action === "install" && item.python_ref && !item.python_package) return `${item.module_id} needs a Python package name for rollback.`;
if (item.action === "uninstall" && !item.python_package) return `${item.module_id} needs a Python package name for uninstall.`;
if (item.action === "install" && Boolean(item.webui_package) !== Boolean(item.webui_ref)) return `${item.module_id} needs both WebUI package and WebUI reference, or neither.`;
if (item.action === "uninstall" && item.webui_ref && !item.webui_package) return `${item.module_id} has a WebUI reference but no WebUI package.`;
}
return "";
}
function statusTone(status: string): string {
const normalized = status.toLowerCase();
if (normalized.includes("fail") || normalized.includes("blocked")) return "error";
if (normalized.includes("rollback") || normalized.includes("running") || normalized.includes("dry")) return "warning";
if (normalized.includes("applied") || normalized.includes("ok") || normalized.includes("unlock")) return "success";
return "inactive";
}
function formatDateTime(value?: string | null): string {
if (!value) return "not recorded";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function installerCommand(frontendRebuildRequired: boolean): string {
const buildFlag = frontendRebuildRequired ? " --build-webui" : "";
return [
"govoplan-module-installer --format shell",
`govoplan-module-installer --daemon --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`,
`govoplan-module-installer --enqueue-supervised --migrate${buildFlag} --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`
].join("\n");
}
type InstallerRequestFormOptions = {
buildWebui: boolean;
migrateDatabase: boolean;
healthUrls: string;
restartCommands: string;
databaseBackupCommand: string;
databaseRestoreCommand: string;
databaseRestoreCheckCommand: string;
activateInstalledModules: boolean;
removeUninstalledModulesFromDesired: boolean;
};
function defaultInstallerRequestOptions(): InstallerRequestFormOptions {
return {
buildWebui: false,
migrateDatabase: true,
healthUrls: "http://127.0.0.1:8000/health",
restartCommands: "",
databaseBackupCommand: "",
databaseRestoreCommand: "",
databaseRestoreCheckCommand: "",
activateInstalledModules: true,
removeUninstalledModulesFromDesired: true
};
}
function normalizeRequestOptions(form: InstallerRequestFormOptions, installPlan: ModuleInstallPlanResponse): ModuleInstallerRequestOptions {
return {
build_webui: form.buildWebui || Boolean(installPlan.preflight?.frontend_rebuild_required),
migrate_database: form.migrateDatabase,
restart_commands: splitLines(form.restartCommands),
health_urls: splitLines(form.healthUrls),
database_backup_command: cleanOptional(form.databaseBackupCommand),
database_restore_command: cleanOptional(form.databaseRestoreCommand),
database_restore_check_command: cleanOptional(form.databaseRestoreCheckCommand),
activate_installed_modules: form.activateInstalledModules,
remove_uninstalled_modules_from_desired: form.removeUninstalledModulesFromDesired,
health_timeout_seconds: 60,
health_interval_seconds: 2
};
}
function splitLines(value: string): string[] {
return value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
}

View File

@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
import { fetchSystemSettings, updateSystemSettings, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsItem } from "../../api/admin";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
store_raw_campaign_json: true,
raw_campaign_json_retention_days: true,
generated_eml_retention_days: true,
stored_report_detail_retention_days: true,
mock_mailbox_retention_days: true,
audit_detail_retention_days: true,
audit_detail_level: true
};
const defaultPrivacyPolicy: PrivacyRetentionPolicy = {
store_raw_campaign_json: true,
raw_campaign_json_retention_days: null,
generated_eml_retention_days: null,
stored_report_detail_retention_days: null,
mock_mailbox_retention_days: null,
audit_detail_retention_days: null,
audit_detail_level: "full",
allow_lower_level_limits: defaultAllowLowerLevelLimits
};
const fallback: SystemSettingsItem = {
default_locale: "en",
allow_tenant_custom_groups: true,
allow_tenant_custom_roles: true,
allow_tenant_api_keys: true,
privacy_retention_policy: defaultPrivacyPolicy,
maintenance_mode: { enabled: false, message: "" },
settings: {}
};
export default function SystemSettingsPanel({ settings, canWrite, canAccessMaintenance }: { settings: ApiSettings; canWrite: boolean; canAccessMaintenance: boolean }) {
const [draft, setDraft] = useState<SystemSettingsItem>(fallback);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function load() {
setLoading(true);
setError("");
try {
const loaded = await fetchSystemSettings(settings);
setDraft(loaded);
}
catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
async function save() {
setBusy(true);
setError("");
try {
setDraft(await updateSystemSettings(settings, {
default_locale: draft.default_locale,
allow_tenant_custom_groups: draft.allow_tenant_custom_groups,
allow_tenant_custom_roles: draft.allow_tenant_custom_roles,
allow_tenant_api_keys: draft.allow_tenant_api_keys,
maintenance_mode: draft.maintenance_mode
}));
setSuccess("System settings saved.");
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
}
return (
<AdminPageLayout
title="System general settings"
description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section."
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy}>{busy ? "Saving…" : "Save settings"}</Button></>}
>
<div className="admin-settings-form">
<Card title="Defaults for newly created tenants">
<FormField label="Default locale"><input value={draft.default_locale} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} /></FormField>
</Card>
<Card title="Tenant administration capabilities">
<div className="settings-list">
<ToggleSwitch checked={draft.allow_tenant_custom_groups} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_groups: checked })} label="Allow tenant-defined groups by default" />
<ToggleSwitch checked={draft.allow_tenant_custom_roles} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_roles: checked })} label="Allow tenant-defined roles by default" />
<ToggleSwitch checked={draft.allow_tenant_api_keys} onChange={(checked) => setDraft({ ...draft, allow_tenant_api_keys: checked })} label="Allow tenant API keys by default" />
</div>
<p className="muted small-note">These settings are enforced by the backend. Central groups and tenant roles remain available even when local creation is disabled.</p>
</Card>
<Card title="Maintenance mode">
<div className="settings-list">
<ToggleSwitch
checked={draft.maintenance_mode.enabled}
disabled={!canWrite || !canAccessMaintenance}
onChange={(checked) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, enabled: checked } })}
label="Restrict authenticated API access to maintenance operators"
/>
</div>
<FormField label="Maintenance message">
<textarea
rows={3}
value={draft.maintenance_mode.message ?? ""}
disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, message: event.target.value } })}
/>
</FormField>
<p className="muted small-note">Changing the maintenance-mode flag requires system:maintenance:access. Login remains available so an operator can sign in during maintenance.</p>
</Card>
</div>
</AdminPageLayout>
);
}