diff --git a/docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md b/docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md new file mode 100644 index 0000000..5d3d749 --- /dev/null +++ b/docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md @@ -0,0 +1,47 @@ +# External References And Integration Maturity + +GovOPlaN integrations use a shared external-reference contract instead of +storing connector-specific URLs and identifiers in every module. + +An external reference identifies an object by: + +- external system instance +- object type +- stable external object ID +- optional connector configuration +- canonical HTTP(S) URL without embedded credentials +- optional source version, ETag, observation time, and non-secret metadata + +The identity key is `system:object_type:object_id`. A GovOPlaN object may retain +multiple references, but one reference must never silently change its identity. +Moving or escalating work creates a new object and an explicit relationship; it +does not rewrite either object's history. + +## Integration Maturity + +Maturity is cumulative: + +1. `discover`: identify configured external systems and their health. +2. `link`: retain and open stable external references. +3. `search`: include authorized external objects in GovOPlaN search. +4. `read`: display authoritative external content. +5. `publish`: create or update external content from GovOPlaN. +6. `synchronize`: reconcile changes in both directions with conflict handling. +7. `migrate`: perform a governed, verifiable transfer into GovOPlaN. +8. `replace`: provide the native operational capability without the external tool. + +Connectors must declare and document the maturity they actually implement. +`synchronize` requires durable cursors, idempotency, provenance, conflict +handling, deletion semantics, and observable failures. A link-only connector +must not imply that GovOPlaN holds an authoritative copy. + +## Domain Ownership + +- Domain modules own native GovOPlaN objects and their authorization. +- Connectors own protocols, credentials, discovery, transport, and sync state. +- Search owns indexing and result aggregation, but source modules remain + responsible for authorization. +- Core owns only the stable DTOs and extension contracts. + +The Python contract is +`govoplan_core.core.external_references.ExternalObjectReference`. diff --git a/docs/GOVOPLAN_MASTER_ROADMAP.md b/docs/GOVOPLAN_MASTER_ROADMAP.md index 1fb1cf8..1a440db 100644 --- a/docs/GOVOPLAN_MASTER_ROADMAP.md +++ b/docs/GOVOPLAN_MASTER_ROADMAP.md @@ -412,7 +412,7 @@ Goal: cover internal support and public issue reporting. Create or refine in this order: -1. `govoplan-issue-reporting`: public/internal reports, categories, intake, +1. `govoplan-tickets`: public/internal reports, requests, incidents, queues, location, evidence, and triage. 2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments, escalation, and resolution evidence. diff --git a/docs/PUBLIC_SECTOR_INTEGRATION_STRATEGY.md b/docs/PUBLIC_SECTOR_INTEGRATION_STRATEGY.md index 3b5dc96..8cc2b6f 100644 --- a/docs/PUBLIC_SECTOR_INTEGRATION_STRATEGY.md +++ b/docs/PUBLIC_SECTOR_INTEGRATION_STRATEGY.md @@ -5,6 +5,9 @@ before deciding to replace specialist workflows. This document is the core strategy index. The executable connector catalogue lives in `govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`. +The canonical cumulative maturity model and external-object DTO are documented +in [EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md](EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md). + ## Strategy Labels Use one or more of these labels for every external system family: diff --git a/src/govoplan_core/core/external_references.py b/src/govoplan_core/core/external_references.py new file mode 100644 index 0000000..2ed99a4 --- /dev/null +++ b/src/govoplan_core/core/external_references.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal +from urllib.parse import urlsplit + + +IntegrationMaturity = Literal[ + "discover", + "link", + "search", + "read", + "publish", + "synchronize", + "migrate", + "replace", +] + +INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = ( + "discover", + "link", + "search", + "read", + "publish", + "synchronize", + "migrate", + "replace", +) + + +class ExternalReferenceValidationError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class ExternalObjectReference: + """Stable identity and provenance for an object owned by another system.""" + + system: str + object_type: str + object_id: str + maturity: IntegrationMaturity = "link" + connector_id: str | None = None + canonical_url: str | None = None + version: str | None = None + etag: str | None = None + observed_at: datetime | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + def __post_init__(self) -> None: + for field_name in ("system", "object_type", "object_id"): + value = str(getattr(self, field_name) or "").strip() + if not value: + raise ExternalReferenceValidationError( + f"External reference {field_name} is required." + ) + if len(value) > 255: + raise ExternalReferenceValidationError( + f"External reference {field_name} is limited to 255 characters." + ) + object.__setattr__(self, field_name, value) + if self.maturity not in INTEGRATION_MATURITY_ORDER: + raise ExternalReferenceValidationError( + f"Unsupported integration maturity: {self.maturity!r}." + ) + if self.connector_id is not None: + connector_id = self.connector_id.strip() + if not connector_id: + raise ExternalReferenceValidationError( + "External reference connector_id cannot be blank." + ) + object.__setattr__(self, "connector_id", connector_id) + if self.canonical_url is not None: + object.__setattr__( + self, + "canonical_url", + _validated_reference_url(self.canonical_url), + ) + + @property + def identity_key(self) -> str: + return f"{self.system}:{self.object_type}:{self.object_id}" + + def supports(self, maturity: IntegrationMaturity) -> bool: + return integration_maturity_rank(self.maturity) >= integration_maturity_rank( + maturity + ) + + def to_dict(self) -> dict[str, object]: + return { + "system": self.system, + "object_type": self.object_type, + "object_id": self.object_id, + "maturity": self.maturity, + "connector_id": self.connector_id, + "canonical_url": self.canonical_url, + "version": self.version, + "etag": self.etag, + "observed_at": ( + self.observed_at.isoformat() if self.observed_at is not None else None + ), + "metadata": dict(self.metadata), + } + + +def integration_maturity_rank(maturity: IntegrationMaturity) -> int: + try: + return INTEGRATION_MATURITY_ORDER.index(maturity) + except ValueError as exc: + raise ExternalReferenceValidationError( + f"Unsupported integration maturity: {maturity!r}." + ) from exc + + +def _validated_reference_url(value: str) -> str: + normalized = value.strip() + parsed = urlsplit(normalized) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ExternalReferenceValidationError( + "External reference URLs must use HTTP or HTTPS." + ) + if parsed.username is not None or parsed.password is not None: + raise ExternalReferenceValidationError( + "External reference URLs must not contain credentials." + ) + return normalized + + +__all__ = [ + "ExternalObjectReference", + "ExternalReferenceValidationError", + "INTEGRATION_MATURITY_ORDER", + "IntegrationMaturity", + "integration_maturity_rank", +] diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index d51f0ff..c64552b 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -8,6 +8,7 @@ from govoplan_core.core.views import ViewSurface if TYPE_CHECKING: from fastapi import APIRouter + from govoplan_core.core.search import SearchProviderRegistration SUPPORTED_MANIFEST_CONTRACT_VERSION = "1" @@ -356,6 +357,7 @@ class ModuleManifest: delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict) uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = () capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict) + search_providers: tuple["SearchProviderRegistration", ...] = () compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility) on_activate: LifecycleHook | None = None on_deactivate: LifecycleHook | None = None diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 7a6afc0..f3c56e4 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -25,6 +25,7 @@ from govoplan_core.core.modules import ( user_workflow_scope_condition_issues, ) from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range +from govoplan_core.core.search import RegisteredSearchProvider, SearchProvider from govoplan_core.core.views import ( ViewSurface, module_view_surface_id, @@ -60,6 +61,8 @@ class PlatformRegistry: self._capability_factories: dict[str, CapabilityFactory] = {} self._capabilities: dict[str, object] = {} self._capability_context: ModuleContext | None = None + self._search_provider_registrations: list[RegisteredSearchProvider] = [] + self._search_providers: dict[str, SearchProvider] = {} def register(self, manifest: ModuleManifest) -> ModuleManifest: if manifest.id in self._manifests: @@ -72,6 +75,13 @@ class PlatformRegistry: self.register_delete_veto(manifest.id, resource_type, provider) for name, factory in manifest.capability_factories.items(): self.register_capability_factory(manifest.id, name, factory) + for registration in manifest.search_providers: + self._search_provider_registrations.append( + RegisteredSearchProvider( + module_id=manifest.id, + registration=registration, + ) + ) return manifest def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot: @@ -89,7 +99,11 @@ class PlatformRegistry: for resource_type, providers in replacement._delete_veto_providers.items() }) self._capability_factories = dict(replacement._capability_factories) + self._search_provider_registrations = list( + replacement._search_provider_registrations + ) self._capabilities.clear() + self._search_providers.clear() return snapshot def get(self, module_id: str) -> ModuleManifest | None: @@ -133,6 +147,7 @@ class PlatformRegistry: def configure_capability_context(self, context: ModuleContext) -> None: self._capability_context = context self._capabilities.clear() + self._search_providers.clear() def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None: if name in self._capability_factories: @@ -160,6 +175,37 @@ class PlatformRegistry: raise RegistryError(f"Required capability is not available: {name}") return capability + def search_provider_registrations( + self, + ) -> tuple[RegisteredSearchProvider, ...]: + return tuple( + sorted( + self._search_provider_registrations, + key=lambda item: ( + item.registration.order, + item.module_id, + item.registration.id, + ), + ) + ) + + def search_providers( + self, + ) -> tuple[tuple[RegisteredSearchProvider, SearchProvider], ...]: + if self._capability_context is None: + if self._search_provider_registrations: + raise RegistryError("Search provider context is not configured.") + return () + providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = [] + for registered in self.search_provider_registrations(): + key = f"{registered.module_id}:{registered.registration.id}" + provider = self._search_providers.get(key) + if provider is None: + provider = registered.registration.create(self._capability_context) + self._search_providers[key] = provider + providers.append((registered, provider)) + return tuple(providers) + def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None: self._tenant_summary_providers[module_id] = provider @@ -462,6 +508,19 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None: _validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities) _validate_interface_providers(manifest.id, manifest.provides_interfaces) _validate_interface_requirements(manifest.id, manifest.requires_interfaces) + provider_ids: set[str] = set() + for registration in manifest.search_providers: + if not _INTERFACE_NAME_RE.match(registration.id): + raise RegistryError( + f"Module {manifest.id!r} search provider id must be namespaced: " + f"{registration.id!r}" + ) + if registration.id in provider_ids: + raise RegistryError( + f"Module {manifest.id!r} declares duplicate search provider " + f"{registration.id!r}" + ) + provider_ids.add(registration.id) def _validate_manifest_overlaps(manifest: ModuleManifest) -> None: diff --git a/src/govoplan_core/core/search.py b/src/govoplan_core/core/search.py new file mode 100644 index 0000000..ee60627 --- /dev/null +++ b/src/govoplan_core/core/search.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Literal, Protocol, runtime_checkable + +from govoplan_core.core.external_references import ExternalObjectReference +from govoplan_core.core.modules import ModuleContext + + +SearchContextKind = Literal["global", "module", "resource"] +SearchVisibility = Literal["tenant", "restricted"] + + +@dataclass(frozen=True, slots=True) +class SearchQuery: + text: str + tenant_id: str + module_ids: tuple[str, ...] = () + resource_types: tuple[str, ...] = () + context_kind: SearchContextKind = "global" + context_id: str | None = None + limit: int = 25 + offset: int = 0 + + def __post_init__(self) -> None: + normalized_text = self.text.strip() + if len(normalized_text) > 500: + raise ValueError("Search text is limited to 500 characters.") + if not self.tenant_id.strip(): + raise ValueError("Search queries require a tenant.") + if not 1 <= self.limit <= 200: + raise ValueError("Search result limits must be between 1 and 200.") + if self.offset < 0: + raise ValueError("Search offsets cannot be negative.") + object.__setattr__(self, "text", normalized_text) + + +@dataclass(frozen=True, slots=True) +class SearchResult: + provider_id: str + module_id: str + resource_type: str + resource_id: str + title: str + url: str + summary: str | None = None + score: float = 0.0 + highlights: tuple[str, ...] = () + breadcrumbs: tuple[str, ...] = () + external_reference: ExternalObjectReference | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class SearchDocument: + tenant_id: str + module_id: str + resource_type: str + resource_id: str + title: str + url: str + summary: str | None = None + body: str | None = None + keywords: tuple[str, ...] = () + visibility: SearchVisibility = "restricted" + acl_tokens: tuple[str, ...] = () + external_reference: ExternalObjectReference | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + def __post_init__(self) -> None: + required = { + "tenant_id": self.tenant_id, + "module_id": self.module_id, + "resource_type": self.resource_type, + "resource_id": self.resource_id, + "title": self.title, + "url": self.url, + } + for field_name, value in required.items(): + if not str(value or "").strip(): + raise ValueError(f"Search document {field_name} is required.") + if self.visibility == "restricted" and not self.acl_tokens: + raise ValueError( + "Restricted search documents require at least one ACL token." + ) + + +@runtime_checkable +class SearchProvider(Protocol): + def search( + self, + session: object, + principal: object, + *, + query: SearchQuery, + ) -> Sequence[SearchResult]: + """Return only results the principal may currently read.""" + + +@runtime_checkable +class SearchIndexWriter(Protocol): + def upsert_document( + self, + session: object, + principal: object, + *, + document: SearchDocument, + ) -> None: + ... + + def delete_document( + self, + session: object, + principal: object, + *, + tenant_id: str, + module_id: str, + resource_type: str, + resource_id: str, + ) -> bool: + ... + + +SearchProviderFactory = Callable[[ModuleContext], SearchProvider] + + +@dataclass(frozen=True, slots=True) +class SearchProviderRegistration: + id: str + factory: SearchProviderFactory + resource_types: tuple[str, ...] = () + order: int = 100 + + def create(self, context: ModuleContext) -> SearchProvider: + provider = self.factory(context) + if not isinstance(provider, SearchProvider): + raise TypeError( + f"Search provider {self.id!r} does not implement SearchProvider." + ) + return provider + + +@dataclass(frozen=True, slots=True) +class RegisteredSearchProvider: + module_id: str + registration: SearchProviderRegistration + + +CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer" + + +def search_index_writer(registry: object | None) -> SearchIndexWriter | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not hasattr(registry, "capability") + or not registry.has_capability(CAPABILITY_SEARCH_INDEX_WRITER) + ): + return None + writer = registry.capability(CAPABILITY_SEARCH_INDEX_WRITER) + return writer if isinstance(writer, SearchIndexWriter) else None + + +__all__ = [ + "CAPABILITY_SEARCH_INDEX_WRITER", + "RegisteredSearchProvider", + "SearchContextKind", + "SearchDocument", + "SearchIndexWriter", + "SearchProvider", + "SearchProviderFactory", + "SearchProviderRegistration", + "SearchQuery", + "SearchResult", + "SearchVisibility", + "search_index_writer", +] diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index 5ce8b20..b9f3d8a 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -45,7 +45,15 @@ class Settings(BaseSettings): tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE") tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE") tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE") - enabled_modules: str = Field(default="tenancy,organizations,identity,idm,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,postbox,notifications,docs,ops", alias="ENABLED_MODULES") + enabled_modules: str = Field( + default=( + "tenancy,organizations,identity,idm,access,admin,dashboard,policy," + "audit,campaigns,files,mail,calendar,poll,scheduling,connectors," + "datasources,dataflow,workflow,views,search,postbox,notifications," + "docs,ops" + ), + alias="ENABLED_MODULES", + ) migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK") redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL") celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED") diff --git a/tests/test_external_reference_contract.py b/tests/test_external_reference_contract.py new file mode 100644 index 0000000..47d917f --- /dev/null +++ b/tests/test_external_reference_contract.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.external_references import ( + ExternalObjectReference, + ExternalReferenceValidationError, +) + + +class ExternalReferenceContractTests(unittest.TestCase): + def test_reference_has_stable_identity_and_ordered_maturity(self) -> None: + reference = ExternalObjectReference( + system="openproject-main", + object_type="work_package", + object_id="42", + maturity="synchronize", + canonical_url="https://projects.example.test/work_packages/42", + ) + + self.assertEqual("openproject-main:work_package:42", reference.identity_key) + self.assertTrue(reference.supports("read")) + self.assertFalse(reference.supports("replace")) + + def test_reference_rejects_credentials_in_urls(self) -> None: + with self.assertRaises(ExternalReferenceValidationError): + ExternalObjectReference( + system="wiki", + object_type="page", + object_id="Main_Page", + canonical_url="https://user:secret@example.test/wiki/Main_Page", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_search_contract.py b/tests/test_search_contract.py new file mode 100644 index 0000000..593de92 --- /dev/null +++ b/tests/test_search_contract.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.modules import ModuleContext, ModuleManifest +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.core.search import ( + SearchDocument, + SearchProvider, + SearchProviderRegistration, + SearchQuery, + SearchResult, +) + + +class _Provider: + def search(self, session, principal, *, query): + del session, principal + return ( + SearchResult( + provider_id="test", + module_id="test", + resource_type="record", + resource_id="1", + title=query.text, + url="/test/1", + ), + ) + + +class SearchContractTests(unittest.TestCase): + def test_query_and_restricted_document_are_bounded(self) -> None: + query = SearchQuery(text=" permit ", tenant_id="tenant-1", limit=20) + document = SearchDocument( + tenant_id="tenant-1", + module_id="cases", + resource_type="case", + resource_id="case-1", + title="Permit", + url="/cases/case-1", + acl_tokens=("account:account-1",), + ) + + self.assertEqual("permit", query.text) + self.assertEqual("restricted", document.visibility) + with self.assertRaises(ValueError): + SearchDocument( + tenant_id="tenant-1", + module_id="cases", + resource_type="case", + resource_id="case-1", + title="Permit", + url="/cases/case-1", + ) + + def test_manifest_provider_registration_resolves_lazily(self) -> None: + registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="test", + name="Test", + version="1", + search_providers=( + SearchProviderRegistration( + id="test.records", + factory=lambda context: _Provider(), + resource_types=("record",), + ), + ), + ) + ) + registry.configure_capability_context( + ModuleContext(registry=registry, settings=object()) + ) + + providers = registry.search_providers() + self.assertEqual(1, len(providers)) + self.assertIsInstance(providers[0][1], SearchProvider) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package-lock.json b/webui/package-lock.json index 9930e39..b0f5437 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -27,6 +27,7 @@ "@govoplan/policy-webui": "file:../../govoplan-policy/webui", "@govoplan/postbox-webui": "file:../../govoplan-postbox/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", + "@govoplan/search-webui": "file:../../govoplan-search/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui" }, @@ -396,6 +397,22 @@ } } }, + "../../govoplan-search/webui": { + "name": "@govoplan/search-webui", + "version": "0.1.14", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": ">=7.18.2 <8" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, "../../govoplan-views/webui": { "name": "@govoplan/views-webui", "version": "0.1.0", @@ -1230,6 +1247,10 @@ "resolved": "../../govoplan-scheduling/webui", "link": true }, + "node_modules/@govoplan/search-webui": { + "resolved": "../../govoplan-search/webui", + "link": true + }, "node_modules/@govoplan/views-webui": { "resolved": "../../govoplan-views/webui", "link": true diff --git a/webui/package.json b/webui/package.json index b62163d..41c0494 100644 --- a/webui/package.json +++ b/webui/package.json @@ -58,6 +58,7 @@ "@govoplan/policy-webui": "file:../../govoplan-policy/webui", "@govoplan/postbox-webui": "file:../../govoplan-postbox/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", + "@govoplan/search-webui": "file:../../govoplan-search/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui" }, diff --git a/webui/src/layout/Titlebar.tsx b/webui/src/layout/Titlebar.tsx index 59828d1..3c72689 100644 --- a/webui/src/layout/Titlebar.tsx +++ b/webui/src/layout/Titlebar.tsx @@ -1,6 +1,6 @@ import { useRef, useState, useEffect } from "react"; import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react"; -import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, ViewsRuntimeUiCapability } from "../types"; +import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types"; import HelpMenu from "./HelpMenu"; import LanguageMenu from "./LanguageMenu"; import LoginModal from "../features/auth/LoginModal"; @@ -37,6 +37,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode const projection = useEffectiveView(); const viewsRuntime = usePlatformUiCapability("views.runtime"); const ViewSelector = viewsRuntime?.Selector ?? null; + const searchRuntime = usePlatformUiCapability("search.runtime"); + const GlobalSearch = searchRuntime?.GlobalSearch ?? null; const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null; const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []); @@ -200,6 +202,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
+ {auth && GlobalSearch && + + } + diff --git a/webui/src/types.ts b/webui/src/types.ts index 2bd4239..697fbb2 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -406,6 +406,30 @@ export type ViewsRuntimeUiCapability = { Selector?: ComponentType; }; +export type SearchContextContribution = { + id: string; + moduleId: string; + label: string; + pathPrefixes: string[]; + resourceTypes?: string[]; + placeholder?: string; + surfaceId?: string; + order?: number; +}; + +export type SearchContextsUiCapability = { + contexts: SearchContextContribution[]; +}; + +export type GlobalSearchProps = { + settings: ApiSettings; + auth: AuthInfo; +}; + +export type SearchRuntimeUiCapability = { + GlobalSearch: ComponentType; +}; + export type DashboardWidgetSize = "small" | "medium" | "wide" | "full"; export type DashboardWidgetTone = "neutral" | "good" | "warning" | "danger" | "info";