Complete permission-aware native search indexing

This commit is contained in:
2026-08-04 03:05:03 +02:00
parent 912db10a8a
commit 1bb338117f
12 changed files with 680 additions and 26 deletions
+2 -1
View File
@@ -14,7 +14,8 @@
"./styles/search.css": "./src/styles/search.css"
},
"scripts": {
"test:search-overlay": "node scripts/test-search-overlay-structure.mjs"
"test:search-overlay": "node scripts/test-search-overlay-structure.mjs",
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/search/SearchPage.tsx", "utf8");
const overlay = fs.readFileSync("src/components/GlobalSearch.tsx", "utf8");
const admin = fs.readFileSync("src/features/search/SearchAdminPanel.tsx", "utf8");
const styles = fs.readFileSync("src/styles/search.css", "utf8");
for (const source of [page, overlay]) {
assert.ok(source.includes("DocumentationHelpLink"), "Search surfaces expose configured-system help");
assert.ok(source.includes("DismissibleAlert"), "Search failures use the shared alert contract");
assert.ok(!source.includes("window.alert("), "Search must not use browser alerts");
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(source), "Search uses semantic interactive elements");
}
assert.ok(page.includes("PageScrollViewport"), "The full search route owns bounded result scrolling");
assert.ok(overlay.includes("<Dialog"), "The title-bar search uses the shared focus-contained dialog");
assert.ok(overlay.includes('role="listbox"'), "Overlay results expose listbox keyboard semantics");
assert.ok(overlay.includes('aria-keyshortcuts="F3 Control+K Meta+K"'), "Search announces its keyboard shortcuts");
assert.ok(styles.includes("@media (max-width: 900px)"), "Search retains a narrow-viewport layout");
assert.ok(admin.includes("AdminPageLayout"), "Search operations use the shared administration layout");
assert.ok(admin.includes("DataGrid"), "Search source coverage uses the shared data grid");
assert.ok(admin.includes("DismissibleAlert"), "Search operator failures use the shared alert contract");
assert.ok(admin.includes("DocumentationHelpLink"), "Search operators can open contextual documentation");
assert.ok(!admin.includes("window.alert("), "Search administration must not use browser alerts");
console.log("Search interface pattern contract passed.");
+97
View File
@@ -63,6 +63,43 @@ export type SearchProviderListResponse = {
resources: SearchResourceType[];
};
export type SearchIndexState = {
provider_id: string;
module_id: string;
resource_type: string;
index_version: number;
status: string;
checkpoint_cursor?: string | null;
high_watermark?: string | null;
last_change_cursor?: string | null;
indexed_documents: number;
rejected_documents: number;
rebuild_started_at?: string | null;
rebuild_completed_at?: string | null;
last_success_at?: string | null;
last_error?: string | null;
};
export type SearchDiagnostics = {
backend: string;
trigram_available: boolean;
queue: Record<string, number>;
queue_oldest_age_seconds?: number | null;
states: SearchIndexState[];
};
export type SearchChangeDispatch = {
selected: number;
applied: number;
retrying: number;
quarantined: number;
};
export type SearchModuleReconcile = {
disabled_documents: number;
enabled_documents: number;
};
export type SearchRequest = {
query: string;
modules?: string[];
@@ -107,3 +144,63 @@ export function listSearchProviders(
{ signal }
);
}
export function getSearchDiagnostics(
settings: ApiSettings,
signal?: AbortSignal
): Promise<SearchDiagnostics> {
return apiFetch<SearchDiagnostics>(
settings,
"/api/v1/search/admin/diagnostics",
{ signal }
);
}
export function reconcileSearchModules(
settings: ApiSettings
): Promise<SearchModuleReconcile> {
return apiFetch<SearchModuleReconcile>(
settings,
"/api/v1/search/admin/reconcile-modules",
{ method: "POST" }
);
}
export function processSearchChanges(
settings: ApiSettings,
limit = 100
): Promise<SearchChangeDispatch> {
return apiFetch<SearchChangeDispatch>(
settings,
apiPath("/api/v1/search/admin/changes/process", { limit }),
{ method: "POST" }
);
}
export function startSearchRebuild(
settings: ApiSettings,
providerId: string,
resourceType: string
): Promise<{ state: SearchIndexState }> {
return apiFetch<{ state: SearchIndexState }>(
settings,
`/api/v1/search/admin/rebuilds/${encodeURIComponent(providerId)}/${encodeURIComponent(resourceType)}/start`,
{ method: "POST" }
);
}
export function continueSearchRebuild(
settings: ApiSettings,
providerId: string,
resourceType: string,
limit = 100
): Promise<{ state: SearchIndexState }> {
return apiFetch<{ state: SearchIndexState }>(
settings,
apiPath(
`/api/v1/search/admin/rebuilds/${encodeURIComponent(providerId)}/${encodeURIComponent(resourceType)}/continue`,
{ limit }
),
{ method: "POST" }
);
}
+5
View File
@@ -13,6 +13,7 @@ import { useLocation } from "react-router";
import {
Button,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
IconButton,
LoadingIndicator,
@@ -588,6 +589,10 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
}
</div>
{loading && <LoadingIndicator size="sm" label="Searching" />}
<DocumentationHelpLink
reference={{ topicId: "search.global-and-contextual", documentationType: "user" }}
label="Open search documentation"
/>
<IconButton
label="Close search"
icon={<X size={17} />}
@@ -0,0 +1,291 @@
import { useEffect, useMemo, useState } from "react";
import {
AdminPageLayout,
adminErrorMessage,
Button,
Card,
DataGrid,
DismissibleAlert,
DocumentationHelpLink,
MetricCard,
StatusBadge,
TableActionGroup,
type ApiSettings,
type DataGridColumn
} from "@govoplan/core-webui";
import { Play, RefreshCw, RotateCw } from "lucide-react";
import {
continueSearchRebuild,
getSearchDiagnostics,
listSearchProviders,
processSearchChanges,
reconcileSearchModules,
startSearchRebuild,
type SearchDiagnostics,
type SearchIndexState,
type SearchResourceType
} from "../../api/search";
type Props = {
settings: ApiSettings;
};
type ResourceRow = SearchResourceType & {
state?: SearchIndexState;
};
const DOCUMENTATION = {
topicId: "search.global-and-contextual",
documentationType: "admin" as const
};
export default function SearchAdminPanel({ settings }: Props) {
const [diagnostics, setDiagnostics] = useState<SearchDiagnostics | null>(null);
const [resources, setResources] = useState<SearchResourceType[]>([]);
const [loading, setLoading] = useState(true);
const [busyKey, setBusyKey] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
void load();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function load() {
setLoading(true);
setError("");
try {
const [nextDiagnostics, catalogue] = await Promise.all([
getSearchDiagnostics(settings),
listSearchProviders(settings)
]);
setDiagnostics(nextDiagnostics);
setResources(catalogue.resources);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
async function runAction(key: string, action: () => Promise<string>) {
setBusyKey(key);
setError("");
setSuccess("");
try {
setSuccess(await action());
await load();
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusyKey("");
}
}
const rows = useMemo<ResourceRow[]>(() => {
const states = new Map(
(diagnostics?.states ?? []).map((state) => [
`${state.provider_id}:${state.resource_type}`,
state
])
);
return resources.map((resource) => ({
...resource,
state: states.get(`${resource.provider_id}:${resource.resource_type}`)
}));
}, [diagnostics, resources]);
const columns = useMemo<DataGridColumn<ResourceRow>[]>(() => [
{
id: "resource",
header: "Search source",
minWidth: 250,
resizable: true,
sortable: true,
filterable: true,
value: (row) => row.label,
render: (row) => (
<div>
<strong>{row.label}</strong>
<div className="muted">{row.module_id} / {row.provider_id}</div>
</div>
)
},
{
id: "status",
header: "State",
width: 135,
minWidth: 115,
sortable: true,
filterable: true,
filterType: "list",
value: (row) => row.state?.status ?? "not built",
render: (row) => (
<StatusBadge
status={statusTone(row.state?.status)}
label={row.state?.status ?? "not built"}
/>
)
},
{
id: "documents",
header: "Documents",
width: 125,
minWidth: 105,
align: "right",
value: (row) => row.state?.indexed_documents ?? 0
},
{
id: "rejected",
header: "Rejected",
width: 105,
minWidth: 90,
align: "right",
value: (row) => row.state?.rejected_documents ?? 0
},
{
id: "lastSuccess",
header: "Last success",
width: 190,
minWidth: 165,
sortable: true,
value: (row) => row.state?.last_success_at ?? "",
render: (row) => formatDateTime(row.state?.last_success_at)
},
{
id: "actions",
header: "Actions",
width: 80,
minWidth: 80,
sticky: "end",
align: "right",
render: (row) => {
const key = `${row.provider_id}:${row.resource_type}`;
const continuing = row.state?.status === "backfilling";
return (
<TableActionGroup
minimumSlots={1}
actions={[{
id: "rebuild",
label: continuing ? "Continue bounded rebuild" : "Start clean rebuild",
icon: continuing ? <Play size={16} /> : <RotateCw size={16} />,
disabled: Boolean(busyKey),
onClick: () => void runAction(key, async () => {
const response = continuing
? await continueSearchRebuild(settings, row.provider_id, row.resource_type)
: await startSearchRebuild(settings, row.provider_id, row.resource_type);
return response.state.status === "backfilling"
? `${row.label} rebuild advanced to the next checkpoint.`
: `${row.label} rebuild completed.`;
})
}]}
/>
);
}
}
], [busyKey, settings]);
const queue = diagnostics?.queue ?? {};
const pending = (queue.queued ?? 0) + (queue.retrying ?? 0);
const quarantined = queue.quarantined ?? 0;
return (
<AdminPageLayout
title="Search index"
description="Inspect source coverage, process durable changes, and reconcile the tenant's derived index from authoritative modules."
loading={loading}
error={error}
success={success}
actions={(
<>
<Button
title="Reload search diagnostics"
aria-label="Reload search diagnostics"
onClick={() => void load()}
disabled={loading || Boolean(busyKey)}
>
<RefreshCw size={16} />
</Button>
<Button
onClick={() => void runAction("process", async () => {
const result = await processSearchChanges(settings);
return `Applied ${result.applied} queued changes; ${result.retrying} remain retryable and ${result.quarantined} were quarantined.`;
})}
disabled={Boolean(busyKey)}
>
<Play size={16} /> Process queue
</Button>
<Button
onClick={() => void runAction("reconcile", async () => {
const result = await reconcileSearchModules(settings);
return `Reconciled active modules: ${result.enabled_documents} enabled and ${result.disabled_documents} disabled documents updated.`;
})}
disabled={Boolean(busyKey)}
>
<RotateCw size={16} /> Reconcile modules
</Button>
<DocumentationHelpLink
reference={DOCUMENTATION}
label="Open Search administration documentation"
/>
</>
)}
>
<div className="metric-grid">
<MetricCard label="Search sources" value={rows.length} tone="neutral" />
<MetricCard label="Pending changes" value={pending} tone={pending ? "warning" : "good"} />
<MetricCard label="Quarantined" value={quarantined} tone={quarantined ? "danger" : "good"} />
<MetricCard label="Backend" value={diagnostics?.backend ?? "-"} tone="neutral" />
</div>
{quarantined > 0 && (
<DismissibleAlert tone="warning" dismissible={false} compact>
Quarantined changes require source or contract repair followed by a source rebuild. They are never silently discarded.
</DismissibleAlert>
)}
<Card title="Native source coverage">
<div className="admin-table-surface">
<DataGrid
id="search-index-sources-v1"
rows={rows}
columns={columns}
getRowKey={(row) => `${row.provider_id}:${row.resource_type}`}
initialFit="container"
emptyText="No active modules announce searchable resource types."
/>
</div>
</Card>
{rows.some((row) => row.state?.last_error) && (
<Card title="Latest source errors">
<div className="settings-list">
{rows.filter((row) => row.state?.last_error).map((row) => (
<DismissibleAlert
key={`${row.provider_id}:${row.resource_type}`}
tone="warning"
dismissible={false}
compact
>
<strong>{row.label}:</strong> {row.state?.last_error}
</DismissibleAlert>
))}
</div>
</Card>
)}
</AdminPageLayout>
);
}
function statusTone(status?: string): string {
if (["ready", "idle"].includes(status ?? "")) return "success";
if (["failed", "quarantined"].includes(status ?? "")) return "danger";
if (["backfilling", "stale"].includes(status ?? "")) return "warning";
return "neutral";
}
function formatDateTime(value?: string | null): string {
if (!value) return "-";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
+23 -16
View File
@@ -9,7 +9,10 @@ import {
} from "react";
import { useSearchParams } from "react-router";
import {
Button,
DocumentationHelpLink,
DismissibleAlert,
IconButton,
LoadingIndicator,
PageScrollViewport,
useGuardedNavigate,
@@ -216,12 +219,12 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
placeholder="Search"
autoFocus
/>
<button type="submit">Search</button>
<Button type="submit" variant="primary">Search</Button>
</form>
<div className="search-filter-menu" ref={filtersRef}>
<button
<Button
type="button"
className={`btn search-filter-trigger${activeFilterCount ? " is-active" : ""}`}
className={`search-filter-trigger${activeFilterCount ? " is-active" : ""}`}
aria-haspopup="dialog"
aria-expanded={filtersOpen}
onClick={() => setFiltersOpen((current) => !current)}>
@@ -232,18 +235,17 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
{activeFilterCount}
</span>
}
</button>
</Button>
{filtersOpen &&
<div className="search-filter-popover" role="dialog" aria-label="Filter search results">
<div className="search-filter-popover-header">
<strong>Filter results</strong>
<button
type="button"
className="btn btn-ghost icon-button"
aria-label="Close filters"
onClick={() => setFiltersOpen(false)}>
<X size={16} />
</button>
<IconButton
label="Close filters"
icon={<X size={16} />}
variant="ghost"
onClick={() => setFiltersOpen(false)}
/>
</div>
<fieldset className="search-filter-group">
<legend>Modules</legend>
@@ -282,18 +284,22 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
</div>
</fieldset>
<div className="search-filter-popover-footer">
<button
<Button
type="button"
className="btn btn-ghost"
variant="ghost"
disabled={activeFilterCount === 0}
onClick={clearFilters}>
Clear filters
</button>
</Button>
</div>
</div>
}
</div>
{loading && <LoadingIndicator size="sm" label="Searching" />}
<DocumentationHelpLink
reference={{ topicId: "search.global-and-contextual", documentationType: "user" }}
label="Open search documentation"
/>
{activeFilterCount > 0 &&
<div className="search-active-filters" aria-label="Active search filters">
{modules.map((moduleId) =>
@@ -359,14 +365,15 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
)}
</div>
{response?.next_cursor &&
<button
<Button
type="button"
variant="secondary"
className="search-load-more"
disabled={loading}
onClick={() => loadResults(response.next_cursor ?? undefined)}>
<ChevronDown size={16} />
<span>Load more</span>
</button>
</Button>
}
</PageScrollViewport>
</main>
+27 -1
View File
@@ -1,5 +1,6 @@
import { createElement, lazy } from "react";
import type {
AdminSectionsUiCapability,
PlatformWebModule,
SearchRuntimeUiCapability
} from "@govoplan/core-webui";
@@ -8,12 +9,29 @@ import "./styles/search.css";
const SearchPage = lazy(() => import("./features/search/SearchPage"));
const SearchAdminPanel = lazy(() => import("./features/search/SearchAdminPanel"));
const searchRuntime: SearchRuntimeUiCapability = {
GlobalSearch,
anyOf: ["search:result:read"]
};
const searchAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "tenant-search-index",
moduleId: "search",
kind: "operations",
surfaceId: "search.admin.index",
label: "Search index",
group: "TENANT",
order: 69,
allOf: ["search:index:admin"],
render: ({ settings }) => createElement(SearchAdminPanel, { settings })
}
]
};
export const searchModule: PlatformWebModule = {
id: "search",
label: "Search",
@@ -50,10 +68,18 @@ export const searchModule: PlatformWebModule = {
kind: "route",
label: "Search results",
order: 20
},
{
id: "search.admin.index",
moduleId: "search",
kind: "section",
label: "Search index administration",
order: 30
}
],
uiCapabilities: {
"search.runtime": searchRuntime
"search.runtime": searchRuntime,
"admin.sections": searchAdminSections
}
};