From 9cc49da0197288cfda945bed163e719affafd81b Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:29:57 +0200 Subject: [PATCH] feat: add VM and LXC list search and update filter --- AppImage/components/virtual-machines.tsx | 202 ++++++++++++++++++----- AppImage/messages/en/common.json | 13 +- AppImage/messages/sk/common.json | 13 +- AppImage/scripts/flask_server.py | 76 +++++++++ 4 files changed, 257 insertions(+), 47 deletions(-) diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index dee7ff06..e115863b 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -10,7 +10,7 @@ import { Badge } from "./ui/badge" import { Progress } from "./ui/progress" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog" -import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, ExternalLink, Tag as TagIcon } from 'lucide-react' +import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, ExternalLink, Search, Tag as TagIcon } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Checkbox } from "./ui/checkbox" import { Switch } from "./ui/switch" @@ -182,6 +182,9 @@ interface LxcDockerInventory { interface VMData { vmid: number name: string + // Compact PVE description included only for list search. The full notes + // remain in the guest detail modal. + description?: string status: string type: string cpu: number @@ -213,6 +216,39 @@ interface VMData { modal_cache_revision?: number } +function normalizeVmSearchValue(value: unknown): string { + return String(value ?? "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLocaleLowerCase() +} + +function matchesVmSearch(vm: VMData, terms: string[]): boolean { + if (terms.length === 0) return true + const searchable = normalizeVmSearchValue([ + vm.name, + vm.vmid, + vm.type, + vm.type === "lxc" ? "container kontajner" : "virtual machine virtualny stroj", + vm.tags, + vm.description, + vm.ip, + ...(vm.app_watches || []).map((app) => app.name || ""), + ].join(" ")) + return terms.every((term) => searchable.includes(term)) +} + +function hasLxcPendingUpdates(vm: VMData): boolean { + if (vm.type !== "lxc") return false + const osUpdates = vm.update_check?.count ?? 0 + const appUpdates = (vm.app_watches || []).filter( + (app) => app.update_available === true && !app.exclude_from_badge, + ).length + const dockerRegistered = (vm.app_watches || []).some((app) => app.helper_slug === "docker") + const dockerUpdates = dockerRegistered ? (vm.docker_inventory?.update_count ?? 0) : 0 + return osUpdates + appUpdates + dockerUpdates > 0 +} + function buildRegisteredAppUrl(vm: VMData, port?: LxcAppPort): string | null { const custom = (port?.custom_url || "").trim() if (custom) return custom @@ -1744,17 +1780,38 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { window.localStorage.setItem("proxmenux.vmListFilter", statusFilter) } }, [statusFilter]) + const [updatesOnly, setUpdatesOnly] = useState(() => { + if (typeof window === "undefined") return false + return window.localStorage.getItem("proxmenux.vmListUpdatesOnly") === "true" + }) + useEffect(() => { + if (typeof window !== "undefined") { + window.localStorage.setItem("proxmenux.vmListUpdatesOnly", String(updatesOnly)) + } + }, [updatesOnly]) + const [vmSearchQuery, setVmSearchQuery] = useState("") + + const vmSearchTerms = useMemo( + () => normalizeVmSearchValue(vmSearchQuery).trim().split(/\s+/).filter(Boolean), + [vmSearchQuery], + ) const statusCounts = useMemo(() => ({ all: safeVMData.length, running: safeVMData.filter((vm) => vm.status === "running").length, stopped: safeVMData.filter((vm) => vm.status === "stopped").length, + updates: safeVMData.filter(hasLxcPendingUpdates).length, }), [safeVMData]) const filteredVMs = useMemo(() => { - if (statusFilter === "all") return safeVMData - return safeVMData.filter((vm) => vm.status === statusFilter) - }, [safeVMData, statusFilter]) + const statusMatched = statusFilter === "all" + ? safeVMData + : safeVMData.filter((vm) => vm.status === statusFilter) + return statusMatched.filter((vm) => ( + (!updatesOnly || hasLxcPendingUpdates(vm)) + && matchesVmSearch(vm, vmSearchTerms) + )) + }, [safeVMData, statusFilter, updatesOnly, vmSearchTerms]) // ── LXC update apply flow (Phase 2a/b) ──────────────────────────── // Users pick a target (OS, App, both) + backup / restart options, @@ -2980,50 +3037,99 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { - + {t("vmLxc.listTitle")} -
- {(["all", "running", "stopped"] as const).map((key) => { - const active = statusFilter === key - const label = t(`vmLxc.statusFilter.${key}`) - // Icon color: white when the tab is active (over the blue fill); - // green / red on inactive tabs so the state mapping stays legible - // before selection. The black-text variant was tested and dropped - // — white reads cleaner alongside the sidebar/nav blue treatment. - const iconClass = active - ? "h-3.5 w-3.5 text-white" - : key === "running" - ? "h-3.5 w-3.5 text-green-500 fill-green-500/25" - : "h-3.5 w-3.5 text-red-500 fill-red-500/25" - return ( - - ) - })} +
+
+
+ + setVmSearchQuery(event.target.value)} + placeholder={t("vmLxc.filters.searchPlaceholder")} + aria-label={t("vmLxc.filters.searchAriaLabel")} + className="h-9 w-full rounded-md border border-border bg-card py-2 pl-8 pr-8 text-sm text-foreground placeholder:text-muted-foreground focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring" + /> + {vmSearchQuery && ( + + )} +
+
+ {(["all", "running", "stopped"] as const).map((key) => { + const active = statusFilter === key + const label = t(`vmLxc.statusFilter.${key}`) + // Icon color: white when the tab is active (over the blue fill); + // green / red on inactive tabs so the state mapping stays legible + // before selection. The black-text variant was tested and dropped + // — white reads cleaner alongside the sidebar/nav blue treatment. + const iconClass = active + ? "h-3.5 w-3.5 text-white" + : key === "running" + ? "h-3.5 w-3.5 text-green-500 fill-green-500/25" + : "h-3.5 w-3.5 text-red-500 fill-red-500/25" + return ( + + ) + })} + +
+
+ {vmSearchTerms.length > 0 && ( + + {t("vmLxc.filters.resultCount", { + shown: filteredVMs.length, + total: safeVMData.length, + })} + + )}
@@ -3031,7 +3137,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.empty")}
) : filteredVMs.length === 0 ? (
- {t("vmLxc.statusFilter.empty", { status: t(`vmLxc.statusFilter.${statusFilter}`) })} + {vmSearchTerms.length > 0 + ? t("vmLxc.filters.noMatches") + : updatesOnly + ? t("vmLxc.filters.noUpdates") + : statusFilter === "running" + ? t("vmLxc.filters.noRunning") + : t("vmLxc.filters.noStopped")}
) : (
diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index db9a48de..e4174605 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -917,12 +917,23 @@ "listTitle": "Virtual Machines & Containers", "empty": "No virtual machines found", "statusFilter": { - "ariaLabel": "Filter virtual machines and containers by status", + "ariaLabel": "Filter virtual machines and containers", "all": "All", "running": "Running", "stopped": "Stopped", "empty": "No virtual machines or containers with status \"{status}\"" }, + "filters": { + "searchPlaceholder": "Search name, ID, tag, or note…", + "searchAriaLabel": "Search virtual machines and containers", + "clearSearch": "Clear search", + "updates": "Updates", + "resultCount": "{shown} of {total} machines", + "noMatches": "No VMs or LXCs match your search.", + "noRunning": "No running VMs or LXCs.", + "noStopped": "No stopped VMs or LXCs.", + "noUpdates": "No LXC with available updates." + }, "uptime": "Uptime: {uptime}", "cpuUsage": "CPU Usage", "memory": "Memory", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index cb5e7dee..261f7b5d 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -917,12 +917,23 @@ "listTitle": "Virtuálne stroje a kontajnery", "empty": "Nenašli sa žiadne virtuálne stroje", "statusFilter": { - "ariaLabel": "Filtrovať virtuálne stroje a kontajnery podľa stavu", + "ariaLabel": "Filtrovať virtuálne stroje a kontajnery", "all": "Všetky", "running": "Spustené", "stopped": "Vypnuté", "empty": "Žiadne virtuálne stroje ani kontajnery so stavom „{status}“" }, + "filters": { + "searchPlaceholder": "Hľadať podľa názvu, ID, značky alebo poznámky…", + "searchAriaLabel": "Hľadať virtuálne stroje a kontajnery", + "clearSearch": "Vymazať hľadanie", + "updates": "Aktualizácie", + "resultCount": "{shown} z {total} strojov", + "noMatches": "Žiadne VM ani LXC nezodpovedajú hľadaniu.", + "noRunning": "Nie sú tu žiadne spustené VM ani LXC.", + "noStopped": "Nie sú tu žiadne vypnuté VM ani LXC.", + "noUpdates": "Žiadne LXC nemá dostupné aktualizácie." + }, "uptime": "Beží: {uptime}", "cpuUsage": "Využitie CPU", "memory": "Pamäť", diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index bebb2ce4..5073395c 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -1701,6 +1701,14 @@ _vm_apps_cache: dict = {} # vmid -> (ts, payload) _vm_app_suggestions_cache: dict = {} # vmid -> (ts, payload) _vm_schedule_cache: dict = {} # vmid -> (ts, payload) _vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only +# Searchable guest notes are intentionally separate from the modal cache. +# The list is polled every few seconds, so reading every PVE config for every +# response would make a simple name/ID search unexpectedly expensive on a +# busy host. A short shared cache keeps note search responsive without +# delaying normal list refreshes. +_VM_LIST_SEARCH_NOTES_TTL = 30 +_vm_list_search_notes_cache: dict = {"ts": 0.0, "signature": (), "notes": {}} +_vm_list_search_notes_lock = threading.Lock() # LXC primary IP cache — populated on first read, held indefinitely. # A running CT's IP doesn't change; the cache is invalidated only when # the CT's lifecycle event fires (start/stop/reboot), so no periodic @@ -1733,6 +1741,64 @@ _VM_SCHEDULE_TTL = _VM_CACHE_INDEFINITE _VM_MOUNTS_TTL = _VM_CACHE_INDEFINITE _vm_modal_cache_lock = threading.Lock() + +def _collapse_guest_note(value) -> str: + """Return a compact, search-only form of a guest description.""" + return " ".join(str(value or "").split()) + + +def _get_cached_vm_list_search_notes(resources, local_node: str) -> dict: + """Return local guest descriptions for list search without hot-path I/O. + + PVE notes live in the individual guest configs, not in the cluster resource + summary behind ``/api/vms``. Cache the small ID → description map so the + dashboard can search notes without fetching every guest detail or parsing + every config on each polling cycle. + """ + guests = [] + for resource in resources or []: + if resource.get("node") != local_node: + continue + try: + vmid = int(resource.get("vmid")) + except (TypeError, ValueError): + continue + vm_type = "lxc" if resource.get("type") == "lxc" else "qemu" + guests.append((vm_type, vmid)) + signature = tuple(sorted(guests)) + now = time.time() + with _vm_list_search_notes_lock: + cached = _vm_list_search_notes_cache + if cached["signature"] == signature and now - cached["ts"] < _VM_LIST_SEARCH_NOTES_TTL: + return cached["notes"] + + notes = {} + for vm_type, vmid in guests: + config_path = ( + f"/etc/pve/lxc/{vmid}.conf" + if vm_type == "lxc" + else f"/etc/pve/qemu-server/{vmid}.conf" + ) + try: + note = _collapse_guest_note( + _read_pve_conf_fast(config_path).get("description", "") + ) + except Exception: + note = "" + if note: + notes[(vm_type, vmid)] = note + + cached["ts"] = now + cached["signature"] = signature + cached["notes"] = notes + return notes + + +def _invalidate_vm_list_search_notes() -> None: + """Make edited guest notes searchable immediately on the next list poll.""" + with _vm_list_search_notes_lock: + _vm_list_search_notes_cache["ts"] = 0.0 + def _vm_cache_get(cache: dict, vmid: int, ttl: int): """Return cached payload for vmid if still fresh, else None.""" with _vm_modal_cache_lock: @@ -6529,6 +6595,7 @@ def get_proxmox_vms(): resources = get_cached_pvesh_cluster_resources_vm() if resources: + search_notes = _get_cached_vm_list_search_notes(resources, local_node) for resource in resources: node = resource.get('node', '') if node != local_node: @@ -6558,6 +6625,14 @@ def get_proxmox_vms(): # format; the client splits + colours them. 'tags': resource.get('tags', ''), } + try: + search_vmid = int(resource.get('vmid')) + except (TypeError, ValueError): + search_vmid = None + if search_vmid is not None: + vm_data['description'] = search_notes.get( + (vm_type, search_vmid), '' + ) # The frontend keeps its own instant modal cache. A # lifecycle rebuild increments this per-guest token so # the browser drops only the restored/restarted guest's @@ -15595,6 +15670,7 @@ def api_vm_config_update(vmid): # drop the details cache so the next open reflects the # edit without waiting for the TTL. _vm_cache_invalidate(vmid, _vm_details_cache, _vm_mounts_cache) + _invalidate_vm_list_search_notes() return jsonify({ 'success': True, 'vmid': vmid,