292 lines
8.9 KiB
TypeScript
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();
|
|
}
|