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);