feat: expose capability and catalog documentation
This commit is contained in:
@@ -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}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(", ")}`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user