diff --git a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx index a5ff2d24..f8e92acc 100644 --- a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx +++ b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx @@ -1,6 +1,7 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { IconButton, Stack, Switch, Tooltip, Typography } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -22,36 +23,39 @@ type Props = { accessLists: AccessList[]; authentikDefaults: AuthentikSettings | null; pagination: { total: number; page: number; perPage: number }; + initialSearch: string; }; -export default function ProxyHostsClient({ hosts, certificates, accessLists, authentikDefaults, pagination }: Props) { +export default function ProxyHostsClient({ hosts, certificates, accessLists, authentikDefaults, pagination, initialSearch }: Props) { const [createOpen, setCreateOpen] = useState(false); const [duplicateHost, setDuplicateHost] = useState(null); const [editHost, setEditHost] = useState(null); const [deleteHost, setDeleteHost] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); + const [searchTerm, setSearchTerm] = useState(initialSearch); - const filteredHosts = useMemo(() => { - if (!searchTerm.trim()) return hosts; + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const debounceRef = useRef | null>(null); - const search = searchTerm.toLowerCase(); - return hosts.filter((host) => { - // Search in name - if (host.name.toLowerCase().includes(search)) return true; - // Search in domains - if (host.domains.some(domain => domain.toLowerCase().includes(search))) return true; - // Search in upstreams - if (host.upstreams.some(upstream => upstream.toLowerCase().includes(search))) return true; + useEffect(() => { + setSearchTerm(initialSearch); + }, [initialSearch]); - const certificate = host.certificate_id - ? certificates.find(c => c.id === host.certificate_id) - : null; - const certName = certificate?.name ?? "Managed by Caddy (Auto)"; - if (certName.toLowerCase().includes(search)) return true; - - return false; - }); - }, [hosts, certificates, searchTerm]); + function handleSearchChange(value: string) { + setSearchTerm(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + const params = new URLSearchParams(searchParams.toString()); + if (value.trim()) { + params.set("search", value.trim()); + } else { + params.delete("search"); + } + params.set("page", "1"); + router.push(`${pathname}?${params.toString()}`); + }, 400); + } const handleToggleEnabled = async (id: number, enabled: boolean) => { await toggleProxyHostAction(id, enabled); @@ -151,13 +155,13 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, aut setSearchTerm(e.target.value)} + onChange={(e) => handleSearchChange(e.target.value)} placeholder="Search hosts..." /> ; + searchParams: Promise<{ page?: string; search?: string }>; } export default async function ProxyHostsPage({ searchParams }: PageProps) { await requireAdmin(); - const { page: pageParam } = await searchParams; + const { page: pageParam, search: searchParam } = await searchParams; const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1); + const search = searchParam?.trim() || undefined; const offset = (page - 1) * PER_PAGE; const [hosts, total, certificates, accessLists, authentikDefaults] = await Promise.all([ - listProxyHostsPaginated(PER_PAGE, offset), - countProxyHosts(), + listProxyHostsPaginated(PER_PAGE, offset, search), + countProxyHosts(search), listCertificates(), listAccessLists(), getAuthentikSettings(), @@ -32,6 +33,7 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) { accessLists={accessLists} authentikDefaults={authentikDefaults} pagination={{ total, page, perPage: PER_PAGE }} + initialSearch={search ?? ""} /> ); } diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts index df38331a..dfce6fac 100644 --- a/src/lib/models/proxy-hosts.ts +++ b/src/lib/models/proxy-hosts.ts @@ -2,7 +2,7 @@ import db, { nowIso, toIso } from "../db"; import { applyCaddyConfig } from "../caddy"; import { logAuditEvent } from "../audit"; import { proxyHosts } from "../db/schema"; -import { desc, eq, count } from "drizzle-orm"; +import { desc, eq, count, ilike, or } from "drizzle-orm"; import { getAuthentikSettings, getGeoBlockSettings, GeoBlockSettings } from "../settings"; const DEFAULT_AUTHENTIK_HEADERS = [ @@ -1329,15 +1329,30 @@ export async function listProxyHosts(): Promise { return hosts.map(parseProxyHost); } -export async function countProxyHosts(): Promise { - const [row] = await db.select({ value: count() }).from(proxyHosts); +export async function countProxyHosts(search?: string): Promise { + const where = search + ? or( + ilike(proxyHosts.name, `%${search}%`), + ilike(proxyHosts.domains, `%${search}%`), + ilike(proxyHosts.upstreams, `%${search}%`) + ) + : undefined; + const [row] = await db.select({ value: count() }).from(proxyHosts).where(where); return row?.value ?? 0; } -export async function listProxyHostsPaginated(limit: number, offset: number): Promise { +export async function listProxyHostsPaginated(limit: number, offset: number, search?: string): Promise { + const where = search + ? or( + ilike(proxyHosts.name, `%${search}%`), + ilike(proxyHosts.domains, `%${search}%`), + ilike(proxyHosts.upstreams, `%${search}%`) + ) + : undefined; const hosts = await db .select() .from(proxyHosts) + .where(where) .orderBy(desc(proxyHosts.createdAt)) .limit(limit) .offset(offset);