From 009bb3e027ce90bb6c83a70051952496e32dacb7 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Sun, 20 Sep 2026 03:20:05 +0200 Subject: [PATCH] Add a global search / command palette (Ctrl/Cmd+K) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/index.ts | 2 + server/src/routes/search.ts | 109 +++++++++++ web/src/api/client.ts | 21 +++ web/src/components/CommandPalette.tsx | 252 ++++++++++++++++++++++++++ web/src/layout/AppShell.tsx | 4 + web/src/pages/Dns.tsx | 16 +- web/src/pages/Ipam.tsx | 4 +- web/src/pages/Secrets.tsx | 4 +- 8 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 server/src/routes/search.ts create mode 100644 web/src/components/CommandPalette.tsx diff --git a/server/src/index.ts b/server/src/index.ts index f81865d..86d8611 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -21,6 +21,7 @@ import { tasksRouter } from "./routes/tasks.js"; import { agentReportRouter } from "./routes/agentReport.js"; import { integrationsRouter } from "./routes/integrations.js"; import { settingsRouter } from "./routes/settings.js"; +import { searchRouter } from "./routes/search.js"; import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js"; import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js"; import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js"; @@ -78,6 +79,7 @@ app.use("/api/tasks", tasksRouter); app.use("/api/agent/report", agentReportRouter); app.use("/api/integrations", integrationsRouter); app.use("/api/settings", settingsRouter); +app.use("/api/search", searchRouter); if (existsSync(webDist)) { app.use(express.static(webDist)); diff --git a/server/src/routes/search.ts b/server/src/routes/search.ts new file mode 100644 index 0000000..e8bcd08 --- /dev/null +++ b/server/src/routes/search.ts @@ -0,0 +1,109 @@ +import { Router } from "express"; +import { and, eq, like, or } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { servers, secrets, ipamEntries, integrations, dnsProviders, dnsZonesCache, dnsRecordsCache } from "../db/schema.js"; +import { requireAuth } from "../auth/middleware.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +export const searchRouter = Router(); + +// Every list endpoint this aggregates (servers, secrets, IPAM, DNS, +// integrations) only requires requireAuth itself — viewer role included — +// so this can safely search across all of them under the same check. +searchRouter.use(requireAuth); + +const RESULT_LIMIT = 8; +const MIN_QUERY_LENGTH = 2; + +const EMPTY_RESULTS = { + servers: [], + secrets: [], + ipam: [], + integrations: [], + dnsProviders: [], + dnsZones: [], + dnsRecords: [], +}; + +searchRouter.get("/", asyncHandler(async (req, res) => { + const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; + if (q.length < MIN_QUERY_LENGTH) { + return res.json(EMPTY_RESULTS); + } + const term = `%${q}%`; + + const [serverRows, secretRows, ipamRows, integrationRows, providerRows, zoneRows, recordRows] = await Promise.all([ + db + .select({ id: servers.id, name: servers.name, hostname: servers.hostname }) + .from(servers) + .where(or(like(servers.name, term), like(servers.hostname, term), like(servers.description, term))) + .limit(RESULT_LIMIT), + db + .select({ id: secrets.id, name: secrets.name, type: secrets.type }) + .from(secrets) + .where(or(like(secrets.name, term), like(secrets.description, term), like(secrets.notes, term))) + .limit(RESULT_LIMIT), + db + .select({ id: ipamEntries.id, ipAddress: ipamEntries.ipAddress, label: ipamEntries.label }) + .from(ipamEntries) + .where( + or( + like(ipamEntries.ipAddress, term), + like(ipamEntries.label, term), + like(ipamEntries.vendor, term), + like(ipamEntries.location, term), + like(ipamEntries.notes, term), + ), + ) + .limit(RESULT_LIMIT), + db + .select({ id: integrations.id, type: integrations.type, name: integrations.name }) + .from(integrations) + .where(like(integrations.name, term)) + .limit(RESULT_LIMIT), + db + .select({ id: dnsProviders.id, providerType: dnsProviders.providerType, name: dnsProviders.name }) + .from(dnsProviders) + .where(like(dnsProviders.name, term)) + .limit(RESULT_LIMIT), + db + .select({ + providerId: dnsZonesCache.providerId, + zoneId: dnsZonesCache.zoneId, + zoneName: dnsZonesCache.zoneName, + providerName: dnsProviders.name, + }) + .from(dnsZonesCache) + .innerJoin(dnsProviders, eq(dnsZonesCache.providerId, dnsProviders.id)) + .where(like(dnsZonesCache.zoneName, term)) + .limit(RESULT_LIMIT), + db + .select({ + providerId: dnsRecordsCache.providerId, + zoneId: dnsRecordsCache.zoneId, + zoneName: dnsZonesCache.zoneName, + type: dnsRecordsCache.type, + name: dnsRecordsCache.name, + content: dnsRecordsCache.content, + providerName: dnsProviders.name, + }) + .from(dnsRecordsCache) + .innerJoin(dnsProviders, eq(dnsRecordsCache.providerId, dnsProviders.id)) + .innerJoin( + dnsZonesCache, + and(eq(dnsRecordsCache.providerId, dnsZonesCache.providerId), eq(dnsRecordsCache.zoneId, dnsZonesCache.zoneId)), + ) + .where(or(like(dnsRecordsCache.name, term), like(dnsRecordsCache.content, term))) + .limit(RESULT_LIMIT), + ]); + + res.json({ + servers: serverRows, + secrets: secretRows, + ipam: ipamRows, + integrations: integrationRows, + dnsProviders: providerRows, + dnsZones: zoneRows, + dnsRecords: recordRows, + }); +})); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index df40430..a5cf27d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(path: string, init?: RequestInit): Promise { export const api = { me: () => request<{ user: CurrentUser }>("/api/me"), + search: { + query: (q: string) => request(`/api/search?q=${encodeURIComponent(q)}`), + }, users: { list: () => request<{ users: UserRecord[] }>("/api/users"), updateRole: (id: number, role: UserRole) => diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..f39abb6 --- /dev/null +++ b/web/src/components/CommandPalette.tsx @@ -0,0 +1,252 @@ +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { IconSearch } from "@tabler/icons-react"; +import { api, type SearchResponse, type IntegrationType, type DnsProviderType, type SecretType } from "../api/client"; + +const SECRET_TYPE_LABELS: Record = { + api_token: "API Token", + ssl_certificate: "SSL Certificate", + password: "Password", + generic: "Generic", +}; + +const INTEGRATION_TYPE_LABELS: Record = { + tailscale: "Tailscale", + proxmox: "Proxmox", + synology: "Synology", + semaphore: "Semaphore", + gitea: "Gitea", + dockhand: "Dockhand", +}; + +// No per-integration-instance route exists yet — every integration of a +// given type links to that type's single dashboard page. +const INTEGRATION_TYPE_PATH: Record = { + tailscale: "/tailscale", + proxmox: "/proxmox", + synology: "/synology", + semaphore: "/semaphore", + gitea: "/gitea", + dockhand: "/docker", +}; + +const DNS_PROVIDER_LABELS: Record = { + cloudflare: "Cloudflare", + loopia: "Loopia", + pihole: "Pi-hole", + azure: "Azure DNS", + cpanel: "cPanel", + technitium: "Technitium", +}; + +interface PaletteItem { + key: string; + category: string; + title: string; + subtitle: string; + path: string; +} + +function buildItems(res: SearchResponse): PaletteItem[] { + const items: PaletteItem[] = []; + for (const s of res.servers) { + items.push({ key: `server-${s.id}`, category: "Servers", title: s.name, subtitle: s.hostname ?? "", path: `/servers/${s.id}` }); + } + for (const s of res.secrets) { + items.push({ + key: `secret-${s.id}`, + category: "Secrets", + title: s.name, + subtitle: SECRET_TYPE_LABELS[s.type], + path: `/secrets?q=${encodeURIComponent(s.name)}`, + }); + } + for (const i of res.ipam) { + items.push({ + key: `ipam-${i.id}`, + category: "IP Addresses", + title: i.ipAddress, + subtitle: i.label ?? "", + path: `/ipam?q=${encodeURIComponent(i.ipAddress)}`, + }); + } + for (const z of res.dnsZones) { + items.push({ + key: `zone-${z.providerId}-${z.zoneId}`, + category: "DNS zones", + title: z.zoneName, + subtitle: z.providerName, + path: `/dns?providerId=${z.providerId}&zoneId=${encodeURIComponent(z.zoneId)}`, + }); + } + for (const r of res.dnsRecords) { + items.push({ + key: `record-${r.providerId}-${r.zoneId}-${r.type}-${r.name}-${r.content}`, + category: "DNS records", + title: `${r.type} ${r.name}`, + subtitle: `${r.content} · zone ${r.zoneName} · ${r.providerName}`, + path: `/dns?providerId=${r.providerId}&zoneId=${encodeURIComponent(r.zoneId)}`, + }); + } + for (const p of res.dnsProviders) { + items.push({ + key: `provider-${p.id}`, + category: "DNS providers", + title: p.name, + subtitle: DNS_PROVIDER_LABELS[p.providerType], + path: `/dns?providerId=${p.id}`, + }); + } + for (const i of res.integrations) { + items.push({ + key: `integration-${i.id}`, + category: "Integrations", + title: i.name, + subtitle: INTEGRATION_TYPE_LABELS[i.type], + path: INTEGRATION_TYPE_PATH[i.type], + }); + } + return items; +} + +export default function CommandPalette() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const navigate = useNavigate(); + + // Global Ctrl/Cmd+K opens the palette from anywhere in the app; Escape closes it. + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOpen((o) => !o); + } else if (e.key === "Escape") { + setOpen(false); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + useEffect(() => { + if (open) { + setQuery(""); + setItems([]); + setActiveIndex(0); + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [open]); + + useEffect(() => { + if (!open || query.trim().length < 2) { + setItems([]); + return; + } + setLoading(true); + const handle = setTimeout(() => { + api.search + .query(query.trim()) + .then((res) => { + setItems(buildItems(res)); + setActiveIndex(0); + }) + .catch(() => setItems([])) + .finally(() => setLoading(false)); + }, 200); + return () => clearTimeout(handle); + }, [query, open]); + + function go(item: PaletteItem) { + setOpen(false); + navigate(item.path); + } + + function handleInputKeyDown(e: ReactKeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, items.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (items[activeIndex]) go(items[activeIndex]); + } + } + + if (!open) { + return ( + + ); + } + + let lastCategory = ""; + + return ( +
setOpen(false)}> +
e.stopPropagation()}> +
+
+
+
+ + + + setQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> +
+
+
+ {loading &&
Searching…
} + {!loading && query.trim().length >= 2 && items.length === 0 &&
No matches.
} + {!loading && query.trim().length < 2 &&
Type at least 2 characters…
} + {items.map((item, index) => { + const showHeader = item.category !== lastCategory; + lastCategory = item.category; + return ( +
+ {showHeader && ( +
+ {item.category} +
+ )} +
setActiveIndex(index)} + onClick={() => go(item)} + > +
{item.title}
+ {item.subtitle &&
{item.subtitle}
} +
+
+ ); + })} +
+
+
+
+
+
+ ); +} diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index 39eddd1..f2e4839 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -23,6 +23,7 @@ import { } from "@tabler/icons-react"; import type { CurrentUser } from "../api/client"; import { getTheme, setTheme, type Theme } from "../utils/theme"; +import CommandPalette from "../components/CommandPalette"; interface NavItem { to: string; @@ -81,6 +82,9 @@ export default function AppShell({ user, children }: { user: CurrentUser; childr Homelab Manager
+
+ +
    {visibleItems.map((item) => (
  • diff --git a/web/src/pages/Dns.tsx b/web/src/pages/Dns.tsx index cc80f87..57ab815 100644 --- a/web/src/pages/Dns.tsx +++ b/web/src/pages/Dns.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type CSSProperties } from "react"; +import { useSearchParams } from "react-router-dom"; import { api, type CurrentUser, @@ -37,6 +38,7 @@ export default function Dns({ user }: { user: CurrentUser }) { const isAdmin = user.role === "admin"; const canEdit = user.role === "admin" || user.role === "operator"; + const [searchParams] = useSearchParams(); const [providers, setProviders] = useState(null); const [managingProviders, setManagingProviders] = useState(false); const [selectedProviderId, setSelectedProviderId] = useState(null); @@ -64,7 +66,11 @@ export default function Dns({ user }: { user: CurrentUser }) { .list() .then((res) => { setProviders(res.providers); - if (!selectedProviderId && res.providers.length > 0) setSelectedProviderId(res.providers[0].id); + if (!selectedProviderId && res.providers.length > 0) { + const linkedId = Number(searchParams.get("providerId")); + const target = res.providers.find((p) => p.id === linkedId) ?? res.providers[0]; + setSelectedProviderId(target.id); + } }) .catch((err) => setError(String(err))); } @@ -78,7 +84,13 @@ export default function Dns({ user }: { user: CurrentUser }) { setRecords(null); api.dns.zones .list(providerId) - .then((res) => setZones(res.zones)) + .then((res) => { + setZones(res.zones); + // Deep-link from global search (?providerId=&zoneId=) — auto-open the linked zone once its records load. + const linkedZoneId = searchParams.get("zoneId"); + const linkedZone = linkedZoneId ? res.zones.find((z) => z.id === linkedZoneId) : undefined; + if (linkedZone) selectZone(linkedZone); + }) .catch((err) => setZonesError(err instanceof Error ? err.message : String(err))); } diff --git a/web/src/pages/Ipam.tsx b/web/src/pages/Ipam.tsx index 8c2e2f8..f3d144d 100644 --- a/web/src/pages/Ipam.tsx +++ b/web/src/pages/Ipam.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { api, type CurrentUser, type IpamEntry, type IpamInput } from "../api/client"; import { downloadCsv } from "../utils/csv"; import { useSortable } from "../hooks/useSortable"; @@ -16,7 +17,8 @@ export default function Ipam({ user }: { user: CurrentUser }) { const canEdit = user.role === "admin" || user.role === "operator"; const [entries, setEntries] = useState(null); const [error, setError] = useState(null); - const [search, setSearch] = useState(""); + const [searchParams] = useSearchParams(); + const [search, setSearch] = useState(() => searchParams.get("q") ?? ""); const [editing, setEditing] = useState(null); const [adding, setAdding] = useState(false); const [form, setForm] = useState(emptyForm); diff --git a/web/src/pages/Secrets.tsx b/web/src/pages/Secrets.tsx index 51dcf63..caa59bf 100644 --- a/web/src/pages/Secrets.tsx +++ b/web/src/pages/Secrets.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { api, type CurrentUser, type SecretInput, type SecretRecord, type SecretStatus } from "../api/client"; import { downloadCsv } from "../utils/csv"; import { useSortable } from "../hooks/useSortable"; @@ -32,7 +33,8 @@ export default function Secrets({ user }: { user: CurrentUser }) { const canEdit = user.role === "admin" || user.role === "operator"; const [secrets, setSecrets] = useState(null); const [error, setError] = useState(null); - const [search, setSearch] = useState(""); + const [searchParams] = useSearchParams(); + const [search, setSearch] = useState(() => searchParams.get("q") ?? ""); const [statusFilter, setStatusFilter] = useState<"all" | SecretStatus>("all"); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(emptyForm);