diff --git a/README.md b/README.md index 4f1c1db..13473fd 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,17 @@ admin UI records operator intent and queues or renders commands for the trusted installer process described in `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`. -Catalog entries show whether the package is already installed and whether the -directory advertises another version. Selecting an 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. +Catalog entries can be searched by module, package, repository, or tag and +filtered by availability, installed state, update state, and blockers. Each row +shows its signed source revision and immutable artifact digest together with +configuration requirements and release notes when the publisher supplies them. +Withdrawn entries and targets with missing dependencies, incompatible named +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, installer request, daemon execution, and run evidence. The projection resets diff --git a/src/govoplan_admin/backend/api/v1/routes.py b/src/govoplan_admin/backend/api/v1/routes.py index 268328a..00a37a3 100644 --- a/src/govoplan_admin/backend/api/v1/routes.py +++ b/src/govoplan_admin/backend/api/v1/routes.py @@ -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.registry import PlatformRegistry 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.i18n import ( REFERENCE_LANGUAGE_CODE, @@ -635,6 +636,12 @@ def _catalog_install_or_update_item(result: dict[str, object], module_id: str) - continue if raw_item.get("module_id") != module_id or raw_item.get("action") not in {"install", "update"}: 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 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( raw_item: dict[str, object], available: Mapping[str, Any], + catalog_items: list[dict[str, object]] | None = None, ) -> ModulePackageCatalogItem: 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 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_version"] = installed_version - payload["update_available"] = bool( - installed_version - and isinstance(target_version, str) - and target_version != installed_version - ) + payload["update_available"] = version_comparison is not None and version_comparison > 0 decision = module_license_decision(_catalog_license_features(raw_item)) payload["license_allowed"] = bool(decision.get("allowed")) 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 [] reason = decision.get("reason") 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) +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]: required: list[str] = [] for raw_item in result.get("modules", []): @@ -1260,9 +1418,13 @@ def read_module_package_catalog( if isinstance(lifecycle, ModuleLifecycleManager) 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)) 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"]), valid=bool(result["valid"]), path=result["path"] if isinstance(result["path"], str) else None, diff --git a/src/govoplan_admin/backend/api/v1/schemas.py b/src/govoplan_admin/backend/api/v1/schemas.py index 20db655..f1a144b 100644 --- a/src/govoplan_admin/backend/api/v1/schemas.py +++ b/src/govoplan_admin/backend/api/v1/schemas.py @@ -387,6 +387,30 @@ class ModuleInterfaceRequirementItem(BaseModel): 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): module_id: str name: str @@ -396,6 +420,16 @@ class ModulePackageCatalogItem(BaseModel): installed: bool = False installed_version: str | None = None 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) optional_dependencies: list[str] = Field(default_factory=list) migration_safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic" diff --git a/src/govoplan_admin/backend/manifest.py b/src/govoplan_admin/backend/manifest.py index e49b09a..8b4a6e0 100644 --- a/src/govoplan_admin/backend/manifest.py +++ b/src/govoplan_admin/backend/manifest.py @@ -147,7 +147,7 @@ manifest = ModuleManifest( summary="Move a reviewed module package plan through preflight, maintenance-gated queueing, daemon execution, and durable run evidence.", 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. " - "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. " "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." ), diff --git a/tests/test_catalog_plan.py b/tests/test_catalog_plan.py index a43a411..bc6ca7b 100644 --- a/tests/test_catalog_plan.py +++ b/tests/test_catalog_plan.py @@ -5,8 +5,12 @@ from unittest.mock import patch 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_core.core.modules import ModuleInterfaceProvider, ModuleManifest class CatalogPlanItemTests(unittest.TestCase): @@ -114,6 +118,139 @@ class CatalogPlanItemTests(unittest.TestCase): self.assertEqual(missing.exception.status_code, 404) 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__": unittest.main() diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index d28adce..d4613fa 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -419,6 +419,30 @@ export type ModuleInterfaceRequirementItem = { 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 = { module_id: string; name: string; @@ -428,6 +452,16 @@ export type ModulePackageCatalogItem = { installed: boolean; installed_version?: string | null; update_available: boolean; + availability: "available" | "withdrawn"; + availability_reason?: string | null; + configuration_requirements: string[]; + release_notes_url?: string | null; + source?: ModulePackageCatalogSource | null; + artifact_integrity: Record; + compatible: boolean; + plan_allowed: boolean; + compatibility_reasons: string[]; + catalog_state: "available" | "installed" | "update_available" | "blocked" | "withdrawn"; dependencies: string[]; optional_dependencies: string[]; migration_safety: "automatic" | "requires_review" | "forward_only" | "destructive"; diff --git a/webui/src/features/admin/ModuleManagementPanel.tsx b/webui/src/features/admin/ModuleManagementPanel.tsx index e6c65b1..b595f9a 100644 --- a/webui/src/features/admin/ModuleManagementPanel.tsx +++ b/webui/src/features/admin/ModuleManagementPanel.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; 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 { cancelModuleInstallerRequest, @@ -52,6 +52,8 @@ const MODULE_INSTALLER_I18N = { resolutionTarget: "i18n:govoplan-admin.where_to_go.f1a20205" } as const; +type CatalogModuleFilter = "all" | "available" | "updates" | "installed" | "blocked"; + export default function ModuleManagementPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) { const [catalog, setCatalog] = useState(null); const [installPlan, setInstallPlan] = useState(null); @@ -71,6 +73,8 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai const [confirmClearPlan, setConfirmClearPlan] = useState(false); const [confirmMaintenance, setConfirmMaintenance] = useState(false); const [cancelRequestId, setCancelRequestId] = useState(""); + const [catalogSearch, setCatalogSearch] = useState(""); + const [catalogFilter, setCatalogFilter] = useState("all"); async function load() { 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 planValid = planValidationError(draftPlanItems) === ""; 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( installPlan?.updated_at, latestInstallerRequest.created_at @@ -494,15 +520,39 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai {packageCatalog.warnings.map((warning) =>

