chore: sync GovOPlaN module split state
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
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, StatusBadge, adminErrorMessage, i18nMessage } 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>;
|
||||
return (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<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>
|
||||
<tbody>
|
||||
{diagnostics.map((item, 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[];}) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<h3>i18n:govoplan-admin.required_data.1b1c1b34</h3>
|
||||
<div className="admin-table-wrap">
|
||||
<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[];}) {
|
||||
if (items.length === 0) return <p className="muted">i18n:govoplan-admin.no_plan_items.7108c582</p>;
|
||||
return (
|
||||
<>
|
||||
<h3>i18n:govoplan-admin.plan.ae2f98a0</h3>
|
||||
<div className="admin-table-wrap">
|
||||
<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>;}) {
|
||||
const entries = Object.entries(refs);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<tbody>
|
||||
{entries.map(([key, value]) => <tr key={key}><td><code>{key}</code></td><td>{value}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user