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
+24
View File
@@ -55,6 +55,30 @@ Frontend package:
Platform module manifests, configuration packages, release catalogs, and governance rules are documented in `govoplan-core/docs/`. Platform module manifests, configuration packages, release catalogs, and governance rules are documented in `govoplan-core/docs/`.
Capabilities can provide generic documentation without exposing their runtime
provider implementation:
```python
ModuleManifest(
capability_factories={"example.lookup": build_lookup},
capability_documentation={
"example.lookup": CapabilityDocumentation(
label="Example lookup",
summary="Resolves records through the versioned lookup contract.",
contract_version="2",
stability="stable",
audience=("module_admin",),
),
},
)
```
Docs also projects the configured module release catalog and configuration
package catalog through the public Core catalog contracts. Catalog trust,
freshness, provenance, descriptions, and package requirements remain visible
as typed evidence; provider objects, credentials, and secret configuration are
never imported into the Docs WebUI.
## Concept documents ## Concept documents
- `docs/DOCUMENTATION_LAYER_CONCEPT.md` defines the configured, available, and evidence documentation model. - `docs/DOCUMENTATION_LAYER_CONCEPT.md` defines the configured, available, and evidence documentation model.
-17
View File
@@ -195,23 +195,6 @@ manifest = ModuleManifest(
), ),
), ),
documentation_sources=( documentation_sources=(
DocumentationSourceDefinition(
id="docs.configuration.packages",
kind="configuration_package",
label="Configuration package catalog",
condition=DocumentationCondition(
any_scopes=("system:settings:read", "admin:settings:read"),
),
provenance={
"source": "core_configuration_package_contract",
"version": "1",
},
inspection={
"package_id": "govoplan.configuration-packages",
"schema_version": "1",
"description": "Signed, preflighted platform configuration packages.",
},
),
DocumentationSourceDefinition( DocumentationSourceDefinition(
id="docs.project.wiki", id="docs.project.wiki",
kind="wiki", kind="wiki",
+231 -5
View File
@@ -58,18 +58,58 @@ class RouteInspection(_SourceModel):
class CapabilityInspection(_SourceModel): class CapabilityInspection(_SourceModel):
kind: Literal["capability"] = "capability" kind: Literal["capability"] = "capability"
capability: str capability: str
label: str | None = None
summary: str | None = None
contract_version: str | None = None
stability: Literal["experimental", "stable", "deprecated"] | None = None
audience: list[str] = Field(default_factory=list)
class PolicyInspection(_SourceModel): class PolicyInspection(_SourceModel):
kind: Literal["policy"] = "policy" kind: Literal["policy"] = "policy"
capability: str capability: str
label: str | None = None
summary: str | None = None
contract_version: str | None = None
stability: Literal["experimental", "stable", "deprecated"] | None = None
audience: list[str] = Field(default_factory=list)
class ReleaseCatalogEntryInspection(_SourceModel):
id: str
name: str
version: str | None = None
description: str | None = None
action: str | None = None
tags: list[str] = Field(default_factory=list)
class ReleaseCatalogInspection(_SourceModel):
kind: Literal["release_catalog"] = "release_catalog"
catalog_type: Literal["modules", "configuration_packages"]
channel: str | None = None
sequence: int | None = None
generated_at: str | None = None
entry_count: int
signed: bool
trusted: bool
cache_used: bool
warnings: list[str] = Field(default_factory=list)
entries: list[ReleaseCatalogEntryInspection] = Field(default_factory=list)
class ConfigurationPackageInspection(_SourceModel): class ConfigurationPackageInspection(_SourceModel):
kind: Literal["configuration_package"] = "configuration_package" kind: Literal["configuration_package"] = "configuration_package"
package_id: str package_id: str
name: str | None = None
version: str | None = None
schema_version: str | None = None schema_version: str | None = None
description: str | None = None description: str | None = None
publisher: str | None = None
category: str | None = None
tags: list[str] = Field(default_factory=list)
required_modules: list[str] = Field(default_factory=list)
required_capabilities: list[str] = Field(default_factory=list)
class WikiInspection(_SourceModel): class WikiInspection(_SourceModel):
@@ -90,6 +130,7 @@ DocumentationSourceInspection = Annotated[
| RouteInspection | RouteInspection
| CapabilityInspection | CapabilityInspection
| PolicyInspection | PolicyInspection
| ReleaseCatalogInspection
| ConfigurationPackageInspection | ConfigurationPackageInspection
| WikiInspection | WikiInspection
| RepositoryInspection, | RepositoryInspection,
@@ -142,6 +183,8 @@ class DocumentationSourceRegistry:
def build_documentation_source_registry( def build_documentation_source_registry(
manifests: tuple[ModuleManifest, ...], manifests: tuple[ModuleManifest, ...],
*,
include_runtime_catalogs: bool = True,
) -> DocumentationSourceRegistry: ) -> DocumentationSourceRegistry:
registry = DocumentationSourceRegistry() registry = DocumentationSourceRegistry()
for manifest in manifests: for manifest in manifests:
@@ -155,6 +198,9 @@ def build_documentation_source_registry(
for source in _linked_sources(manifest): for source in _linked_sources(manifest):
if registry.get(source.item.id) is None: if registry.get(source.item.id) is None:
registry.register(source) registry.register(source)
if include_runtime_catalogs:
for source in _catalog_sources():
registry.register(source)
return registry return registry
@@ -203,21 +249,37 @@ def _route_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSou
def _capability_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]: def _capability_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]:
sources: list[RegisteredDocumentationSource] = [] sources: list[RegisteredDocumentationSource] = []
for capability in sorted(manifest.capability_factories): for capability in sorted(manifest.capability_factories):
metadata = manifest.capability_documentation.get(capability)
is_policy = capability.startswith("policy.") is_policy = capability.startswith("policy.")
kind = "policy" if is_policy else "capability" kind = "policy" if is_policy else "capability"
inspection: DocumentationSourceInspection = ( inspection: DocumentationSourceInspection = (
PolicyInspection(capability=capability) PolicyInspection(
capability=capability,
label=metadata.label if metadata else None,
summary=metadata.summary if metadata else None,
contract_version=metadata.contract_version if metadata else None,
stability=metadata.stability if metadata else None,
audience=list(metadata.audience) if metadata else [],
)
if is_policy if is_policy
else CapabilityInspection(capability=capability) else CapabilityInspection(
capability=capability,
label=metadata.label if metadata else None,
summary=metadata.summary if metadata else None,
contract_version=metadata.contract_version if metadata else None,
stability=metadata.stability if metadata else None,
audience=list(metadata.audience) if metadata else [],
)
) )
sources.append(_registered_source( sources.append(_registered_source(
source_id=_derived_source_id(manifest.id, kind, capability), source_id=_derived_source_id(manifest.id, kind, capability),
kind=kind, kind=kind,
owner_module_id=manifest.id, owner_module_id=manifest.id,
label=f"{manifest.name} {kind} {capability}", label=metadata.label if metadata else f"{manifest.name} {kind} {capability}",
provenance={"source": "module_manifest", "version": manifest.version}, provenance={"source": "module_manifest", "version": manifest.version},
inspection=inspection, inspection=inspection,
condition=DocumentationCondition(required_modules=(manifest.id,)), condition=DocumentationCondition(required_modules=(manifest.id,)),
documentation_types=metadata.documentation_types if metadata else ("admin",),
)) ))
return tuple(sources) return tuple(sources)
@@ -240,6 +302,7 @@ def _defined_source(
condition=definition.condition, condition=definition.condition,
documentation_types=definition.documentation_types, documentation_types=definition.documentation_types,
state=definition.state, state=definition.state,
state_reason=definition.state_reason,
configuration_key=definition.configuration_key, configuration_key=definition.configuration_key,
) )
@@ -249,8 +312,15 @@ def _defined_inspection(definition: DocumentationSourceDefinition) -> Documentat
if definition.kind == "configuration_package": if definition.kind == "configuration_package":
return ConfigurationPackageInspection( return ConfigurationPackageInspection(
package_id=str(safe.get("package_id") or definition.id), package_id=str(safe.get("package_id") or definition.id),
name=_optional_text(safe.get("name")),
version=_optional_text(safe.get("version")),
schema_version=_optional_text(safe.get("schema_version")), schema_version=_optional_text(safe.get("schema_version")),
description=_optional_text(safe.get("description")), description=_optional_text(safe.get("description")),
publisher=_optional_text(safe.get("publisher")),
category=_optional_text(safe.get("category")),
tags=_string_list(safe.get("tags")),
required_modules=_string_list(safe.get("required_modules")),
required_capabilities=_string_list(safe.get("required_capabilities")),
) )
href = definition.link.href if definition.link else str(safe.get("href") or "") href = definition.link.href if definition.link else str(safe.get("href") or "")
if definition.kind == "wiki": if definition.kind == "wiki":
@@ -271,9 +341,23 @@ def _defined_inspection(definition: DocumentationSourceDefinition) -> Documentat
order=int(safe.get("order") or 0), order=int(safe.get("order") or 0),
) )
if definition.kind == "policy": if definition.kind == "policy":
return PolicyInspection(capability=str(safe.get("capability") or definition.id)) return PolicyInspection(
capability=str(safe.get("capability") or definition.id),
label=_optional_text(safe.get("label")),
summary=_optional_text(safe.get("summary")),
contract_version=_optional_text(safe.get("contract_version")),
stability=_capability_stability(safe.get("stability")),
audience=_string_list(safe.get("audience")),
)
if definition.kind == "capability": if definition.kind == "capability":
return CapabilityInspection(capability=str(safe.get("capability") or definition.id)) return CapabilityInspection(
capability=str(safe.get("capability") or definition.id),
label=_optional_text(safe.get("label")),
summary=_optional_text(safe.get("summary")),
contract_version=_optional_text(safe.get("contract_version")),
stability=_capability_stability(safe.get("stability")),
audience=_string_list(safe.get("audience")),
)
return ManifestInspection( return ManifestInspection(
module_id=str(safe.get("module_id") or definition.id.split(".", 1)[0]), module_id=str(safe.get("module_id") or definition.id.split(".", 1)[0]),
name=str(safe.get("name") or definition.label), name=str(safe.get("name") or definition.label),
@@ -319,6 +403,7 @@ def _registered_source(
condition: DocumentationCondition | None = None, condition: DocumentationCondition | None = None,
documentation_types: tuple[DocumentationType, ...] = ("admin",), documentation_types: tuple[DocumentationType, ...] = ("admin",),
state: Literal["configured", "disabled", "unavailable"] = "configured", state: Literal["configured", "disabled", "unavailable"] = "configured",
state_reason: str | None = None,
configuration_key: str | None = None, configuration_key: str | None = None,
) -> RegisteredDocumentationSource: ) -> RegisteredDocumentationSource:
clean_provenance = _safe_source_fields(provenance) clean_provenance = _safe_source_fields(provenance)
@@ -329,6 +414,7 @@ def _registered_source(
owner_module_id=owner_module_id, owner_module_id=owner_module_id,
label=label, label=label,
state=state, state=state,
state_reason=state_reason,
inspection_url=f"/api/v1/docs/sources/{source_id}", inspection_url=f"/api/v1/docs/sources/{source_id}",
provenance=DocumentationSourceProvenance( provenance=DocumentationSourceProvenance(
source=str(clean_provenance.get("source") or "unknown"), source=str(clean_provenance.get("source") or "unknown"),
@@ -346,6 +432,130 @@ def _registered_source(
) )
def _catalog_sources() -> tuple[RegisteredDocumentationSource, ...]:
from govoplan_core.core.configuration_packages import validate_configuration_package_catalog
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
return (
*_release_catalog_sources(
"modules",
"Module release catalog",
validate_module_package_catalog(),
),
*_release_catalog_sources(
"configuration_packages",
"Configuration package catalog",
validate_configuration_package_catalog(),
),
)
def _release_catalog_sources(
catalog_type: Literal["modules", "configuration_packages"],
label: str,
validation: Mapping[str, object],
) -> tuple[RegisteredDocumentationSource, ...]:
entry_key = "modules" if catalog_type == "modules" else "packages"
raw_entries = validation.get(entry_key)
entries = [item for item in raw_entries if isinstance(item, Mapping)] if isinstance(raw_entries, list) else []
configured = bool(validation.get("configured"))
valid = bool(validation.get("valid"))
state: Literal["configured", "disabled", "unavailable"] = (
"configured" if configured and valid else "unavailable" if configured else "disabled"
)
reason = _optional_text(validation.get("error"))
if state == "disabled":
reason = "No catalog source is configured."
catalog_id = f"docs.release-catalog.{catalog_type.replace('_', '-')}"
catalog_source = _registered_source(
source_id=catalog_id,
kind="release_catalog",
owner_module_id="docs",
label=label,
provenance={
"source": "core_catalog_contract",
"published_at": validation.get("generated_at"),
},
inspection=ReleaseCatalogInspection(
catalog_type=catalog_type,
channel=_optional_text(validation.get("channel")),
sequence=_optional_int(validation.get("sequence")),
generated_at=_optional_text(validation.get("generated_at")),
entry_count=len(entries),
signed=bool(validation.get("signed")),
trusted=bool(validation.get("trusted")),
cache_used=bool(validation.get("cache_used")),
warnings=_string_list(validation.get("warnings")),
entries=[_release_catalog_entry(item, catalog_type) for item in entries],
),
state=state,
state_reason=reason,
)
if catalog_type == "modules" or not valid:
return (catalog_source,)
return (
catalog_source,
*(
_configuration_package_source(item, validation)
for item in entries
),
)
def _release_catalog_entry(
item: Mapping[str, object],
catalog_type: Literal["modules", "configuration_packages"],
) -> ReleaseCatalogEntryInspection:
item_id = (
_optional_text(item.get("module_id"))
if catalog_type == "modules"
else _optional_text(item.get("package_id"))
)
return ReleaseCatalogEntryInspection(
id=item_id or "unknown",
name=_optional_text(item.get("name")) or item_id or "Unknown",
version=_optional_text(item.get("version")),
description=_optional_text(item.get("description")),
action=_optional_text(item.get("action")),
tags=_string_list(item.get("tags")),
)
def _configuration_package_source(
item: Mapping[str, object],
validation: Mapping[str, object],
) -> RegisteredDocumentationSource:
package_id = _optional_text(item.get("package_id")) or "unknown"
required_modules = [
str(requirement.get("module_id"))
for requirement in item.get("required_modules", ())
if isinstance(requirement, Mapping) and requirement.get("module_id")
] if isinstance(item.get("required_modules"), (list, tuple)) else []
return _registered_source(
source_id=_derived_source_id("docs", "configuration-package", package_id),
kind="configuration_package",
owner_module_id="docs",
label=_optional_text(item.get("name")) or package_id,
provenance={
"source": "configuration_package_catalog",
"version": item.get("version"),
"published_at": validation.get("generated_at"),
},
inspection=ConfigurationPackageInspection(
package_id=package_id,
name=_optional_text(item.get("name")),
version=_optional_text(item.get("version")),
schema_version=_optional_text(item.get("schema_version")),
description=_optional_text(item.get("description")),
publisher=_optional_text(item.get("publisher")),
category=_optional_text(item.get("category")),
tags=_string_list(item.get("tags")),
required_modules=required_modules,
required_capabilities=_string_list(item.get("required_capabilities")),
),
)
def _visibility_payload( def _visibility_payload(
condition: DocumentationCondition, condition: DocumentationCondition,
documentation_types: tuple[DocumentationType, ...], documentation_types: tuple[DocumentationType, ...],
@@ -385,6 +595,22 @@ def _optional_text(value: object) -> str | None:
return text[:2_000] if text else None return text[:2_000] if text else None
def _optional_int(value: object) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _capability_stability(
value: object,
) -> Literal["experimental", "stable", "deprecated"] | None:
text = _optional_text(value)
return text if text in {"experimental", "stable", "deprecated"} else None
def _string_list(value: object) -> list[str]: def _string_list(value: object) -> list[str]:
if not isinstance(value, (list, tuple)): if not isinstance(value, (list, tuple)):
return [] return []
+109 -3
View File
@@ -10,6 +10,7 @@ from fastapi import HTTPException
from govoplan_access.backend.manifest import get_manifest as get_access_manifest from govoplan_access.backend.manifest import get_manifest as get_access_manifest
from govoplan_core.core.modules import DocumentationCondition, DocumentationLink, DocumentationTopic, ModuleManifest, NavItem from govoplan_core.core.modules import DocumentationCondition, DocumentationLink, DocumentationTopic, ModuleManifest, NavItem
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationConfigurationDecision, DocumentationConfigurationDecision,
DocumentationConfigurationProviderRegistration, DocumentationConfigurationProviderRegistration,
DocumentationSourceDefinition, DocumentationSourceDefinition,
@@ -27,7 +28,10 @@ from govoplan_docs.backend.api.v1.routes import (
docs_context, docs_context,
) )
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest
from govoplan_docs.backend.sources import build_documentation_source_registry from govoplan_docs.backend.sources import (
_release_catalog_sources,
build_documentation_source_registry,
)
class FakePrincipal: class FakePrincipal:
@@ -446,6 +450,14 @@ class DocsContextTests(unittest.TestCase):
"example.lookup": lambda _context: object(), "example.lookup": lambda _context: object(),
"policy.example": lambda _context: object(), "policy.example": lambda _context: object(),
}, },
capability_documentation={
"example.lookup": CapabilityDocumentation(
label="Example lookup",
summary="Resolves example records through a stable provider contract.",
contract_version="2",
audience=("module_admin",),
),
},
frontend=FrontendModule( frontend=FrontendModule(
module_id="example", module_id="example",
routes=( routes=(
@@ -490,7 +502,10 @@ class DocsContextTests(unittest.TestCase):
), ),
) )
sources = build_documentation_source_registry((manifest,)).sources() sources = build_documentation_source_registry(
(manifest,),
include_runtime_catalogs=False,
).sources()
self.assertEqual( self.assertEqual(
{source.item.kind for source in sources}, {source.item.kind for source in sources},
@@ -505,6 +520,14 @@ class DocsContextTests(unittest.TestCase):
}, },
) )
self.assertNotIn("must-not-leak", repr(sources)) self.assertNotIn("must-not-leak", repr(sources))
capability_source = next(
source for source in sources
if source.item.kind == "capability"
)
self.assertEqual(capability_source.item.label, "Example lookup")
self.assertEqual(capability_source.item.inspection.summary, "Resolves example records through a stable provider contract.")
self.assertEqual(capability_source.item.inspection.contract_version, "2")
self.assertEqual(capability_source.item.inspection.audience, ["module_admin"])
for source in sources: for source in sources:
self.assertTrue(source.item.id.startswith("example.")) self.assertTrue(source.item.id.startswith("example."))
self.assertEqual("example", source.item.owner_module_id) self.assertEqual("example", source.item.owner_module_id)
@@ -513,6 +536,86 @@ class DocsContextTests(unittest.TestCase):
source.item.inspection_url, source.item.inspection_url,
) )
def test_release_and_configuration_catalogs_become_typed_sources(self) -> None:
release_sources = _release_catalog_sources(
"modules",
"Module release catalog",
{
"configured": True,
"valid": True,
"channel": "stable",
"sequence": 12,
"generated_at": "2026-07-31T10:00:00Z",
"signed": True,
"trusted": True,
"cache_used": False,
"warnings": [],
"modules": [{
"module_id": "files",
"name": "Files",
"version": "0.1.10",
"description": "Managed files.",
"action": "install",
"tags": ["official"],
}],
},
)
self.assertEqual(len(release_sources), 1)
release = release_sources[0].item
self.assertEqual(release.kind, "release_catalog")
self.assertEqual(release.state, "configured")
self.assertEqual(release.inspection.entry_count, 1)
self.assertEqual(release.inspection.entries[0].description, "Managed files.")
configuration_sources = _release_catalog_sources(
"configuration_packages",
"Configuration package catalog",
{
"configured": True,
"valid": True,
"channel": "stable",
"signed": True,
"trusted": True,
"packages": [{
"package_id": "public-service.base",
"name": "Public service base",
"version": "3",
"description": "Baseline configuration for a public service.",
"required_modules": [{"module_id": "access"}],
"required_capabilities": ["policy.retention"],
"tags": ["reference"],
}],
},
)
self.assertEqual(len(configuration_sources), 2)
package = next(
source.item for source in configuration_sources
if source.item.kind == "configuration_package"
)
self.assertEqual(package.inspection.package_id, "public-service.base")
self.assertEqual(package.inspection.required_modules, ["access"])
self.assertEqual(package.inspection.required_capabilities, ["policy.retention"])
def test_docs_manifest_links_repository_and_wiki_sources(self) -> None:
sources = build_documentation_source_registry(
(get_docs_manifest(),),
include_runtime_catalogs=False,
).sources()
source_kinds = {source.item.kind for source in sources}
self.assertIn("repository", source_kinds)
self.assertIn("wiki", source_kinds)
self.assertTrue(any(
source.item.inspection.href.endswith("/govoplan-docs/wiki")
for source in sources
if source.item.kind == "wiki"
))
self.assertTrue(any(
"DOCUMENTATION_LAYER_CONCEPT.md" in source.item.inspection.href
for source in sources
if source.item.kind == "repository"
))
def test_source_visibility_hides_unauthorized_ids(self) -> None: def test_source_visibility_hides_unauthorized_ids(self) -> None:
manifest = ModuleManifest( manifest = ModuleManifest(
id="example", id="example",
@@ -536,7 +639,10 @@ class DocsContextTests(unittest.TestCase):
) )
registry = PlatformRegistry() registry = PlatformRegistry()
registry.register(manifest) registry.register(manifest)
sources = build_documentation_source_registry((manifest,)).sources() sources = build_documentation_source_registry(
(manifest,),
include_runtime_catalogs=False,
).sources()
denied = _visible_documentation_sources( denied = _visible_documentation_sources(
sources, sources,
+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 = { export type DocsDocumentationCondition = {
required_modules: string[]; required_modules: string[];
any_modules: string[]; any_modules: string[];
@@ -186,3 +200,18 @@ export function fetchDocsContext(settings: ApiSettings, options: { documentation
const query = params.toString(); const query = params.toString();
return apiFetch(settings, `/api/v1/docs/context${query ? `?${query}` : ""}`); 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 { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react"; import { ChevronDown, ChevronRight, Eye, RefreshCw } from "lucide-react";
import { import {
Button, Button,
DataGrid, DataGrid,
Dialog,
DismissibleAlert, DismissibleAlert,
ExplorerTree, ExplorerTree,
LoadingFrame, LoadingFrame,
@@ -18,12 +19,14 @@ import {
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
fetchDocsContext, fetchDocsContext,
fetchDocsSource,
type DocsContext, type DocsContext,
type DocsDocumentationTopic, type DocsDocumentationTopic,
type DocsModule, type DocsModule,
type DocsOptionalModuleEvidence, type DocsOptionalModuleEvidence,
type DocsRoute, type DocsRoute,
type DocsSource type DocsSource,
type DocsSourceDetail
} from "../../api/docs"; } from "../../api/docs";
type DocumentationType = "admin" | "user"; type DocumentationType = "admin" | "user";
@@ -189,6 +192,8 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
grantedPermissions={grantedPermissions} grantedPermissions={grantedPermissions}
evidenceModules={context?.layers.evidence.optional_modules ?? []} evidenceModules={context?.layers.evidence.optional_modules ?? []}
evidenceSources={context?.layers.evidence.sources ?? []} evidenceSources={context?.layers.evidence.sources ?? []}
settings={settings}
locale={locale}
/> />
</main> </main>
<PageOutline items={outlineItems} /> <PageOutline items={outlineItems} />
@@ -269,7 +274,9 @@ function SelectedPageContent({
availableRoutes, availableRoutes,
grantedPermissions, grantedPermissions,
evidenceModules, evidenceModules,
evidenceSources evidenceSources,
settings,
locale
}: { }: {
page: DocsPageNode | null; page: DocsPageNode | null;
adminDocs: boolean; adminDocs: boolean;
@@ -281,6 +288,8 @@ function SelectedPageContent({
grantedPermissions: Array<{ scope: string; label: string; category: string }>; grantedPermissions: Array<{ scope: string; label: string; category: string }>;
evidenceModules: DocsOptionalModuleEvidence[]; evidenceModules: DocsOptionalModuleEvidence[];
evidenceSources: DocsSource[]; evidenceSources: DocsSource[];
settings: ApiSettings;
locale: string;
}) { }) {
if (!page) { if (!page) {
return ( return (
@@ -310,7 +319,13 @@ function SelectedPageContent({
<section id="docs-admin-permissions" className="docs-reference-block"> <section id="docs-admin-permissions" className="docs-reference-block">
<h3>i18n:govoplan-docs.granted_permissions.0a232e78</h3> <h3>i18n:govoplan-docs.granted_permissions.0a232e78</h3>
<PermissionList permissions={grantedPermissions} /> <PermissionList permissions={grantedPermissions} />
<EvidenceList modules={evidenceModules} sources={evidenceSources} /> <EvidenceList
modules={evidenceModules}
sources={evidenceSources}
settings={settings}
documentationType={documentationType}
locale={locale}
/>
</section> </section>
</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>; 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 ( return (
<dl className="detail-list"> <>
{modules.map((item) => {sourceError && <DismissibleAlert tone="danger" resetKey={sourceError}>{sourceError}</DismissibleAlert>}
<div key={`${item.source_module_id}-${item.module_id}`}> <dl className="detail-list">
<dt><StatusBadge status={item.status === "installed" ? "success" : "inactive"} label={item.status} /></dt> {modules.map((item) =>
<dd><strong>{item.module_id}</strong><span className="muted"> · {item.reason}</span></dd> <div key={`${item.source_module_id}-${item.module_id}`}>
</div> <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>
{sources.map((item) => </div>
<div key={item.id}> )}
<dt><StatusBadge status={item.state === "configured" ? "success" : item.state === "disabled" ? "inactive" : "warning"} label={item.state} /></dt> {sources.map((item) =>
<dd><strong>{item.label}</strong><span className="muted"> · {item.owner_module_id} · {item.kind} · {item.provenance.source}</span></dd> <div key={item.id}>
</div> <dt><StatusBadge status={item.state === "configured" ? "success" : item.state === "disabled" ? "inactive" : "warning"} label={item.state} /></dt>
)} <dd>
</dl> <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 { function routeRequirements(route: DocsRoute): string {
const parts = []; const parts = [];
if (route.required_all.length) parts.push(`all: ${route.required_all.join(", ")}`); 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.screen.c4878ec4": "Screen",
"i18n:govoplan-docs.section.5e498158": "Section", "i18n:govoplan-docs.section.5e498158": "Section",
"i18n:govoplan-docs.source.6da13add": "Source", "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.status.bae7d5be": "Status",
"i18n:govoplan-docs.steps.6041435e": "Steps", "i18n:govoplan-docs.steps.6041435e": "Steps",
"i18n:govoplan-docs.summary.d6b9936d": "Summary", "i18n:govoplan-docs.summary.d6b9936d": "Summary",
@@ -165,6 +171,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-docs.screen.c4878ec4": "Ansicht", "i18n:govoplan-docs.screen.c4878ec4": "Ansicht",
"i18n:govoplan-docs.section.5e498158": "Bereich", "i18n:govoplan-docs.section.5e498158": "Bereich",
"i18n:govoplan-docs.source.6da13add": "Quelle", "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.status.bae7d5be": "Status",
"i18n:govoplan-docs.steps.6041435e": "Schritte", "i18n:govoplan-docs.steps.6041435e": "Schritte",
"i18n:govoplan-docs.summary.d6b9936d": "Zusammenfassung", "i18n:govoplan-docs.summary.d6b9936d": "Zusammenfassung",