{warning}

)} {!packageCatalog.configured &&
i18n:govoplan-admin.no_package_catalog_configured_set_govoplan_modul.2c5fceb3
} {packageCatalog.configured && packageCatalog.modules.length === 0 && !packageCatalog.error &&
i18n:govoplan-admin.package_catalog_is_configured_but_contains_no_en.b6b053cb
} + {packageCatalog.modules.length > 0 &&
+ + + 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" }, + ]} /> + +
} + {packageCatalog.modules.length > 0 && visibleCatalogModules.length === 0 &&
i18n:govoplan-admin.no_catalog_entries_match.7c8f1008
} {packageCatalog.modules.length > 0 &&
- {packageCatalog.modules.map((item) => + {visibleCatalogModules.map((item) =>
{item.name} {item.module_id} {item.version && v{item.version}} - {item.installed && } +
{item.python_package && {item.python_package}} @@ -520,13 +570,24 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai {item.provides_interfaces.length > 0 && i18n:govoplan-admin.provides.221b70d9 {providedInterfacesLabel(item)}} {item.requires_interfaces.length > 0 && i18n:govoplan-admin.requires.a4fc9357 {requiredInterfacesLabel(item)}}
+ {item.source &&
+ i18n:govoplan-admin.source.7c8f1009 {item.source.revision_url ? {item.source.repository}@{item.source.tag} : {item.source.repository}@{item.source.tag}} + {shortIdentity(item.source.commit)} +
} + {Object.entries(item.artifact_integrity).map(([kind, artifact]) =>
+ i18n:govoplan-admin.artifact.7c8f1010 {kind} {artifact.url ? {artifact.filename ?? artifact.registry_identity ?? artifact.ref ?? kind} : artifact.filename ?? artifact.registry_identity ?? artifact.ref ?? kind} + {artifact.sha256 && sha256:{shortIdentity(artifact.sha256)}} +
)} {item.description &&

{item.description}

} + {item.configuration_requirements.length > 0 &&

i18n:govoplan-admin.configuration_requirements.7c8f1011 {item.configuration_requirements.join(", ")}

} {item.migration_notes &&

{item.migration_notes}

} {item.bridge_notes &&

{item.bridge_notes}

} {item.recovery_notes &&

{item.recovery_notes}

} + {item.release_notes_url &&

i18n:govoplan-admin.release_notes.7c8f1012

} + {item.compatibility_reasons.map((compatibilityReason) =>

{compatibilityReason}

)} {item.license_reason &&

{item.license_reason}

}
- +
)}
} @@ -952,6 +1013,25 @@ function currentWindowLabel(item: Pick 14 ? `${value.slice(0, 12)}...` : value; +} + function ModuleStatus({ module, desiredEnabled }: {module: ModuleCatalogItem;desiredEnabled: boolean;}) { if (module.current_enabled && !desiredEnabled) return ; if (!module.current_enabled && desiredEnabled) return ; @@ -960,6 +1040,7 @@ function ModuleStatus({ module, desiredEnabled }: {module: ModuleCatalogItem;des } function catalogPlanButtonLabel(item: ModulePackageCatalogItem): string { + if (item.installed && !item.plan_allowed) return "i18n:govoplan-admin.installed.7bb4405c"; return item.action === "update" || item.installed ? "i18n:govoplan-admin.plan_update.86e6857a" : "i18n:govoplan-admin.plan_install.e82bffe6"; diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index cd743a6..dc0a8e3 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -2,6 +2,20 @@ import type { PlatformTranslations } from "@govoplan/core-webui"; export const generatedTranslations: PlatformTranslations = { "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.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.", @@ -438,6 +452,20 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-admin.working.049ac820": "Working..." }, "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.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.",