feat: expose capability and catalog documentation
This commit is contained in:
@@ -55,6 +55,30 @@ Frontend package:
|
||||
|
||||
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
|
||||
|
||||
- `docs/DOCUMENTATION_LAYER_CONCEPT.md` defines the configured, available, and evidence documentation model.
|
||||
|
||||
@@ -195,23 +195,6 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
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(
|
||||
id="docs.project.wiki",
|
||||
kind="wiki",
|
||||
|
||||
@@ -58,18 +58,58 @@ class RouteInspection(_SourceModel):
|
||||
class CapabilityInspection(_SourceModel):
|
||||
kind: Literal["capability"] = "capability"
|
||||
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):
|
||||
kind: Literal["policy"] = "policy"
|
||||
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):
|
||||
kind: Literal["configuration_package"] = "configuration_package"
|
||||
package_id: str
|
||||
name: str | None = None
|
||||
version: str | None = None
|
||||
schema_version: 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):
|
||||
@@ -90,6 +130,7 @@ DocumentationSourceInspection = Annotated[
|
||||
| RouteInspection
|
||||
| CapabilityInspection
|
||||
| PolicyInspection
|
||||
| ReleaseCatalogInspection
|
||||
| ConfigurationPackageInspection
|
||||
| WikiInspection
|
||||
| RepositoryInspection,
|
||||
@@ -142,6 +183,8 @@ class DocumentationSourceRegistry:
|
||||
|
||||
def build_documentation_source_registry(
|
||||
manifests: tuple[ModuleManifest, ...],
|
||||
*,
|
||||
include_runtime_catalogs: bool = True,
|
||||
) -> DocumentationSourceRegistry:
|
||||
registry = DocumentationSourceRegistry()
|
||||
for manifest in manifests:
|
||||
@@ -155,6 +198,9 @@ def build_documentation_source_registry(
|
||||
for source in _linked_sources(manifest):
|
||||
if registry.get(source.item.id) is None:
|
||||
registry.register(source)
|
||||
if include_runtime_catalogs:
|
||||
for source in _catalog_sources():
|
||||
registry.register(source)
|
||||
return registry
|
||||
|
||||
|
||||
@@ -203,21 +249,37 @@ def _route_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSou
|
||||
def _capability_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]:
|
||||
sources: list[RegisteredDocumentationSource] = []
|
||||
for capability in sorted(manifest.capability_factories):
|
||||
metadata = manifest.capability_documentation.get(capability)
|
||||
is_policy = capability.startswith("policy.")
|
||||
kind = "policy" if is_policy else "capability"
|
||||
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
|
||||
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(
|
||||
source_id=_derived_source_id(manifest.id, kind, capability),
|
||||
kind=kind,
|
||||
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},
|
||||
inspection=inspection,
|
||||
condition=DocumentationCondition(required_modules=(manifest.id,)),
|
||||
documentation_types=metadata.documentation_types if metadata else ("admin",),
|
||||
))
|
||||
return tuple(sources)
|
||||
|
||||
@@ -240,6 +302,7 @@ def _defined_source(
|
||||
condition=definition.condition,
|
||||
documentation_types=definition.documentation_types,
|
||||
state=definition.state,
|
||||
state_reason=definition.state_reason,
|
||||
configuration_key=definition.configuration_key,
|
||||
)
|
||||
|
||||
@@ -249,8 +312,15 @@ def _defined_inspection(definition: DocumentationSourceDefinition) -> Documentat
|
||||
if definition.kind == "configuration_package":
|
||||
return ConfigurationPackageInspection(
|
||||
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")),
|
||||
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 "")
|
||||
if definition.kind == "wiki":
|
||||
@@ -271,9 +341,23 @@ def _defined_inspection(definition: DocumentationSourceDefinition) -> Documentat
|
||||
order=int(safe.get("order") or 0),
|
||||
)
|
||||
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":
|
||||
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(
|
||||
module_id=str(safe.get("module_id") or definition.id.split(".", 1)[0]),
|
||||
name=str(safe.get("name") or definition.label),
|
||||
@@ -319,6 +403,7 @@ def _registered_source(
|
||||
condition: DocumentationCondition | None = None,
|
||||
documentation_types: tuple[DocumentationType, ...] = ("admin",),
|
||||
state: Literal["configured", "disabled", "unavailable"] = "configured",
|
||||
state_reason: str | None = None,
|
||||
configuration_key: str | None = None,
|
||||
) -> RegisteredDocumentationSource:
|
||||
clean_provenance = _safe_source_fields(provenance)
|
||||
@@ -329,6 +414,7 @@ def _registered_source(
|
||||
owner_module_id=owner_module_id,
|
||||
label=label,
|
||||
state=state,
|
||||
state_reason=state_reason,
|
||||
inspection_url=f"/api/v1/docs/sources/{source_id}",
|
||||
provenance=DocumentationSourceProvenance(
|
||||
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(
|
||||
condition: DocumentationCondition,
|
||||
documentation_types: tuple[DocumentationType, ...],
|
||||
@@ -385,6 +595,22 @@ def _optional_text(value: object) -> str | 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]:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
|
||||
+109
-3
@@ -10,6 +10,7 @@ from fastapi import HTTPException
|
||||
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 (
|
||||
CapabilityDocumentation,
|
||||
DocumentationConfigurationDecision,
|
||||
DocumentationConfigurationProviderRegistration,
|
||||
DocumentationSourceDefinition,
|
||||
@@ -27,7 +28,10 @@ from govoplan_docs.backend.api.v1.routes import (
|
||||
docs_context,
|
||||
)
|
||||
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:
|
||||
@@ -446,6 +450,14 @@ class DocsContextTests(unittest.TestCase):
|
||||
"example.lookup": 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(
|
||||
module_id="example",
|
||||
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(
|
||||
{source.item.kind for source in sources},
|
||||
@@ -505,6 +520,14 @@ class DocsContextTests(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
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:
|
||||
self.assertTrue(source.item.id.startswith("example."))
|
||||
self.assertEqual("example", source.item.owner_module_id)
|
||||
@@ -513,6 +536,86 @@ class DocsContextTests(unittest.TestCase):
|
||||
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:
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
@@ -536,7 +639,10 @@ class DocsContextTests(unittest.TestCase):
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
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(
|
||||
sources,
|
||||
|
||||
@@ -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