Complete permission-aware native search indexing
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
@@ -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.");
|
||||
@@ -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" }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user