354 lines
18 KiB
TypeScript
354 lines
18 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { Check, Download, Play, RefreshCw } from "lucide-react";
|
|
import type { ApiSettings } from "@govoplan/core-webui";
|
|
import { AdminPageLayout, Button, Card, DataGrid, StatusBadge, adminErrorMessage, i18nMessage, type DataGridColumn } from "@govoplan/core-webui";
|
|
import {
|
|
applyConfigurationPackage,
|
|
createConfigurationChangeRequest,
|
|
dryRunConfigurationPackage,
|
|
exportConfigurationPackage,
|
|
fetchConfigurationPackageCatalogValidation,
|
|
type ConfigurationChangeRequest,
|
|
type ConfigurationPackageDiagnostic,
|
|
type ConfigurationPackagePlanItem,
|
|
type ConfigurationPackageRequiredData } from
|
|
"../../api/admin";
|
|
|
|
const SAMPLE_ACCESS_PACKAGE = {
|
|
package_id: "govoplan.access.minimal-office",
|
|
name: "i18n:govoplan-admin.minimal_office_access.af48f49a",
|
|
version: "0.1.0",
|
|
required_modules: [{ module_id: "access" }],
|
|
required_capabilities: ["configuration.provider", "access.configuration"],
|
|
fragments: [
|
|
{
|
|
module_id: "access",
|
|
fragment_type: "roles",
|
|
payload: {
|
|
items: [
|
|
{
|
|
slug: "case-clerk",
|
|
name: "i18n:govoplan-admin.case_clerk.b78a314a",
|
|
description: "i18n:govoplan-admin.handles_incoming_administrative_work.dc80c349",
|
|
permissions: ["admin:users:read"]
|
|
}]
|
|
|
|
}
|
|
},
|
|
{
|
|
module_id: "access",
|
|
fragment_type: "groups",
|
|
payload: { items: [{ slug: "front-office", name: "i18n:govoplan-admin.front_office.d9dcfee1" }] }
|
|
},
|
|
{
|
|
module_id: "access",
|
|
fragment_type: "group_role_assignments",
|
|
payload: { items: [{ group: "front-office", role: "case-clerk" }] }
|
|
}]
|
|
|
|
};
|
|
|
|
type DryRunResult = {
|
|
diagnostics: ConfigurationPackageDiagnostic[];
|
|
required_data: ConfigurationPackageRequiredData[];
|
|
plan: ConfigurationPackagePlanItem[];
|
|
};
|
|
|
|
type ApplyResult = {
|
|
diagnostics: ConfigurationPackageDiagnostic[];
|
|
created_refs: Record<string, string>;
|
|
updated_refs: Record<string, string>;
|
|
};
|
|
|
|
export default function ConfigurationPackagesPanel({ settings, canWrite }: {settings: ApiSettings;canWrite: boolean;}) {
|
|
const [catalogValidation, setCatalogValidation] = useState<Record<string, unknown> | null>(null);
|
|
const [packageText, setPackageText] = useState(() => JSON.stringify(SAMPLE_ACCESS_PACKAGE, null, 2));
|
|
const [tenantId, setTenantId] = useState("");
|
|
const [suppliedDataText, setSuppliedDataText] = useState("{}");
|
|
const [changeRequestId, setChangeRequestId] = useState("");
|
|
const [dryRun, setDryRun] = useState<DryRunResult | null>(null);
|
|
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
|
|
const [exportText, setExportText] = useState("");
|
|
const [lastRequest, setLastRequest] = useState<ConfigurationChangeRequest | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState("");
|
|
const [error, setError] = useState("");
|
|
const [message, setMessage] = useState("");
|
|
|
|
const parsedPackage = useMemo(() => parseObject(packageText), [packageText]);
|
|
const parsedSuppliedData = useMemo(() => parseObject(suppliedDataText), [suppliedDataText]);
|
|
const canRun = Boolean(parsedPackage.value && parsedSuppliedData.value);
|
|
|
|
async function loadCatalog() {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const response = await fetchConfigurationPackageCatalogValidation(settings);
|
|
setCatalogValidation(response.validation);
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {void loadCatalog();}, [settings.accessToken, settings.apiBaseUrl]);
|
|
|
|
async function runDryRun() {
|
|
const manifest = requireParsedPackage();
|
|
const suppliedData = requireParsedSuppliedData();
|
|
if (!manifest || !suppliedData) return;
|
|
setBusy("dry-run");
|
|
setError("");
|
|
setMessage("");
|
|
setApplyResult(null);
|
|
try {
|
|
const result = await dryRunConfigurationPackage(settings, runPayload(manifest, suppliedData));
|
|
setDryRun(result);
|
|
setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.preflight_finished_with_blockers.7e2ca12b" : "i18n:govoplan-admin.preflight_passed.c0c99055");
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function requestApproval() {
|
|
const manifest = requireParsedPackage();
|
|
if (!manifest) return;
|
|
setBusy("approval");
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
const request = await createConfigurationChangeRequest(settings, {
|
|
key: "configuration_packages.apply",
|
|
value: manifest,
|
|
dry_run: true,
|
|
target: tenantId.trim() ? { tenant_id: tenantId.trim() } : {},
|
|
reason: "i18n:govoplan-admin.configuration_package_apply.c270e5f3"
|
|
});
|
|
setLastRequest(request);
|
|
setChangeRequestId(request.id);
|
|
setMessage(i18nMessage("i18n:govoplan-admin.change_request_created_value.4af6d3d2", { value0: request.id }));
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function applyPackage() {
|
|
const manifest = requireParsedPackage();
|
|
const suppliedData = requireParsedSuppliedData();
|
|
if (!manifest || !suppliedData) return;
|
|
setBusy("apply");
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
const result = await applyConfigurationPackage(settings, runPayload(manifest, suppliedData, changeRequestId.trim() || null));
|
|
setApplyResult(result);
|
|
setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.apply_finished_with_blockers.78487a12" : "i18n:govoplan-admin.package_applied.63782ce7");
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function exportAccessPackage() {
|
|
setBusy("export");
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
const result = await exportConfigurationPackage(settings, {
|
|
tenant_id: tenantId.trim() || null,
|
|
module_ids: ["access"],
|
|
scopes: tenantId.trim() ? ["tenant"] : ["system"]
|
|
});
|
|
const exported = {
|
|
package_id: tenantId.trim() ? "govoplan.export.access-tenant" : "govoplan.export.access-system",
|
|
name: tenantId.trim() ? "i18n:govoplan-admin.exported_tenant_access_configuration.9122c85c" : "i18n:govoplan-admin.exported_system_access_configuration.2e1c6f4c",
|
|
version: "0.1.0",
|
|
required_modules: [{ module_id: "access" }],
|
|
required_capabilities: ["configuration.provider", "access.configuration"],
|
|
fragments: result.fragments,
|
|
data_requirements: result.data_requirements
|
|
};
|
|
setExportText(JSON.stringify(exported, null, 2));
|
|
setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.export_finished_with_blockers.e2f70611" : "i18n:govoplan-admin.access_configuration_exported.098f200d");
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
function runPayload(manifest: Record<string, unknown>, suppliedData: Record<string, unknown>, requestId?: string | null) {
|
|
return {
|
|
package: manifest,
|
|
tenant_id: tenantId.trim() || null,
|
|
supplied_data: suppliedData,
|
|
change_request_id: requestId ?? null
|
|
};
|
|
}
|
|
|
|
function requireParsedPackage(): Record<string, unknown> | null {
|
|
if (!parsedPackage.value) {
|
|
setError(parsedPackage.error || "i18n:govoplan-admin.package_json_must_be_an_object.db825bb3");
|
|
return null;
|
|
}
|
|
return parsedPackage.value;
|
|
}
|
|
|
|
function requireParsedSuppliedData(): Record<string, unknown> | null {
|
|
if (!parsedSuppliedData.value) {
|
|
setError(parsedSuppliedData.error || "i18n:govoplan-admin.supplied_data_json_must_be_an_object.b265eb92");
|
|
return null;
|
|
}
|
|
return parsedSuppliedData.value;
|
|
}
|
|
|
|
return (
|
|
<AdminPageLayout
|
|
title="i18n:govoplan-admin.configuration_packages.eb2f05f1"
|
|
description="i18n:govoplan-admin.import_preflight_approve_apply_and_export_module.29aec929"
|
|
loading={loading}
|
|
error={error || parsedPackage.error || parsedSuppliedData.error || ""}
|
|
success={message}
|
|
actions={<Button onClick={() => void loadCatalog()} disabled={loading || Boolean(busy)}><RefreshCw size={16} /> i18n:govoplan-admin.reload.cce71553</Button>}>
|
|
|
|
<Card title="i18n:govoplan-admin.catalog.4a88d27b">
|
|
<div className="button-row compact-actions">
|
|
<StatusBadge status={catalogValidation?.valid ? "success" : catalogValidation?.configured ? "warning" : "inactive"} label={catalogValidation?.valid ? "i18n:govoplan-admin.valid.a4aefa35" : catalogValidation?.configured ? "i18n:govoplan-admin.needs_attention.a126722e" : "i18n:govoplan-admin.not_configured.811931bb"} />
|
|
{catalogValidation?.signed !== undefined && <StatusBadge status={catalogValidation.signed ? "success" : "inactive"} label={catalogValidation.signed ? "i18n:govoplan-admin.signed.6e3665d8" : "i18n:govoplan-admin.unsigned.e91344ea"} />}
|
|
{catalogValidation?.trusted !== undefined && <StatusBadge status={catalogValidation.trusted ? "success" : "warning"} label={catalogValidation.trusted ? "i18n:govoplan-admin.trusted.99f7ed54" : "i18n:govoplan-admin.untrusted.cdc7838a"} />}
|
|
</div>
|
|
<pre className="code-panel module-install-plan-commands">{JSON.stringify(catalogValidation ?? {}, null, 2)}</pre>
|
|
</Card>
|
|
|
|
<Card title="i18n:govoplan-admin.package.7431e3df">
|
|
<div className="module-installer-request-grid">
|
|
<label className="wide"><span>i18n:govoplan-admin.tenant_id.59eba244</span><input value={tenantId} onChange={(event) => setTenantId(event.target.value)} placeholder="active tenant" /></label>
|
|
<label className="wide"><span>i18n:govoplan-admin.change_request_id.96ee3239</span><input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." /></label>
|
|
<label className="wide"><span>i18n:govoplan-admin.package_json.a2b10f38</span><textarea rows={18} value={packageText} onChange={(event) => setPackageText(event.target.value)} /></label>
|
|
<label className="wide"><span>i18n:govoplan-admin.supplied_data_json.6932bfb7</span><textarea rows={6} value={suppliedDataText} onChange={(event) => setSuppliedDataText(event.target.value)} /></label>
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={() => void runDryRun()} disabled={!canRun || Boolean(busy)}><Play size={16} /> i18n:govoplan-admin.dry_run.3d14659c</Button>
|
|
<Button onClick={() => void requestApproval()} disabled={!canWrite || !parsedPackage.value || Boolean(busy)}><Check size={16} /> i18n:govoplan-admin.request_approval.6245aea1</Button>
|
|
<Button variant="primary" onClick={() => void applyPackage()} disabled={!canWrite || !canRun || Boolean(busy)}><Check size={16} /> i18n:govoplan-admin.apply.cfea419c</Button>
|
|
<Button onClick={() => void exportAccessPackage()} disabled={Boolean(busy)}><Download size={16} /> i18n:govoplan-admin.export_access.9a2eb91e</Button>
|
|
</div>
|
|
{lastRequest && <p className="muted small-note">i18n:govoplan-admin.last_request.4508ef35 {lastRequest.id} ({lastRequest.status})</p>}
|
|
</Card>
|
|
|
|
{dryRun && <Card title="i18n:govoplan-admin.preflight.8016a487">
|
|
<PackageDiagnostics diagnostics={dryRun.diagnostics} />
|
|
<RequiredData items={dryRun.required_data} />
|
|
<PackagePlan items={dryRun.plan} />
|
|
</Card>}
|
|
|
|
{applyResult && <Card title="i18n:govoplan-admin.apply_result.0fde1c3c">
|
|
<PackageDiagnostics diagnostics={applyResult.diagnostics} />
|
|
<ReferenceMap title="i18n:govoplan-admin.created.accf40c8" refs={applyResult.created_refs} />
|
|
<ReferenceMap title="i18n:govoplan-admin.updated.f2f8570d" refs={applyResult.updated_refs} />
|
|
</Card>}
|
|
|
|
{exportText && <Card title="i18n:govoplan-admin.export.f3e4fadb">
|
|
<textarea className="code-panel module-install-plan-commands" rows={16} value={exportText} onChange={(event) => setExportText(event.target.value)} />
|
|
</Card>}
|
|
</AdminPageLayout>);
|
|
|
|
}
|
|
|
|
function parseObject(text: string): {value: Record<string, unknown> | null;error: string;} {
|
|
try {
|
|
const value = JSON.parse(text) as unknown;
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return { value: null, error: "i18n:govoplan-admin.json_must_be_an_object.848569c9" };
|
|
return { value: value as Record<string, unknown>, error: "" };
|
|
} catch (err) {
|
|
return { value: null, error: err instanceof Error ? err.message : "i18n:govoplan-admin.invalid_json.01ccb74f" };
|
|
}
|
|
}
|
|
|
|
function PackageDiagnostics({ diagnostics }: {diagnostics: ConfigurationPackageDiagnostic[];}) {
|
|
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 (
|
|
<DataGrid
|
|
id="admin-configuration-package-diagnostics"
|
|
rows={diagnostics}
|
|
columns={columns}
|
|
getRowKey={(item, index) => `${item.code}-${index}`}
|
|
/>);
|
|
|
|
}
|
|
|
|
function RequiredData({ items }: {items: ConfigurationPackageRequiredData[];}) {
|
|
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 (
|
|
<>
|
|
<h3>i18n:govoplan-admin.required_data.1b1c1b34</h3>
|
|
<DataGrid id="admin-configuration-package-required-data" rows={items} columns={columns} getRowKey={(item) => item.key} />
|
|
</>);
|
|
|
|
}
|
|
|
|
function PackagePlan({ items }: {items: ConfigurationPackagePlanItem[];}) {
|
|
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 (
|
|
<>
|
|
<h3>i18n:govoplan-admin.plan.ae2f98a0</h3>
|
|
<DataGrid id="admin-configuration-package-plan" rows={items} columns={columns} getRowKey={(item, index) => `${item.module_id}-${item.fragment_type}-${item.fragment_id ?? index}`} />
|
|
</>);
|
|
|
|
}
|
|
|
|
function ReferenceMap({ title, refs }: {title: string;refs: Record<string, string>;}) {
|
|
const entries = Object.entries(refs).map(([key, value]) => ({ key, value }));
|
|
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 (
|
|
<>
|
|
<h3>{title}</h3>
|
|
<DataGrid id={`admin-configuration-package-refs-${title}`} rows={entries} columns={columns} getRowKey={(item) => item.key} />
|
|
</>);
|
|
|
|
}
|
|
|
|
function diagnosticTone(severity: ConfigurationPackageDiagnostic["severity"]): string {
|
|
if (severity === "blocker") return "error";
|
|
if (severity === "warning") return "warning";
|
|
return "inactive";
|
|
}
|
|
|
|
function planTone(action: ConfigurationPackagePlanItem["action"]): string {
|
|
if (action === "blocked") return "error";
|
|
if (action === "skip" || action === "noop") return "inactive";
|
|
if (action === "update" || action === "bind") return "warning";
|
|
return "success";
|
|
}
|