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

+2
View File
@@ -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));
+109
View File
@@ -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,
});
}));
+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) =>
+252
View File
@@ -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<SecretType, string> = {
api_token: "API Token",
ssl_certificate: "SSL Certificate",
password: "Password",
generic: "Generic",
};
const INTEGRATION_TYPE_LABELS: Record<IntegrationType, string> = {
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<IntegrationType, string> = {
tailscale: "/tailscale",
proxmox: "/proxmox",
synology: "/synology",
semaphore: "/semaphore",
gitea: "/gitea",
dockhand: "/docker",
};
const DNS_PROVIDER_LABELS: Record<DnsProviderType, string> = {
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<PaletteItem[]>([]);
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<button
type="button"
className="btn btn-outline-secondary btn-sm w-100 d-flex align-items-center justify-content-center gap-2"
onClick={() => setOpen(true)}
title="Search (Ctrl/Cmd+K)"
>
<IconSearch size={16} />
Search
<kbd className="ms-auto">Ctrl K</kbd>
</button>
);
}
let lastCategory = "";
return (
<div className="modal modal-blur show" style={{ display: "block" }} onClick={() => setOpen(false)}>
<div className="modal-dialog modal-dialog-centered" style={{ maxWidth: 600 }} onClick={(e) => e.stopPropagation()}>
<div className="modal-content">
<div className="modal-body p-0">
<div className="p-2 border-bottom">
<div className="input-icon">
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
ref={inputRef}
type="text"
className="form-control form-control-lg border-0"
placeholder="Search servers, DNS, secrets, IPs, integrations…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
</div>
</div>
<div style={{ maxHeight: "60vh", overflowY: "auto" }}>
{loading && <div className="p-3 text-secondary">Searching…</div>}
{!loading && query.trim().length >= 2 && items.length === 0 && <div className="p-3 text-secondary">No matches.</div>}
{!loading && query.trim().length < 2 && <div className="p-3 text-secondary">Type at least 2 characters…</div>}
{items.map((item, index) => {
const showHeader = item.category !== lastCategory;
lastCategory = item.category;
return (
<div key={item.key}>
{showHeader && (
<div className="px-3 pt-2 pb-1 text-uppercase text-secondary" style={{ fontSize: "0.7rem" }}>
{item.category}
</div>
)}
<div
className={`px-3 py-2 d-flex justify-content-between align-items-center gap-3 ${index === activeIndex ? "bg-secondary-lt" : ""}`}
style={{ cursor: "pointer" }}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => go(item)}
>
<div className="text-truncate">{item.title}</div>
{item.subtitle && <div className="text-secondary small text-truncate">{item.subtitle}</div>}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
<div className="modal-backdrop show"></div>
</div>
);
}
+4
View File
@@ -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
<NavLink to="/">Homelab Manager</NavLink>
</h1>
<div className={`navbar-collapse collapse ${sidebarOpen ? "show" : ""}`}>
<div className="px-3 pt-3 pt-lg-0">
<CommandPalette />
</div>
<ul className="navbar-nav pt-lg-3">
{visibleItems.map((item) => (
<li className="nav-item" key={item.to}>
+14 -2
View File
@@ -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<DnsProviderSummary[] | null>(null);
const [managingProviders, setManagingProviders] = useState(false);
const [selectedProviderId, setSelectedProviderId] = useState<number | null>(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)));
}
+3 -1
View File
@@ -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<IpamEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [searchParams] = useSearchParams();
const [search, setSearch] = useState(() => searchParams.get("q") ?? "");
const [editing, setEditing] = useState<IpamEntry | null>(null);
const [adding, setAdding] = useState(false);
const [form, setForm] = useState<IpamInput>(emptyForm);
+3 -1
View File
@@ -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<SecretRecord[] | null>(null);
const [error, setError] = useState<string | null>(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<number | null>(null);
const [form, setForm] = useState<SecretInput>(emptyForm);