Expose searchable module catalog compatibility

This commit is contained in:
2026-08-06 22:42:13 +02:00
parent 1e55a80d3c
commit 9d522ef6e0
8 changed files with 501 additions and 20 deletions
+11 -6
View File
@@ -54,12 +54,17 @@ admin UI records operator intent and queues or renders commands for the trusted
installer process described in installer process described in
`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`. `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`.
Catalog entries show whether the package is already installed and whether the Catalog entries can be searched by module, package, repository, or tag and
directory advertises another version. Selecting an entry preserves its signed filtered by availability, installed state, update state, and blockers. Each row
registry URLs and integrity evidence in the reviewed plan. The installer, not shows its signed source revision and immutable artifact digest together with
the browser or API request, downloads and verifies those artifacts. On a shared configuration requirements and release notes when the publisher supplies them.
or Kubernetes deployment, the same plan requires a new immutable image Withdrawn entries and targets with missing dependencies, incompatible named
composition instead of changing one running replica. interfaces, or an unsupported current-version window cannot be added to a plan.
Selecting an eligible entry preserves its signed registry URLs and integrity
evidence in the reviewed plan. The installer, not the browser or API request,
downloads and verifies those artifacts. On a shared or Kubernetes deployment,
the same plan requires a new immutable image composition instead of changing
one running replica.
The WebUI presents the lifecycle as five derived stages: plan, preflight, The WebUI presents the lifecycle as five derived stages: plan, preflight,
installer request, daemon execution, and run evidence. The projection resets installer request, daemon execution, and run evidence. The projection resets
+169 -7
View File
@@ -84,6 +84,7 @@ from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, Maintenance
from govoplan_core.core.lifecycle import ModuleLifecycleManager from govoplan_core.core.lifecycle import ModuleLifecycleManager
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import get_registry from govoplan_core.core.runtime import get_registry
from govoplan_core.core.versioning import compare_versions, format_version_range, version_satisfies_range
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_core.i18n import ( from govoplan_core.i18n import (
REFERENCE_LANGUAGE_CODE, REFERENCE_LANGUAGE_CODE,
@@ -635,6 +636,12 @@ def _catalog_install_or_update_item(result: dict[str, object], module_id: str) -
continue continue
if raw_item.get("module_id") != module_id or raw_item.get("action") not in {"install", "update"}: if raw_item.get("module_id") != module_id or raw_item.get("action") not in {"install", "update"}:
continue continue
if raw_item.get("availability") == "withdrawn":
reason = str(raw_item.get("availability_reason") or "Catalog release has been withdrawn.")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Catalog entry {module_id} cannot be installed: {reason}",
)
return raw_item return raw_item
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install/update entry not found: {module_id}") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install/update entry not found: {module_id}")
@@ -709,18 +716,21 @@ def _catalog_license_features(raw_item: dict[str, object]) -> list[str]:
def _module_package_catalog_item( def _module_package_catalog_item(
raw_item: dict[str, object], raw_item: dict[str, object],
available: Mapping[str, Any], available: Mapping[str, Any],
catalog_items: list[dict[str, object]] | None = None,
) -> ModulePackageCatalogItem: ) -> ModulePackageCatalogItem:
payload = dict(raw_item) payload = dict(raw_item)
manifest = available.get(str(raw_item.get("module_id") or "")) module_id = str(raw_item.get("module_id") or "")
manifest = available.get(module_id)
installed_version = str(getattr(manifest, "version", "") or "") or None installed_version = str(getattr(manifest, "version", "") or "") or None
target_version = raw_item.get("version") target_version = raw_item.get("version")
version_comparison = (
compare_versions(target_version, installed_version)
if installed_version and isinstance(target_version, str)
else None
)
payload["installed"] = manifest is not None payload["installed"] = manifest is not None
payload["installed_version"] = installed_version payload["installed_version"] = installed_version
payload["update_available"] = bool( payload["update_available"] = version_comparison is not None and version_comparison > 0
installed_version
and isinstance(target_version, str)
and target_version != installed_version
)
decision = module_license_decision(_catalog_license_features(raw_item)) decision = module_license_decision(_catalog_license_features(raw_item))
payload["license_allowed"] = bool(decision.get("allowed")) payload["license_allowed"] = bool(decision.get("allowed"))
payload["license_enforced"] = bool(decision.get("enforced")) payload["license_enforced"] = bool(decision.get("enforced"))
@@ -728,9 +738,157 @@ def _module_package_catalog_item(
payload["license_missing_features"] = [str(item) for item in missing] if isinstance(missing, list) else [] payload["license_missing_features"] = [str(item) for item in missing] if isinstance(missing, list) else []
reason = decision.get("reason") reason = decision.get("reason")
payload["license_reason"] = reason if isinstance(reason, str) else None payload["license_reason"] = reason if isinstance(reason, str) else None
compatibility_reasons = _catalog_compatibility_reasons(
raw_item,
available=available,
catalog_items=catalog_items or [raw_item],
)
if not bool(decision.get("allowed")):
compatibility_reasons.append(
str(reason or f"The active license policy does not allow {module_id}.")
)
if raw_item.get("action") not in {"install", "update"}:
compatibility_reasons.append("This catalog action cannot be added to an install plan.")
compatibility_reasons = list(dict.fromkeys(compatibility_reasons))
payload["compatible"] = not compatibility_reasons
payload["plan_allowed"] = bool(
not compatibility_reasons
and raw_item.get("action") in {"install", "update"}
and (
manifest is None
or (version_comparison is not None and version_comparison > 0)
or (version_comparison is not None and version_comparison < 0 and raw_item.get("allow_downgrade") is True)
or (version_comparison == 0 and raw_item.get("allow_same_version") is True)
)
)
payload["compatibility_reasons"] = compatibility_reasons
if raw_item.get("availability") == "withdrawn":
payload["catalog_state"] = "withdrawn"
elif compatibility_reasons:
payload["catalog_state"] = "blocked"
elif payload["update_available"]:
payload["catalog_state"] = "update_available"
elif payload["installed"]:
payload["catalog_state"] = "installed"
else:
payload["catalog_state"] = "available"
return ModulePackageCatalogItem.model_validate(payload) return ModulePackageCatalogItem.model_validate(payload)
def _catalog_compatibility_reasons(
raw_item: dict[str, object],
*,
available: Mapping[str, Any],
catalog_items: list[dict[str, object]],
) -> list[str]:
module_id = str(raw_item.get("module_id") or "")
reasons: list[str] = []
if raw_item.get("availability") == "withdrawn":
reasons.append(
str(raw_item.get("availability_reason") or "This catalog release has been withdrawn.")
)
manifest = available.get(module_id)
installed_version = str(getattr(manifest, "version", "") or "") or None
version_min = _catalog_string(raw_item, "current_version_min")
version_max = _catalog_string(raw_item, "current_version_max_exclusive")
if installed_version and not version_satisfies_range(
installed_version,
version_min=version_min,
version_max_exclusive=version_max,
):
reasons.append(
f"Installed version {installed_version} is outside the supported update window "
f"({format_version_range(version_min=version_min, version_max_exclusive=version_max)})."
)
target_version = _catalog_string(raw_item, "version")
if (
installed_version
and target_version
and compare_versions(target_version, installed_version) < 0
and raw_item.get("allow_downgrade") is not True
):
reasons.append(
f"Catalog version {target_version} is older than installed version {installed_version}; "
"the release does not allow a downgrade."
)
catalog_module_ids = {
str(item.get("module_id") or "")
for item in catalog_items
if item.get("availability") != "withdrawn"
}
for dependency in _catalog_string_values(raw_item.get("dependencies")):
if dependency not in available and dependency not in catalog_module_ids:
reasons.append(f"Required module {dependency} is neither installed nor available in this catalog.")
providers = _catalog_interface_providers(catalog_items, available=available)
requirements = raw_item.get("requires_interfaces")
if isinstance(requirements, list):
for requirement in requirements:
if not isinstance(requirement, Mapping) or requirement.get("optional") is True:
continue
interface_name = str(requirement.get("name") or "").strip()
if not interface_name:
continue
required_min = _catalog_mapping_string(requirement, "version_min")
required_max = _catalog_mapping_string(requirement, "version_max_exclusive")
if any(
version_satisfies_range(
provider_version,
version_min=required_min,
version_max_exclusive=required_max,
)
for provider_version in providers.get(interface_name, ())
):
continue
reasons.append(
f"Required interface {interface_name} "
f"({format_version_range(version_min=required_min, version_max_exclusive=required_max)}) "
"has no compatible installed or catalog provider."
)
return reasons
def _catalog_interface_providers(
catalog_items: list[dict[str, object]],
*,
available: Mapping[str, Any],
) -> dict[str, set[str]]:
providers: dict[str, set[str]] = {}
for item in catalog_items:
if item.get("availability") == "withdrawn":
continue
declared = item.get("provides_interfaces")
if not isinstance(declared, list):
continue
for provider in declared:
if not isinstance(provider, Mapping):
continue
name = str(provider.get("name") or "").strip()
version = str(provider.get("version") or "").strip()
if name and version:
providers.setdefault(name, set()).add(version)
for manifest in available.values():
for provider in getattr(manifest, "provides_interfaces", ()):
name = str(getattr(provider, "name", "") or "").strip()
version = str(getattr(provider, "version", "") or "").strip()
if name and version:
providers.setdefault(name, set()).add(version)
return providers
def _catalog_string_values(value: object) -> list[str]:
if not isinstance(value, list):
return []
return [str(item).strip() for item in value if str(item).strip()]
def _catalog_mapping_string(value: Mapping[str, object], field: str) -> str | None:
raw = value.get(field)
return str(raw).strip() if isinstance(raw, str) and raw.strip() else None
def _catalog_required_license_features(result: dict[str, object]) -> list[str]: def _catalog_required_license_features(result: dict[str, object]) -> list[str]:
required: list[str] = [] required: list[str] = []
for raw_item in result.get("modules", []): for raw_item in result.get("modules", []):
@@ -1260,9 +1418,13 @@ def read_module_package_catalog(
if isinstance(lifecycle, ModuleLifecycleManager) if isinstance(lifecycle, ModuleLifecycleManager)
else available_module_manifests(ignore_load_errors=True) else available_module_manifests(ignore_load_errors=True)
) )
catalog_items = [item for item in result["modules"] if isinstance(item, dict)]
license_diagnostics = module_license_diagnostics(required_features=_catalog_required_license_features(result)) license_diagnostics = module_license_diagnostics(required_features=_catalog_required_license_features(result))
return ModulePackageCatalogResponse( return ModulePackageCatalogResponse(
modules=[_module_package_catalog_item(item, available) for item in result["modules"] if isinstance(item, dict)], modules=[
_module_package_catalog_item(item, available, catalog_items)
for item in catalog_items
],
configured=bool(result["configured"]), configured=bool(result["configured"]),
valid=bool(result["valid"]), valid=bool(result["valid"]),
path=result["path"] if isinstance(result["path"], str) else None, path=result["path"] if isinstance(result["path"], str) else None,
@@ -387,6 +387,30 @@ class ModuleInterfaceRequirementItem(BaseModel):
optional: bool = False optional: bool = False
class ModulePackageCatalogSource(BaseModel):
repository: str
tag: str
commit: str
repository_url: str | None = None
revision_url: str | None = None
class ModulePackageArtifactIdentity(BaseModel):
ref: str | None = None
path: str | None = None
artifact_path: str | None = None
url: str | None = None
filename: str | None = None
sha256: str | None = None
size: int | None = None
integrity: str | None = None
sbom_url: str | None = None
provenance_url: str | None = None
registry_identity: str | None = None
git_ref: str | None = None
source_commit: str | None = None
class ModulePackageCatalogItem(BaseModel): class ModulePackageCatalogItem(BaseModel):
module_id: str module_id: str
name: str name: str
@@ -396,6 +420,16 @@ class ModulePackageCatalogItem(BaseModel):
installed: bool = False installed: bool = False
installed_version: str | None = None installed_version: str | None = None
update_available: bool = False update_available: bool = False
availability: Literal["available", "withdrawn"] = "available"
availability_reason: str | None = None
configuration_requirements: list[str] = Field(default_factory=list)
release_notes_url: str | None = None
source: ModulePackageCatalogSource | None = None
artifact_integrity: dict[str, ModulePackageArtifactIdentity] = Field(default_factory=dict)
compatible: bool = True
plan_allowed: bool = True
compatibility_reasons: list[str] = Field(default_factory=list)
catalog_state: Literal["available", "installed", "update_available", "blocked", "withdrawn"] = "available"
dependencies: list[str] = Field(default_factory=list) dependencies: list[str] = Field(default_factory=list)
optional_dependencies: list[str] = Field(default_factory=list) optional_dependencies: list[str] = Field(default_factory=list)
migration_safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic" migration_safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic"
+1 -1
View File
@@ -147,7 +147,7 @@ manifest = ModuleManifest(
summary="Move a reviewed module package plan through preflight, maintenance-gated queueing, daemon execution, and durable run evidence.", summary="Move a reviewed module package plan through preflight, maintenance-gated queueing, daemon execution, and durable run evidence.",
body=( body=(
"The Modules administration surface projects one operator workflow: save a package plan, resolve preflight findings, enter maintenance mode with the required authority, queue a supervised installer request, and inspect the matching run record. " "The Modules administration surface projects one operator workflow: save a package plan, resolve preflight findings, enter maintenance mode with the required authority, queue a supervised installer request, and inspect the matching run record. "
"When no deployment-specific catalog is configured, the package directory discovers the signed public GovOPlaN stable catalog and marks installed packages and available updates. Selecting an entry copies its exact signed registry identities into the plan; artifact download and digest verification happen only in the trusted installer. " "When no deployment-specific catalog is configured, the package directory discovers the signed public GovOPlaN stable catalog. Operators can search and filter available, installed, update, blocked, and withdrawn entries; each row exposes the signed source revision, artifact digest, release notes, and configuration requirements supplied by the catalog. Missing dependencies, incompatible named interfaces, unsupported update windows, and withdrawn releases block plan creation. Selecting an eligible entry copies its exact signed registry identities into the plan; artifact download and digest verification happen only in the trusted installer. "
"The stage indicator is derived from the saved plan timestamp, the latest matching request, and its run; an older request is never presented as evidence for a newer plan. " "The stage indicator is derived from the saved plan timestamp, the latest matching request, and its run; an older request is never presented as evidence for a newer plan. "
"Disabled queue actions name the earliest blocker, the person who can resolve it, and the plan surface where work continues. Package mutation remains outside the FastAPI process and recovery evidence remains durable in the installer ledger. Shared deployments require an immutable image rollout rather than node-local mutation. Tenant entitlement and user/View visibility remain policy settings, not package lifecycle operations." "Disabled queue actions name the earliest blocker, the person who can resolve it, and the plan surface where work continues. Package mutation remains outside the FastAPI process and recovery evidence remains durable in the installer ledger. Shared deployments require an immutable image rollout rather than node-local mutation. Tenant entitlement and user/View visibility remain policy settings, not package lifecycle operations."
), ),
+138 -1
View File
@@ -5,8 +5,12 @@ from unittest.mock import patch
from fastapi import HTTPException from fastapi import HTTPException
from govoplan_admin.backend.api.v1.routes import _catalog_plan_item from govoplan_admin.backend.api.v1.routes import (
_catalog_plan_item,
_module_package_catalog_item,
)
from govoplan_admin.backend.api.v1.schemas import SystemSettingsItem from govoplan_admin.backend.api.v1.schemas import SystemSettingsItem
from govoplan_core.core.modules import ModuleInterfaceProvider, ModuleManifest
class CatalogPlanItemTests(unittest.TestCase): class CatalogPlanItemTests(unittest.TestCase):
@@ -114,6 +118,139 @@ class CatalogPlanItemTests(unittest.TestCase):
self.assertEqual(missing.exception.status_code, 404) self.assertEqual(missing.exception.status_code, 404)
self.assertEqual(missing.exception.detail, "Catalog install/update entry not found: calendar") self.assertEqual(missing.exception.detail, "Catalog install/update entry not found: calendar")
with self.subTest("withdrawn entry"), self.assertRaises(HTTPException) as withdrawn:
_catalog_plan_item(
"calendar",
set(),
validation={
"valid": True,
"modules": [{
"module_id": "calendar",
"action": "install",
"availability": "withdrawn",
"availability_reason": "Superseded after a security review.",
}],
},
)
self.assertEqual(withdrawn.exception.status_code, 409)
self.assertIn("security review", withdrawn.exception.detail)
def test_catalog_item_reports_dependency_interface_and_update_compatibility(self) -> None:
raw_item: dict[str, object] = {
"module_id": "calendar",
"name": "Calendar",
"version": "0.2.0",
"action": "install",
"dependencies": ["access"],
"current_version_min": "0.1.5",
"current_version_max_exclusive": "0.2.0",
"requires_interfaces": [{
"name": "files.storage",
"version_min": "2.0.0",
"optional": False,
}],
}
available = {
"calendar": ModuleManifest(id="calendar", name="Calendar", version="0.1.4"),
"files": ModuleManifest(
id="files",
name="Files",
version="0.1.0",
provides_interfaces=(ModuleInterfaceProvider(name="files.storage", version="1.0.0"),),
),
}
with patch(
"govoplan_admin.backend.api.v1.routes.module_license_decision",
return_value={"allowed": True, "enforced": False, "missing_features": []},
):
item = _module_package_catalog_item(raw_item, available, [raw_item])
self.assertFalse(item.compatible)
self.assertFalse(item.plan_allowed)
self.assertEqual("blocked", item.catalog_state)
self.assertTrue(item.update_available)
self.assertEqual(3, len(item.compatibility_reasons))
self.assertTrue(any("update window" in reason for reason in item.compatibility_reasons))
self.assertTrue(any("Required module access" in reason for reason in item.compatibility_reasons))
self.assertTrue(any("Required interface files.storage" in reason for reason in item.compatibility_reasons))
def test_catalog_item_accepts_dependencies_and_interfaces_from_same_catalog(self) -> None:
raw_item: dict[str, object] = {
"module_id": "calendar",
"name": "Calendar",
"version": "0.2.0",
"action": "install",
"dependencies": ["access"],
"requires_interfaces": [{
"name": "access.directory",
"version_min": "1.0.0",
"optional": False,
}],
}
provider: dict[str, object] = {
"module_id": "access",
"availability": "available",
"provides_interfaces": [{"name": "access.directory", "version": "1.1.0"}],
}
with patch(
"govoplan_admin.backend.api.v1.routes.module_license_decision",
return_value={"allowed": True, "enforced": False, "missing_features": []},
):
item = _module_package_catalog_item(raw_item, {}, [raw_item, provider])
self.assertTrue(item.compatible)
self.assertTrue(item.plan_allowed)
self.assertEqual("available", item.catalog_state)
self.assertEqual([], item.compatibility_reasons)
def test_catalog_item_distinguishes_current_release_and_downgrade(self) -> None:
available = {
"calendar": ModuleManifest(id="calendar", name="Calendar", version="0.2.0"),
}
license_decision = {"allowed": True, "enforced": False, "missing_features": []}
with patch(
"govoplan_admin.backend.api.v1.routes.module_license_decision",
return_value=license_decision,
):
current = _module_package_catalog_item({
"module_id": "calendar",
"name": "Calendar",
"version": "0.2.0",
"action": "install",
}, available)
refresh = _module_package_catalog_item({
"module_id": "calendar",
"name": "Calendar",
"version": "0.2.0",
"action": "install",
"allow_same_version": True,
}, available)
downgrade = _module_package_catalog_item({
"module_id": "calendar",
"name": "Calendar",
"version": "0.1.9",
"action": "install",
}, available)
allowed_downgrade = _module_package_catalog_item({
"module_id": "calendar",
"name": "Calendar",
"version": "0.1.9",
"action": "install",
"allow_downgrade": True,
}, available)
self.assertTrue(current.compatible)
self.assertFalse(current.plan_allowed)
self.assertFalse(current.update_available)
self.assertEqual("installed", current.catalog_state)
self.assertTrue(refresh.plan_allowed)
self.assertFalse(downgrade.compatible)
self.assertFalse(downgrade.plan_allowed)
self.assertTrue(any("does not allow a downgrade" in reason for reason in downgrade.compatibility_reasons))
self.assertTrue(allowed_downgrade.compatible)
self.assertTrue(allowed_downgrade.plan_allowed)
self.assertFalse(allowed_downgrade.update_available)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+34
View File
@@ -419,6 +419,30 @@ export type ModuleInterfaceRequirementItem = {
optional: boolean; optional: boolean;
}; };
export type ModulePackageCatalogSource = {
repository: string;
tag: string;
commit: string;
repository_url?: string | null;
revision_url?: string | null;
};
export type ModulePackageArtifactIdentity = {
ref?: string | null;
path?: string | null;
artifact_path?: string | null;
url?: string | null;
filename?: string | null;
sha256?: string | null;
size?: number | null;
integrity?: string | null;
sbom_url?: string | null;
provenance_url?: string | null;
registry_identity?: string | null;
git_ref?: string | null;
source_commit?: string | null;
};
export type ModulePackageCatalogItem = { export type ModulePackageCatalogItem = {
module_id: string; module_id: string;
name: string; name: string;
@@ -428,6 +452,16 @@ export type ModulePackageCatalogItem = {
installed: boolean; installed: boolean;
installed_version?: string | null; installed_version?: string | null;
update_available: boolean; update_available: boolean;
availability: "available" | "withdrawn";
availability_reason?: string | null;
configuration_requirements: string[];
release_notes_url?: string | null;
source?: ModulePackageCatalogSource | null;
artifact_integrity: Record<string, ModulePackageArtifactIdentity>;
compatible: boolean;
plan_allowed: boolean;
compatibility_reasons: string[];
catalog_state: "available" | "installed" | "update_available" | "blocked" | "withdrawn";
dependencies: string[]; dependencies: string[];
optional_dependencies: string[]; optional_dependencies: string[];
migration_safety: "automatic" | "requires_review" | "forward_only" | "destructive"; migration_safety: "automatic" | "requires_review" | "forward_only" | "destructive";
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui"; import type { ApiSettings } from "@govoplan/core-webui";
import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, Button, ConfirmDialog, dispatchPlatformModulesChanged, DocumentationHelpLink, formatDateTime, MetricCard, StageRail, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type FormatDateTimeOptions } from "@govoplan/core-webui"; import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, Button, ConfirmDialog, dispatchPlatformModulesChanged, DocumentationHelpLink, formatDateTime, MetricCard, SegmentedControl, StageRail, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type FormatDateTimeOptions } from "@govoplan/core-webui";
import { Check, Clock, FileText, Pencil, Send } from "lucide-react"; import { Check, Clock, FileText, Pencil, Send } from "lucide-react";
import { import {
cancelModuleInstallerRequest, cancelModuleInstallerRequest,
@@ -52,6 +52,8 @@ const MODULE_INSTALLER_I18N = {
resolutionTarget: "i18n:govoplan-admin.where_to_go.f1a20205" resolutionTarget: "i18n:govoplan-admin.where_to_go.f1a20205"
} as const; } as const;
type CatalogModuleFilter = "all" | "available" | "updates" | "installed" | "blocked";
export default function ModuleManagementPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) { export default function ModuleManagementPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) {
const [catalog, setCatalog] = useState<ModuleCatalogResponse | null>(null); const [catalog, setCatalog] = useState<ModuleCatalogResponse | null>(null);
const [installPlan, setInstallPlan] = useState<ModuleInstallPlanResponse | null>(null); const [installPlan, setInstallPlan] = useState<ModuleInstallPlanResponse | null>(null);
@@ -71,6 +73,8 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
const [confirmClearPlan, setConfirmClearPlan] = useState(false); const [confirmClearPlan, setConfirmClearPlan] = useState(false);
const [confirmMaintenance, setConfirmMaintenance] = useState(false); const [confirmMaintenance, setConfirmMaintenance] = useState(false);
const [cancelRequestId, setCancelRequestId] = useState(""); const [cancelRequestId, setCancelRequestId] = useState("");
const [catalogSearch, setCatalogSearch] = useState("");
const [catalogFilter, setCatalogFilter] = useState<CatalogModuleFilter>("all");
async function load() { async function load() {
setLoading(true); setLoading(true);
@@ -108,6 +112,28 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
const planDirty = Boolean(installPlan && JSON.stringify(normalizePlanItems(draftPlanItems)) !== JSON.stringify(normalizePlanItems(installPlan.items))); const planDirty = Boolean(installPlan && JSON.stringify(normalizePlanItems(draftPlanItems)) !== JSON.stringify(normalizePlanItems(installPlan.items)));
const planValid = planValidationError(draftPlanItems) === ""; const planValid = planValidationError(draftPlanItems) === "";
const latestInstallerRequest = installerRequests?.requests[0] ?? null; const latestInstallerRequest = installerRequests?.requests[0] ?? null;
const visibleCatalogModules = useMemo(() => {
const query = catalogSearch.trim().toLocaleLowerCase();
return (packageCatalog?.modules ?? []).filter((item) => {
const matchesFilter =
catalogFilter === "all" ||
(catalogFilter === "available" && item.catalog_state === "available") ||
(catalogFilter === "updates" && item.update_available) ||
(catalogFilter === "installed" && item.installed) ||
(catalogFilter === "blocked" && ["blocked", "withdrawn"].includes(item.catalog_state));
if (!matchesFilter) return false;
if (!query) return true;
return [
item.name,
item.module_id,
item.description ?? "",
item.python_package ?? "",
item.webui_package ?? "",
item.source?.repository ?? "",
...item.tags,
].join(" ").toLocaleLowerCase().includes(query);
});
}, [catalogFilter, catalogSearch, packageCatalog]);
const currentInstallerRequest = latestInstallerRequest && installerRequestMatchesPlan( const currentInstallerRequest = latestInstallerRequest && installerRequestMatchesPlan(
installPlan?.updated_at, installPlan?.updated_at,
latestInstallerRequest.created_at latestInstallerRequest.created_at
@@ -494,15 +520,39 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
{packageCatalog.warnings.map((warning) => <p key={warning} className="alert warning">{warning}</p>)} {packageCatalog.warnings.map((warning) => <p key={warning} className="alert warning">{warning}</p>)}
{!packageCatalog.configured && <div className="module-install-plan-empty">i18n:govoplan-admin.no_package_catalog_configured_set_govoplan_modul.2c5fceb3</div>} {!packageCatalog.configured && <div className="module-install-plan-empty">i18n:govoplan-admin.no_package_catalog_configured_set_govoplan_modul.2c5fceb3</div>}
{packageCatalog.configured && packageCatalog.modules.length === 0 && !packageCatalog.error && <div className="module-install-plan-empty">i18n:govoplan-admin.package_catalog_is_configured_but_contains_no_en.b6b053cb</div>} {packageCatalog.configured && packageCatalog.modules.length === 0 && !packageCatalog.error && <div className="module-install-plan-empty">i18n:govoplan-admin.package_catalog_is_configured_but_contains_no_en.b6b053cb</div>}
{packageCatalog.modules.length > 0 && <div className="module-package-catalog-toolbar">
<label className="module-package-catalog-search">
<span>i18n:govoplan-admin.search_module_directory.7c8f1001</span>
<input
type="search"
value={catalogSearch}
onChange={(event) => setCatalogSearch(event.target.value)}
placeholder="i18n:govoplan-admin.module_name_package_or_tag.7c8f1002" />
</label>
<SegmentedControl<CatalogModuleFilter>
ariaLabel="i18n:govoplan-admin.filter_module_directory.7c8f1003"
value={catalogFilter}
onChange={setCatalogFilter}
options={[
{ id: "all", label: "i18n:govoplan-admin.all_catalog_entries.7c8f1004" },
{ id: "available", label: "i18n:govoplan-admin.available.7c8f1005" },
{ id: "updates", label: "i18n:govoplan-admin.updates.7c8f1006" },
{ id: "installed", label: "i18n:govoplan-admin.installed.7bb4405c" },
{ id: "blocked", label: "i18n:govoplan-admin.blocked.7c8f1007" },
]} />
</div>}
{packageCatalog.modules.length > 0 && visibleCatalogModules.length === 0 && <div className="module-install-plan-empty">i18n:govoplan-admin.no_catalog_entries_match.7c8f1008</div>}
{packageCatalog.modules.length > 0 && <div className="module-package-catalog-list"> {packageCatalog.modules.length > 0 && <div className="module-package-catalog-list">
{packageCatalog.modules.map((item) => {visibleCatalogModules.map((item) =>
<div key={`${item.module_id}-${item.version ?? "latest"}`} className="module-package-catalog-row"> <div key={`${item.module_id}-${item.version ?? "latest"}`} className="module-package-catalog-row">
<div className="module-management-main"> <div className="module-management-main">
<div className="module-management-title"> <div className="module-management-title">
<strong>{item.name}</strong> <strong>{item.name}</strong>
<code>{item.module_id}</code> <code>{item.module_id}</code>
{item.version && <span>v{item.version}</span>} {item.version && <span>v{item.version}</span>}
{item.installed && <StatusBadge status={item.update_available ? "warning" : "success"} label={item.update_available ? "i18n:govoplan-admin.plan_update.86e6857a" : "i18n:govoplan-admin.installed.7bb4405c"} />} <StatusBadge status={catalogStateTone(item)} label={catalogStateLabel(item)} />
</div> </div>
<div className="module-management-details"> <div className="module-management-details">
{item.python_package && <span>{item.python_package}</span>} {item.python_package && <span>{item.python_package}</span>}
@@ -520,13 +570,24 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
{item.provides_interfaces.length > 0 && <span>i18n:govoplan-admin.provides.221b70d9 {providedInterfacesLabel(item)}</span>} {item.provides_interfaces.length > 0 && <span>i18n:govoplan-admin.provides.221b70d9 {providedInterfacesLabel(item)}</span>}
{item.requires_interfaces.length > 0 && <span>i18n:govoplan-admin.requires.a4fc9357 {requiredInterfacesLabel(item)}</span>} {item.requires_interfaces.length > 0 && <span>i18n:govoplan-admin.requires.a4fc9357 {requiredInterfacesLabel(item)}</span>}
</div> </div>
{item.source && <div className="module-package-catalog-provenance">
<span>i18n:govoplan-admin.source.7c8f1009 {item.source.revision_url ? <a href={item.source.revision_url} target="_blank" rel="noreferrer"><code>{item.source.repository}@{item.source.tag}</code></a> : <code>{item.source.repository}@{item.source.tag}</code>}</span>
<code title={item.source.commit}>{shortIdentity(item.source.commit)}</code>
</div>}
{Object.entries(item.artifact_integrity).map(([kind, artifact]) => <div key={kind} className="module-package-catalog-provenance">
<span>i18n:govoplan-admin.artifact.7c8f1010 <code>{kind}</code> {artifact.url ? <a href={artifact.url} target="_blank" rel="noreferrer">{artifact.filename ?? artifact.registry_identity ?? artifact.ref ?? kind}</a> : artifact.filename ?? artifact.registry_identity ?? artifact.ref ?? kind}</span>
{artifact.sha256 && <code title={artifact.sha256}>sha256:{shortIdentity(artifact.sha256)}</code>}
</div>)}
{item.description && <p className="module-package-catalog-description">{item.description}</p>} {item.description && <p className="module-package-catalog-description">{item.description}</p>}
{item.configuration_requirements.length > 0 && <p className="module-package-catalog-description">i18n:govoplan-admin.configuration_requirements.7c8f1011 {item.configuration_requirements.join(", ")}</p>}
{item.migration_notes && <p className="module-package-catalog-description">{item.migration_notes}</p>} {item.migration_notes && <p className="module-package-catalog-description">{item.migration_notes}</p>}
{item.bridge_notes && <p className="module-package-catalog-description">{item.bridge_notes}</p>} {item.bridge_notes && <p className="module-package-catalog-description">{item.bridge_notes}</p>}
{item.recovery_notes && <p className="module-package-catalog-description">{item.recovery_notes}</p>} {item.recovery_notes && <p className="module-package-catalog-description">{item.recovery_notes}</p>}
{item.release_notes_url && <p className="module-package-catalog-description"><a href={item.release_notes_url} target="_blank" rel="noreferrer">i18n:govoplan-admin.release_notes.7c8f1012</a></p>}
{item.compatibility_reasons.map((compatibilityReason) => <p key={compatibilityReason} className="alert warning">{compatibilityReason}</p>)}
{item.license_reason && <p className={`module-package-catalog-description${item.license_allowed ? "" : " alert warning"}`}>{item.license_reason}</p>} {item.license_reason && <p className={`module-package-catalog-description${item.license_allowed ? "" : " alert warning"}`}>{item.license_reason}</p>}
</div> </div>
<Button onClick={() => void addCatalogItem(item)} disabled={!canWrite || planBusy || !packageCatalog.valid || !item.license_allowed || !["install", "update"].includes(item.action)} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !packageCatalog.valid || !item.license_allowed || !["install", "update"].includes(item.action) ? ADMIN_INTERFACE_I18N.catalogBlocked : undefined}>{catalogPlanButtonLabel(item)}</Button> <Button onClick={() => void addCatalogItem(item)} disabled={!canWrite || planBusy || !packageCatalog.valid || !item.plan_allowed} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !packageCatalog.valid || !item.plan_allowed ? ADMIN_INTERFACE_I18N.catalogBlocked : undefined}>{catalogPlanButtonLabel(item)}</Button>
</div> </div>
)} )}
</div>} </div>}
@@ -952,6 +1013,25 @@ function currentWindowLabel(item: Pick<ModulePackageCatalogItem, "current_versio
].filter(Boolean).join(" ") || "any"; ].filter(Boolean).join(" ") || "any";
} }
function catalogStateTone(item: ModulePackageCatalogItem): string {
if (item.catalog_state === "withdrawn") return "locked";
if (item.catalog_state === "blocked" || item.catalog_state === "update_available") return "warning";
if (item.catalog_state === "installed") return "success";
return "inactive";
}
function catalogStateLabel(item: ModulePackageCatalogItem): string {
if (item.catalog_state === "withdrawn") return "i18n:govoplan-admin.withdrawn.7c8f1013";
if (item.catalog_state === "blocked") return "i18n:govoplan-admin.blocked.7c8f1007";
if (item.catalog_state === "update_available") return "i18n:govoplan-admin.update_available.7c8f1014";
if (item.catalog_state === "installed") return "i18n:govoplan-admin.installed.7bb4405c";
return "i18n:govoplan-admin.available.7c8f1005";
}
function shortIdentity(value: string): string {
return value.length > 14 ? `${value.slice(0, 12)}...` : value;
}
function ModuleStatus({ module, desiredEnabled }: {module: ModuleCatalogItem;desiredEnabled: boolean;}) { function ModuleStatus({ module, desiredEnabled }: {module: ModuleCatalogItem;desiredEnabled: boolean;}) {
if (module.current_enabled && !desiredEnabled) return <StatusBadge status="warning" label="i18n:govoplan-admin.will_disable.14bb193a" />; if (module.current_enabled && !desiredEnabled) return <StatusBadge status="warning" label="i18n:govoplan-admin.will_disable.14bb193a" />;
if (!module.current_enabled && desiredEnabled) return <StatusBadge status="warning" label="i18n:govoplan-admin.will_enable.5d52d70b" />; if (!module.current_enabled && desiredEnabled) return <StatusBadge status="warning" label="i18n:govoplan-admin.will_enable.5d52d70b" />;
@@ -960,6 +1040,7 @@ function ModuleStatus({ module, desiredEnabled }: {module: ModuleCatalogItem;des
} }
function catalogPlanButtonLabel(item: ModulePackageCatalogItem): string { function catalogPlanButtonLabel(item: ModulePackageCatalogItem): string {
if (item.installed && !item.plan_allowed) return "i18n:govoplan-admin.installed.7bb4405c";
return item.action === "update" || item.installed return item.action === "update" || item.installed
? "i18n:govoplan-admin.plan_update.86e6857a" ? "i18n:govoplan-admin.plan_update.86e6857a"
: "i18n:govoplan-admin.plan_install.e82bffe6"; : "i18n:govoplan-admin.plan_install.e82bffe6";
+28
View File
@@ -2,6 +2,20 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "en": {
"i18n:govoplan-admin.search_module_directory.7c8f1001": "Search module directory",
"i18n:govoplan-admin.module_name_package_or_tag.7c8f1002": "Module name, package, or tag",
"i18n:govoplan-admin.filter_module_directory.7c8f1003": "Filter module directory",
"i18n:govoplan-admin.all_catalog_entries.7c8f1004": "All",
"i18n:govoplan-admin.available.7c8f1005": "Available",
"i18n:govoplan-admin.updates.7c8f1006": "Updates",
"i18n:govoplan-admin.blocked.7c8f1007": "Blocked",
"i18n:govoplan-admin.no_catalog_entries_match.7c8f1008": "No catalog entries match the current search and filter.",
"i18n:govoplan-admin.source.7c8f1009": "Source:",
"i18n:govoplan-admin.artifact.7c8f1010": "Artifact:",
"i18n:govoplan-admin.configuration_requirements.7c8f1011": "Configuration requirements:",
"i18n:govoplan-admin.release_notes.7c8f1012": "Release notes",
"i18n:govoplan-admin.withdrawn.7c8f1013": "Withdrawn",
"i18n:govoplan-admin.update_available.7c8f1014": "Update available",
"i18n:govoplan-admin.administration_data_is_loading.6bf3c001": "Administration data is loading.", "i18n:govoplan-admin.administration_data_is_loading.6bf3c001": "Administration data is loading.",
"i18n:govoplan-admin.an_administration_operation_is_in_progress.6bf3c002": "An administration operation is in progress.", "i18n:govoplan-admin.an_administration_operation_is_in_progress.6bf3c002": "An administration operation is in progress.",
"i18n:govoplan-admin.system_administration_write_permission_is_required.6bf3c003": "System administration write permission is required.", "i18n:govoplan-admin.system_administration_write_permission_is_required.6bf3c003": "System administration write permission is required.",
@@ -438,6 +452,20 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.working.049ac820": "Working..." "i18n:govoplan-admin.working.049ac820": "Working..."
}, },
"de": { "de": {
"i18n:govoplan-admin.search_module_directory.7c8f1001": "Modulverzeichnis durchsuchen",
"i18n:govoplan-admin.module_name_package_or_tag.7c8f1002": "Modulname, Paket oder Schlagwort",
"i18n:govoplan-admin.filter_module_directory.7c8f1003": "Modulverzeichnis filtern",
"i18n:govoplan-admin.all_catalog_entries.7c8f1004": "Alle",
"i18n:govoplan-admin.available.7c8f1005": "Verfügbar",
"i18n:govoplan-admin.updates.7c8f1006": "Aktualisierungen",
"i18n:govoplan-admin.blocked.7c8f1007": "Blockiert",
"i18n:govoplan-admin.no_catalog_entries_match.7c8f1008": "Keine Katalogeinträge entsprechen der aktuellen Suche und dem Filter.",
"i18n:govoplan-admin.source.7c8f1009": "Quelle:",
"i18n:govoplan-admin.artifact.7c8f1010": "Artefakt:",
"i18n:govoplan-admin.configuration_requirements.7c8f1011": "Konfigurationsvoraussetzungen:",
"i18n:govoplan-admin.release_notes.7c8f1012": "Versionshinweise",
"i18n:govoplan-admin.withdrawn.7c8f1013": "Zurückgezogen",
"i18n:govoplan-admin.update_available.7c8f1014": "Aktualisierung verfügbar",
"i18n:govoplan-admin.administration_data_is_loading.6bf3c001": "Administrationsdaten werden geladen.", "i18n:govoplan-admin.administration_data_is_loading.6bf3c001": "Administrationsdaten werden geladen.",
"i18n:govoplan-admin.an_administration_operation_is_in_progress.6bf3c002": "Eine Administrationsaktion wird gerade ausgeführt.", "i18n:govoplan-admin.an_administration_operation_is_in_progress.6bf3c002": "Eine Administrationsaktion wird gerade ausgeführt.",
"i18n:govoplan-admin.system_administration_write_permission_is_required.6bf3c003": "Eine Schreibberechtigung für die Systemadministration ist erforderlich.", "i18n:govoplan-admin.system_administration_write_permission_is_required.6bf3c003": "Eine Schreibberechtigung für die Systemadministration ist erforderlich.",