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:
1 parent
23eb7f0d70
commit
009bb3e027
8 files changed
+408
-4
No files matched your search
@@ -21,6 +21,7 @@ import { tasksRouter } from "./routes/tasks.js";
|
|||||||
import { agentReportRouter } from "./routes/agentReport.js";
|
import { agentReportRouter } from "./routes/agentReport.js";
|
||||||
import { integrationsRouter } from "./routes/integrations.js";
|
import { integrationsRouter } from "./routes/integrations.js";
|
||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
|
import { searchRouter } from "./routes/search.js";
|
||||||
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
||||||
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
||||||
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.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/agent/report", agentReportRouter);
|
||||||
app.use("/api/integrations", integrationsRouter);
|
app.use("/api/integrations", integrationsRouter);
|
||||||
app.use("/api/settings", settingsRouter);
|
app.use("/api/settings", settingsRouter);
|
||||||
|
app.use("/api/search", searchRouter);
|
||||||
|
|
||||||
if (existsSync(webDist)) {
|
if (existsSync(webDist)) {
|
||||||
app.use(express.static(webDist));
|
app.use(express.static(webDist));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}));
|
||||||
@@ -532,6 +532,24 @@ export interface SynologySystemInfo {
|
|||||||
memory: { totalBytes: number | null; usedBytes: number | null };
|
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 UnauthorizedError extends Error {}
|
||||||
export class ForbiddenError extends Error {}
|
export class ForbiddenError extends Error {}
|
||||||
|
|
||||||
@@ -557,6 +575,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
me: () => request<{ user: CurrentUser }>("/api/me"),
|
me: () => request<{ user: CurrentUser }>("/api/me"),
|
||||||
|
search: {
|
||||||
|
query: (q: string) => request<SearchResponse>(`/api/search?q=${encodeURIComponent(q)}`),
|
||||||
|
},
|
||||||
users: {
|
users: {
|
||||||
list: () => request<{ users: UserRecord[] }>("/api/users"),
|
list: () => request<{ users: UserRecord[] }>("/api/users"),
|
||||||
updateRole: (id: number, role: UserRole) =>
|
updateRole: (id: number, role: UserRole) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import type { CurrentUser } from "../api/client";
|
import type { CurrentUser } from "../api/client";
|
||||||
import { getTheme, setTheme, type Theme } from "../utils/theme";
|
import { getTheme, setTheme, type Theme } from "../utils/theme";
|
||||||
|
import CommandPalette from "../components/CommandPalette";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -81,6 +82,9 @@ export default function AppShell({ user, children }: { user: CurrentUser; childr
|
|||||||
<NavLink to="/">Homelab Manager</NavLink>
|
<NavLink to="/">Homelab Manager</NavLink>
|
||||||
</h1>
|
</h1>
|
||||||
<div className={`navbar-collapse collapse ${sidebarOpen ? "show" : ""}`}>
|
<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">
|
<ul className="navbar-nav pt-lg-3">
|
||||||
{visibleItems.map((item) => (
|
{visibleItems.map((item) => (
|
||||||
<li className="nav-item" key={item.to}>
|
<li className="nav-item" key={item.to}>
|
||||||
|
|||||||
+14
-2
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, type CSSProperties } from "react";
|
import { useEffect, useState, type CSSProperties } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type CurrentUser,
|
type CurrentUser,
|
||||||
@@ -37,6 +38,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
|||||||
const isAdmin = user.role === "admin";
|
const isAdmin = user.role === "admin";
|
||||||
const canEdit = user.role === "admin" || user.role === "operator";
|
const canEdit = user.role === "admin" || user.role === "operator";
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [providers, setProviders] = useState<DnsProviderSummary[] | null>(null);
|
const [providers, setProviders] = useState<DnsProviderSummary[] | null>(null);
|
||||||
const [managingProviders, setManagingProviders] = useState(false);
|
const [managingProviders, setManagingProviders] = useState(false);
|
||||||
const [selectedProviderId, setSelectedProviderId] = useState<number | null>(null);
|
const [selectedProviderId, setSelectedProviderId] = useState<number | null>(null);
|
||||||
@@ -64,7 +66,11 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
|||||||
.list()
|
.list()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setProviders(res.providers);
|
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)));
|
.catch((err) => setError(String(err)));
|
||||||
}
|
}
|
||||||
@@ -78,7 +84,13 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
|||||||
setRecords(null);
|
setRecords(null);
|
||||||
api.dns.zones
|
api.dns.zones
|
||||||
.list(providerId)
|
.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)));
|
.catch((err) => setZonesError(err instanceof Error ? err.message : String(err)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { api, type CurrentUser, type IpamEntry, type IpamInput } from "../api/client";
|
import { api, type CurrentUser, type IpamEntry, type IpamInput } from "../api/client";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
import { useSortable } from "../hooks/useSortable";
|
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 canEdit = user.role === "admin" || user.role === "operator";
|
||||||
const [entries, setEntries] = useState<IpamEntry[] | null>(null);
|
const [entries, setEntries] = useState<IpamEntry[] | null>(null);
|
||||||
const [error, setError] = useState<string | 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 [editing, setEditing] = useState<IpamEntry | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [form, setForm] = useState<IpamInput>(emptyForm);
|
const [form, setForm] = useState<IpamInput>(emptyForm);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
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 { api, type CurrentUser, type SecretInput, type SecretRecord, type SecretStatus } from "../api/client";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
import { useSortable } from "../hooks/useSortable";
|
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 canEdit = user.role === "admin" || user.role === "operator";
|
||||||
const [secrets, setSecrets] = useState<SecretRecord[] | null>(null);
|
const [secrets, setSecrets] = useState<SecretRecord[] | null>(null);
|
||||||
const [error, setError] = useState<string | 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 [statusFilter, setStatusFilter] = useState<"all" | SecretStatus>("all");
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [form, setForm] = useState<SecretInput>(emptyForm);
|
const [form, setForm] = useState<SecretInput>(emptyForm);
|
||||||
|
|||||||
Reference in new issue
Block a user