Files
govoplan-search/webui/src/features/search/SearchAdminPanel.tsx
T

292 lines
8.9 KiB
TypeScript

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();
}