mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-27 01:53:38 +00:00
429 lines
13 KiB
JavaScript
429 lines
13 KiB
JavaScript
import { GitBranch, Package, Search, Server, User, X } from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { searchAPI } from "../utils/api";
|
|
|
|
const GlobalSearch = () => {
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState(null);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
const searchRef = useRef(null);
|
|
const inputRef = useRef(null);
|
|
const navigate = useNavigate();
|
|
|
|
// Debounce search
|
|
const debounceTimerRef = useRef(null);
|
|
|
|
const performSearch = useCallback(async (searchQuery) => {
|
|
if (!searchQuery || searchQuery.trim().length === 0) {
|
|
setResults(null);
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await searchAPI.global(searchQuery);
|
|
setResults(response.data);
|
|
setIsOpen(true);
|
|
setSelectedIndex(-1);
|
|
} catch (error) {
|
|
console.error("Search error:", error);
|
|
setResults(null);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleInputChange = (e) => {
|
|
const value = e.target.value;
|
|
setQuery(value);
|
|
|
|
// Clear previous timer
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
|
|
// Set new timer
|
|
debounceTimerRef.current = setTimeout(() => {
|
|
performSearch(value);
|
|
}, 300);
|
|
};
|
|
|
|
const handleClear = () => {
|
|
// Clear debounce timer to prevent any pending searches
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
setQuery("");
|
|
setResults(null);
|
|
setIsOpen(false);
|
|
setSelectedIndex(-1);
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const handleResultClick = (result) => {
|
|
// Navigate based on result type
|
|
switch (result.type) {
|
|
case "host":
|
|
navigate(`/hosts/${result.id}`);
|
|
break;
|
|
case "package":
|
|
navigate(`/packages/${result.id}`);
|
|
break;
|
|
case "repository":
|
|
navigate(`/repositories/${result.id}`);
|
|
break;
|
|
case "user":
|
|
// Users don't have detail pages, so navigate to settings
|
|
navigate("/settings/users");
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
// Close dropdown and clear
|
|
handleClear();
|
|
};
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event) => {
|
|
if (searchRef.current && !searchRef.current.contains(event.target)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, []);
|
|
|
|
// Keyboard navigation
|
|
const flattenedResults = [];
|
|
if (results) {
|
|
if (results.hosts?.length > 0) {
|
|
flattenedResults.push({ type: "header", label: "Hosts" });
|
|
flattenedResults.push(...results.hosts);
|
|
}
|
|
if (results.packages?.length > 0) {
|
|
flattenedResults.push({ type: "header", label: "Packages" });
|
|
flattenedResults.push(...results.packages);
|
|
}
|
|
if (results.repositories?.length > 0) {
|
|
flattenedResults.push({ type: "header", label: "Repositories" });
|
|
flattenedResults.push(...results.repositories);
|
|
}
|
|
if (results.users?.length > 0) {
|
|
flattenedResults.push({ type: "header", label: "Users" });
|
|
flattenedResults.push(...results.users);
|
|
}
|
|
}
|
|
|
|
const navigableResults = flattenedResults.filter((r) => r.type !== "header");
|
|
|
|
const handleKeyDown = (e) => {
|
|
if (!isOpen || !results) return;
|
|
|
|
switch (e.key) {
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) =>
|
|
prev < navigableResults.length - 1 ? prev + 1 : prev,
|
|
);
|
|
break;
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
|
break;
|
|
case "Enter":
|
|
e.preventDefault();
|
|
if (selectedIndex >= 0 && navigableResults[selectedIndex]) {
|
|
handleResultClick(navigableResults[selectedIndex]);
|
|
}
|
|
break;
|
|
case "Escape":
|
|
e.preventDefault();
|
|
setIsOpen(false);
|
|
setSelectedIndex(-1);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
|
|
// Get icon for result type
|
|
const getResultIcon = (type) => {
|
|
switch (type) {
|
|
case "host":
|
|
return <Server className="h-4 w-4 text-blue-500" />;
|
|
case "package":
|
|
return <Package className="h-4 w-4 text-green-500" />;
|
|
case "repository":
|
|
return <GitBranch className="h-4 w-4 text-purple-500" />;
|
|
case "user":
|
|
return <User className="h-4 w-4 text-orange-500" />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Get display text for result
|
|
const getResultDisplay = (result) => {
|
|
switch (result.type) {
|
|
case "host":
|
|
return {
|
|
primary: result.friendly_name || result.hostname,
|
|
secondary: result.ip || result.hostname,
|
|
};
|
|
case "package":
|
|
return {
|
|
primary: result.name,
|
|
secondary: result.description || result.category,
|
|
};
|
|
case "repository":
|
|
return {
|
|
primary: result.name,
|
|
secondary: result.distribution,
|
|
};
|
|
case "user":
|
|
return {
|
|
primary: result.username,
|
|
secondary: result.email,
|
|
};
|
|
default:
|
|
return { primary: "", secondary: "" };
|
|
}
|
|
};
|
|
|
|
const hasResults =
|
|
results &&
|
|
(results.hosts?.length > 0 ||
|
|
results.packages?.length > 0 ||
|
|
results.repositories?.length > 0 ||
|
|
results.users?.length > 0);
|
|
|
|
return (
|
|
<div ref={searchRef} className="relative w-full max-w-sm">
|
|
<div className="relative">
|
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
<Search className="h-5 w-5 text-secondary-400" />
|
|
</div>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
className="block w-full rounded-lg border border-secondary-200 bg-white py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400"
|
|
placeholder="Search hosts, packages, repos, users..."
|
|
value={query}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={() => {
|
|
if (query && results) setIsOpen(true);
|
|
}}
|
|
/>
|
|
{query && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClear}
|
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Dropdown Results */}
|
|
{isOpen && (
|
|
<div className="absolute z-50 mt-2 w-full rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800">
|
|
{isLoading ? (
|
|
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
|
Searching...
|
|
</div>
|
|
) : hasResults ? (
|
|
<div className="max-h-96 overflow-y-auto">
|
|
{/* Hosts */}
|
|
{results.hosts?.length > 0 && (
|
|
<div>
|
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
|
Hosts
|
|
</div>
|
|
{results.hosts.map((host, _idx) => {
|
|
const display = getResultDisplay(host);
|
|
const globalIdx = navigableResults.findIndex(
|
|
(r) => r.id === host.id && r.type === "host",
|
|
);
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={host.id}
|
|
onClick={() => handleResultClick(host)}
|
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
|
globalIdx === selectedIndex
|
|
? "bg-primary-50 dark:bg-primary-900/20"
|
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
|
}`}
|
|
>
|
|
{getResultIcon("host")}
|
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
|
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
|
{display.primary}
|
|
</span>
|
|
<span className="text-xs text-secondary-400">•</span>
|
|
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
|
{display.secondary}
|
|
</span>
|
|
</div>
|
|
<div className="flex-shrink-0 text-xs text-secondary-400">
|
|
{host.os_type}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Packages */}
|
|
{results.packages?.length > 0 && (
|
|
<div>
|
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
|
Packages
|
|
</div>
|
|
{results.packages.map((pkg, _idx) => {
|
|
const display = getResultDisplay(pkg);
|
|
const globalIdx = navigableResults.findIndex(
|
|
(r) => r.id === pkg.id && r.type === "package",
|
|
);
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={pkg.id}
|
|
onClick={() => handleResultClick(pkg)}
|
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
|
globalIdx === selectedIndex
|
|
? "bg-primary-50 dark:bg-primary-900/20"
|
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
|
}`}
|
|
>
|
|
{getResultIcon("package")}
|
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
|
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
|
{display.primary}
|
|
</span>
|
|
{display.secondary && (
|
|
<>
|
|
<span className="text-xs text-secondary-400">
|
|
•
|
|
</span>
|
|
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
|
{display.secondary}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex-shrink-0 text-xs text-secondary-400">
|
|
{pkg.host_count} hosts
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Repositories */}
|
|
{results.repositories?.length > 0 && (
|
|
<div>
|
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
|
Repositories
|
|
</div>
|
|
{results.repositories.map((repo, _idx) => {
|
|
const display = getResultDisplay(repo);
|
|
const globalIdx = navigableResults.findIndex(
|
|
(r) => r.id === repo.id && r.type === "repository",
|
|
);
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={repo.id}
|
|
onClick={() => handleResultClick(repo)}
|
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
|
globalIdx === selectedIndex
|
|
? "bg-primary-50 dark:bg-primary-900/20"
|
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
|
}`}
|
|
>
|
|
{getResultIcon("repository")}
|
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
|
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
|
{display.primary}
|
|
</span>
|
|
<span className="text-xs text-secondary-400">•</span>
|
|
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
|
{display.secondary}
|
|
</span>
|
|
</div>
|
|
<div className="flex-shrink-0 text-xs text-secondary-400">
|
|
{repo.host_count} hosts
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Users */}
|
|
{results.users?.length > 0 && (
|
|
<div>
|
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
|
Users
|
|
</div>
|
|
{results.users.map((user, _idx) => {
|
|
const display = getResultDisplay(user);
|
|
const globalIdx = navigableResults.findIndex(
|
|
(r) => r.id === user.id && r.type === "user",
|
|
);
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={user.id}
|
|
onClick={() => handleResultClick(user)}
|
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
|
globalIdx === selectedIndex
|
|
? "bg-primary-50 dark:bg-primary-900/20"
|
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
|
}`}
|
|
>
|
|
{getResultIcon("user")}
|
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
|
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
|
{display.primary}
|
|
</span>
|
|
<span className="text-xs text-secondary-400">•</span>
|
|
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
|
{display.secondary}
|
|
</span>
|
|
</div>
|
|
<div className="flex-shrink-0 text-xs text-secondary-400">
|
|
{user.role}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : query.trim() ? (
|
|
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
|
No results found for "{query}"
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default GlobalSearch;
|