feat: expose capability and catalog documentation

This commit is contained in:
2026-07-31 04:21:34 +02:00
parent af18f072d8
commit e2bf104c53
7 changed files with 555 additions and 44 deletions
+29
View File
@@ -70,6 +70,20 @@ export type DocsSource = {
};
};
export type DocsSourceDetail = DocsSource & {
visibility: {
documentation_types: string[];
required_modules: string[];
any_modules: string[];
missing_modules: string[];
required_capabilities: string[];
required_scopes: string[];
any_scopes: string[];
configuration_keys: string[];
};
inspection: Record<string, unknown>;
};
export type DocsDocumentationCondition = {
required_modules: string[];
any_modules: string[];
@@ -186,3 +200,18 @@ export function fetchDocsContext(settings: ApiSettings, options: { documentation
const query = params.toString();
return apiFetch(settings, `/api/v1/docs/context${query ? `?${query}` : ""}`);
}
export function fetchDocsSource(
settings: ApiSettings,
sourceId: string,
options: { documentationType?: "admin" | "user"; locale?: string } = {}
): Promise<DocsSourceDetail> {
const params = new URLSearchParams();
if (options.documentationType) params.set("type", options.documentationType);
if (options.locale) params.set("locale", options.locale);
const query = params.toString();
return apiFetch(
settings,
`/api/v1/docs/sources/${encodeURIComponent(sourceId)}${query ? `?${query}` : ""}`
);
}
+150 -19
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "react-router";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
import { ChevronDown, ChevronRight, Eye, RefreshCw } from "lucide-react";
import {
Button,
DataGrid,
Dialog,
DismissibleAlert,
ExplorerTree,
LoadingFrame,
@@ -18,12 +19,14 @@ import {
} from "@govoplan/core-webui";
import {
fetchDocsContext,
fetchDocsSource,
type DocsContext,
type DocsDocumentationTopic,
type DocsModule,
type DocsOptionalModuleEvidence,
type DocsRoute,
type DocsSource
type DocsSource,
type DocsSourceDetail
} from "../../api/docs";
type DocumentationType = "admin" | "user";
@@ -189,6 +192,8 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
grantedPermissions={grantedPermissions}
evidenceModules={context?.layers.evidence.optional_modules ?? []}
evidenceSources={context?.layers.evidence.sources ?? []}
settings={settings}
locale={locale}
/>
</main>
<PageOutline items={outlineItems} />
@@ -269,7 +274,9 @@ function SelectedPageContent({
availableRoutes,
grantedPermissions,
evidenceModules,
evidenceSources
evidenceSources,
settings,
locale
}: {
page: DocsPageNode | null;
adminDocs: boolean;
@@ -281,6 +288,8 @@ function SelectedPageContent({
grantedPermissions: Array<{ scope: string; label: string; category: string }>;
evidenceModules: DocsOptionalModuleEvidence[];
evidenceSources: DocsSource[];
settings: ApiSettings;
locale: string;
}) {
if (!page) {
return (
@@ -310,7 +319,13 @@ function SelectedPageContent({
<section id="docs-admin-permissions" className="docs-reference-block">
<h3>i18n:govoplan-docs.granted_permissions.0a232e78</h3>
<PermissionList permissions={grantedPermissions} />
<EvidenceList modules={evidenceModules} sources={evidenceSources} />
<EvidenceList
modules={evidenceModules}
sources={evidenceSources}
settings={settings}
documentationType={documentationType}
locale={locale}
/>
</section>
</section>
);
@@ -965,26 +980,142 @@ function PermissionList({ permissions }: { permissions: Array<{ scope: string; l
);
}
function EvidenceList({ modules, sources }: { modules: DocsOptionalModuleEvidence[]; sources: DocsSource[] }) {
function EvidenceList({
modules,
sources,
settings,
documentationType,
locale
}: {
modules: DocsOptionalModuleEvidence[];
sources: DocsSource[];
settings: ApiSettings;
documentationType: DocumentationType;
locale: string;
}) {
const [selected, setSelected] = useState<DocsSourceDetail | null>(null);
const [loadingSourceId, setLoadingSourceId] = useState("");
const [sourceError, setSourceError] = useState("");
if (!modules.length && !sources.length) return <p className="muted">i18n:govoplan-docs.no_evidence_sources_found.be3bb2f6</p>;
async function inspectSource(source: DocsSource) {
setLoadingSourceId(source.id);
setSourceError("");
try {
setSelected(await fetchDocsSource(settings, source.id, { documentationType, locale }));
} catch (error) {
setSourceError(adminErrorMessage(error));
} finally {
setLoadingSourceId("");
}
}
return (
<dl className="detail-list">
{modules.map((item) =>
<div key={`${item.source_module_id}-${item.module_id}`}>
<dt><StatusBadge status={item.status === "installed" ? "success" : "inactive"} label={item.status} /></dt>
<dd><strong>{item.module_id}</strong><span className="muted"> · {item.reason}</span></dd>
</div>
)}
{sources.map((item) =>
<div key={item.id}>
<dt><StatusBadge status={item.state === "configured" ? "success" : item.state === "disabled" ? "inactive" : "warning"} label={item.state} /></dt>
<dd><strong>{item.label}</strong><span className="muted"> · {item.owner_module_id} · {item.kind} · {item.provenance.source}</span></dd>
</div>
)}
</dl>
<>
{sourceError && <DismissibleAlert tone="danger" resetKey={sourceError}>{sourceError}</DismissibleAlert>}
<dl className="detail-list">
{modules.map((item) =>
<div key={`${item.source_module_id}-${item.module_id}`}>
<dt><StatusBadge status={item.status === "installed" ? "success" : "inactive"} label={item.status} /></dt>
<dd><strong>{item.module_id}</strong><span className="muted"> · {item.reason}</span></dd>
</div>
)}
{sources.map((item) =>
<div key={item.id}>
<dt><StatusBadge status={item.state === "configured" ? "success" : item.state === "disabled" ? "inactive" : "warning"} label={item.state} /></dt>
<dd>
<strong>{item.label}</strong>
<span className="muted"> · {item.owner_module_id} · {item.kind} · {item.provenance.source}</span>
{item.state_reason && <span className="muted block">{item.state_reason}</span>}
</dd>
<dd>
<Button
variant="ghost"
className="icon-button"
title="i18n:govoplan-docs.inspect_source.bcc1739d"
aria-label="i18n:govoplan-docs.inspect_source.bcc1739d"
disabled={loadingSourceId === item.id}
onClick={() => void inspectSource(item)}
>
<Eye size={16} />
</Button>
</dd>
</div>
)}
</dl>
<Dialog
open={selected !== null}
title={selected?.label ?? "i18n:govoplan-docs.source_details.6dc79c75"}
onClose={() => setSelected(null)}
footer={<Button onClick={() => setSelected(null)}>i18n:govoplan-docs.close.87b84f71</Button>}
>
{selected && <SourceInspection source={selected} />}
</Dialog>
</>
);
}
function SourceInspection({ source }: { source: DocsSourceDetail }) {
const provenance = compactSourceRecord(source.provenance);
const visibility = compactSourceRecord(source.visibility);
const inspection = compactSourceRecord(source.inspection);
return (
<div className="stack">
<div>
<StatusBadge
status={source.state === "configured" ? "success" : source.state === "disabled" ? "inactive" : "warning"}
label={source.state}
/>
{source.state_reason && <p className="muted">{source.state_reason}</p>}
</div>
<SourceInspectionGroup title="i18n:govoplan-docs.provenance.73e80298" values={provenance} />
<SourceInspectionGroup title="i18n:govoplan-docs.visibility.80ab5798" values={visibility} />
<SourceInspectionGroup title="i18n:govoplan-docs.inspection.83dc17ca" values={inspection} />
</div>
);
}
function SourceInspectionGroup({ title, values }: { title: string; values: Array<[string, unknown]> }) {
if (!values.length) return null;
return (
<section>
<h3>{title}</h3>
<dl className="detail-list">
{values.map(([key, value]) =>
<div key={key}>
<dt>{humanizeSourceKey(key)}</dt>
<dd>{formatSourceValue(value)}</dd>
</div>
)}
</dl>
</section>
);
}
function compactSourceRecord(value: object): Array<[string, unknown]> {
return Object.entries(value).filter(([, item]) => {
if (item === null || item === undefined || item === "") return false;
return !Array.isArray(item) || item.length > 0;
});
}
function humanizeSourceKey(value: string): string {
const words = value.replaceAll("_", " ");
return words.charAt(0).toUpperCase() + words.slice(1);
}
function formatSourceValue(value: unknown): string {
if (Array.isArray(value)) {
if (value.every((item) => ["string", "number", "boolean"].includes(typeof item))) {
return value.join(", ");
}
return JSON.stringify(value, null, 2);
}
if (value && typeof value === "object") return JSON.stringify(value, null, 2);
return String(value);
}
function routeRequirements(route: DocsRoute): string {
const parts = [];
if (route.required_all.length) parts.push(`all: ${route.required_all.join(", ")}`);
+12
View File
@@ -74,6 +74,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-docs.screen.c4878ec4": "Screen",
"i18n:govoplan-docs.section.5e498158": "Section",
"i18n:govoplan-docs.source.6da13add": "Source",
"i18n:govoplan-docs.source_details.6dc79c75": "Source details",
"i18n:govoplan-docs.inspect_source.bcc1739d": "Inspect source",
"i18n:govoplan-docs.inspection.83dc17ca": "Inspection",
"i18n:govoplan-docs.provenance.73e80298": "Provenance",
"i18n:govoplan-docs.visibility.80ab5798": "Visibility",
"i18n:govoplan-docs.close.87b84f71": "Close",
"i18n:govoplan-docs.status.bae7d5be": "Status",
"i18n:govoplan-docs.steps.6041435e": "Steps",
"i18n:govoplan-docs.summary.d6b9936d": "Summary",
@@ -165,6 +171,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-docs.screen.c4878ec4": "Ansicht",
"i18n:govoplan-docs.section.5e498158": "Bereich",
"i18n:govoplan-docs.source.6da13add": "Quelle",
"i18n:govoplan-docs.source_details.6dc79c75": "Quelldetails",
"i18n:govoplan-docs.inspect_source.bcc1739d": "Quelle anzeigen",
"i18n:govoplan-docs.inspection.83dc17ca": "Prüfdaten",
"i18n:govoplan-docs.provenance.73e80298": "Herkunft",
"i18n:govoplan-docs.visibility.80ab5798": "Sichtbarkeit",
"i18n:govoplan-docs.close.87b84f71": "Schließen",
"i18n:govoplan-docs.status.bae7d5be": "Status",
"i18n:govoplan-docs.steps.6041435e": "Schritte",
"i18n:govoplan-docs.summary.d6b9936d": "Zusammenfassung",