Add a global search / command palette (Ctrl/Cmd+K)

With 6 integrations, DNS, secrets, IPAM, and servers all in one app,
there was no single place to type a hostname/IP/name and jump
straight to it. New GET /api/search aggregates a LIKE-based search
across servers, secrets, IPAM, integrations, DNS providers, DNS
zones, and DNS records (joining zone/provider names onto each record
result) in one round trip — homelab-scale row counts make a naive
LIKE scan plenty fast, no FTS needed. Every underlying resource's own
list endpoint already only requires requireAuth (viewer role
included), so the aggregate endpoint uses the same single check.

Frontend: a self-contained CommandPalette component (Tabler's modal
CSS classes driven by React state, since the app doesn't load
Bootstrap's JS) opens via a sidebar search button or Ctrl/Cmd+K from
anywhere, debounces input, and supports arrow-key navigation. Results
link to the right page: servers to their existing /servers/:id
detail route; DNS zone/record results deep-link via new ?providerId=
&zoneId= query-param handling added to Dns.tsx (auto-selects that
provider/zone on load, since Dns.tsx previously held selection only
in local state with no URL sync); secrets/IPAM results link with
?q= to prefill each page's existing client-side search box;
integrations link to their type's dashboard page (no per-instance
route exists yet, so same-type integrations share one link).

Verified the query logic (joins, case-insensitive LIKE, correct
zone/provider name resolution) against an isolated scratch database
seeded with realistic cross-referencing rows — a server name match, a
case-mismatched match against both a secret and a DNS provider
sharing "cloudflare", an IP address matching both an IPAM entry and
the DNS A record pointing at it (confirming the record's joined zone
and provider names came through correctly), an integration name
match, and a no-match query returning every category empty. Did not
re-verify the requireAuth/asyncHandler wiring itself, since it's the
same one-line pattern already proven across every other router in
this app. Confirmed the real dev database's mtime was untouched
throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-20 03:20:05 +02:00
1 parent 23eb7f0d70
commit 009bb3e027
8 files changed
+408 -4

No files matched your search

+21
View File
@@ -532,6 +532,24 @@ export interface SynologySystemInfo {
memory: { totalBytes: number | null; usedBytes: number | null };
}
export interface SearchResponse {
servers: { id: number; name: string; hostname: string | null }[];
secrets: { id: number; name: string; type: SecretType }[];
ipam: { id: number; ipAddress: string; label: string | null }[];
integrations: { id: number; type: IntegrationType; name: string }[];
dnsProviders: { id: number; providerType: DnsProviderType; name: string }[];
dnsZones: { providerId: number; zoneId: string; zoneName: string; providerName: string }[];
dnsRecords: {
providerId: number;
zoneId: string;
zoneName: string;
type: string;
name: string;
content: string;
providerName: string;
}[];
}
export class UnauthorizedError extends Error {}
export class ForbiddenError extends Error {}
@@ -557,6 +575,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
me: () => request<{ user: CurrentUser }>("/api/me"),
search: {
query: (q: string) => request<SearchResponse>(`/api/search?q=${encodeURIComponent(q)}`),
},
users: {
list: () => request<{ users: UserRecord[] }>("/api/users"),
updateRole: (id: number, role: UserRole) =>