1"use client";
2
3
13import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
14import { Loader2, RefreshCw } from "lucide-react";
15import { orgAdminApi } from "@/lib/org-admin";
16import { isApiError } from "@/lib/api";
17import { toast } from "@/stores/ui-store";
18import { Section } from "@/components/settings/settings-shell";
19
20function fmtDuration(ms: number | null): string {
21 if (ms === null) return "—";
22 if (ms < 1000) return `${ms} ms`;
23 const s = Math.floor(ms / 1000);
24 if (s < 60) return `${s} s`;
25 const m = Math.floor(s / 60);
26 if (m < 60) return `${m} m ${s % 60} s`;
27 const h = Math.floor(m / 60);
28 if (h < 24) return `${h} h ${m % 60} m`;
29 return `${Math.floor(h / 24)} d ${h % 24} h`;
30}
31
32function fmtTime(iso: string | null): string {
33 if (!iso) return "unknown";
34 return new Date(iso).toLocaleString();
35}
36
37export function AdminSearchHealthTab({ orgId }: { orgId: string }) {
38 const qc = useQueryClient();
39 const health = useQuery({
40 queryKey: ["orgs", orgId, "admin", "search", "health"],
41 queryFn: () => orgAdminApi.searchHealth(orgId),
42 retry: false,
43 refetchInterval: 15_000,
44 });
45
46 const reindex = useMutation({
47 mutationFn: () => orgAdminApi.searchReindex(orgId),
48 onSuccess: (res) => {
49 toast("success", "Reindex enqueued — the worker will rebuild the index");
50 void res;
51 qc.invalidateQueries({ queryKey: ["orgs", orgId, "admin", "search", "health"] });
52 },
53 onError: (e) => toast("error", "Could not enqueue the reindex", isApiError(e) ? e.message : undefined)
54 });
55
56 if (health.isLoading) {
57 return <p className="text-sm text-muted-foreground" role="status">Loading search health…</p>;
58 }
59 if (health.isError) {
60 return <p className="text-sm text-destructive" role="alert">Could not load the search health.</p>;
61 }
62 const h = health.data!;
63
64 return (
65 <div className="space-y-6">
66 {!h.healthy ? (
67 <div data-testid="search-health-down" className="rounded-lg border border-destructive/40 bg-destructive/5 p-4" role="alert">
68 <div className="text-sm font-medium text-destructive">Search index unavailable</div>
69 <p className="mt-1 text-xs text-muted-foreground">
70 OpenSearch did not answer ({h.error ?? "unknown error"}). The counts below are from the Postgres source of
71 truth only.
72 </p>
73 </div>
74 ) : (
75 <div
76 data-testid="search-health-ok"
77 className="flex items-center gap-2 rounded-lg border border-border p-4 text-sm"
78 role="status"
79 >
80 <span className="size-2 rounded-full bg-emerald-500" aria-hidden />
81 Index reachable.
82 {h.drift !== null && h.drift !== 0 && (
83 <span className="text-xs text-muted-foreground">
84 Drift {h.drift > 0 ? "+" : ""}
85 {h.drift} docs vs the source of truth — consider a reindex.
86 </span>
87 )}
88 </div>
89 )}
90
91 <Section title="Counts" description="Source of truth: the Postgres searchDocument mirror (what reconcile compares against).">
92 <dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm sm:grid-cols-4">
93 <div>
94 <dt className="text-xs text-muted-foreground">Workspaces</dt>
95 <dd className="text-lg font-semibold">{h.workspaces}</dd>
96 </div>
97 <div>
98 <dt className="text-xs text-muted-foreground">Source docs</dt>
99 <dd className="text-lg font-semibold">{h.sourceCount}</dd>
100 </div>
101 <div>
102 <dt className="text-xs text-muted-foreground">Index docs</dt>
103 <dd className="text-lg font-semibold">{h.docCount ?? "—"}</dd>
104 </div>
105 <div>
106 <dt className="text-xs text-muted-foreground">Drift</dt>
107 <dd className="text-lg font-semibold">{h.drift === null ? "—" : h.drift === 0 ? "0" : `${h.drift > 0 ? "+" : ""}${h.drift}`}</dd>
108 </div>
109 </dl>
110 </Section>
111
112 <Section title="Freshness" description="Lag = now − the newest indexedAt stamp in the mirror.">
113 <dl className="grid grid-cols-1 gap-x-6 gap-y-3 text-sm sm:grid-cols-3">
114 <div>
115 <dt className="text-xs text-muted-foreground">Indexing lag</dt>
116 <dd className="text-lg font-semibold">{fmtDuration(h.lagMs)}</dd>
117 </div>
118 <div>
119 <dt className="text-xs text-muted-foreground">Last indexed</dt>
120 <dd className="text-sm font-medium">{fmtTime(h.lastIndexedAt)}</dd>
121 </div>
122 <div>
123 <dt className="text-xs text-muted-foreground">Last reconcile</dt>
124 <dd className="text-sm font-medium">{fmtTime(h.lastReconcileAt)}</dd>
125 </div>
126 </dl>
127 </Section>
128
129 <Section
130 title="Reindex"
131 description="Enqueue a full, org-scoped rebuild of the content index (T-156, async — the worker does the work)."
132 >
133 <button
134 type="button"
135 onClick={() => reindex.mutate()}
136 disabled={reindex.isPending}
137 data-testid="search-health-reindex"
138 className="inline-flex items-center gap-2 rounded-md border border-input px-3 py-1.5 text-sm font-medium hover:bg-muted disabled:opacity-50"
139 >
140 {reindex.isPending ? (
141 <Loader2 className="size-4 animate-spin" aria-hidden />
142 ) : (
143 <RefreshCw className="size-4" aria-hidden />
144 )}
145 {reindex.isPending ? "Enqueuing…" : "Reindex now"}
146 </button>
147 </Section>
148 </div>
149 );
150}
151