Merge pull request #334 from Vaso73/feat/vm-lxc-search-update-filter

feat: add VM and LXC list search and update filter
This commit is contained in:
MacRimi
2026-09-04 08:07:14 +02:00
committed by GitHub
4 changed files with 257 additions and 47 deletions
+123 -11
View File
@@ -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,15 +3037,39 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
<Card className="bg-card border-border">
<CardHeader className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<CardTitle className="flex items-center gap-2 text-xl lg:text-2xl font-bold text-foreground">
<Server className="h-6 w-6" />
{t("vmLxc.listTitle")}
</CardTitle>
<div className="flex w-full flex-col gap-2 xl:w-auto xl:items-end">
<div className="flex w-full flex-col gap-2 md:flex-row md:flex-wrap md:items-center xl:flex-nowrap">
<div className="relative w-full md:w-72">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
inputMode="search"
value={vmSearchQuery}
onChange={(event) => 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 && (
<button
type="button"
onClick={() => setVmSearchQuery("")}
aria-label={t("vmLxc.filters.clearSearch")}
className="absolute right-1.5 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div
role="tablist"
role="group"
aria-label={t("vmLxc.statusFilter.ariaLabel")}
className="inline-flex w-full sm:w-auto rounded-lg border border-border bg-muted/40 p-1 gap-1"
className="grid w-full grid-cols-2 gap-1 rounded-lg border border-border bg-muted/40 p-1 md:inline-flex md:w-auto"
>
{(["all", "running", "stopped"] as const).map((key) => {
const active = statusFilter === key
@@ -3006,10 +3087,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<button
key={key}
type="button"
role="tab"
aria-selected={active}
aria-pressed={active}
onClick={() => setStatusFilter(key)}
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
className={`inline-flex w-full items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors md:w-auto ${
active
? "bg-blue-500 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
@@ -3024,6 +3104,32 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</button>
)
})}
<button
type="button"
aria-pressed={updatesOnly}
onClick={() => setUpdatesOnly((current) => !current)}
className={`inline-flex w-full items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors md:w-auto ${
updatesOnly
? "bg-violet-500/80 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
<ArrowUpCircle className="h-3.5 w-3.5" />
<span>{t("vmLxc.filters.updates")}</span>
<span className={`ml-0.5 text-xs tabular-nums ${updatesOnly ? "text-white/80" : "opacity-70"}`}>
{statusCounts.updates}
</span>
</button>
</div>
</div>
{vmSearchTerms.length > 0 && (
<span className="text-xs text-muted-foreground">
{t("vmLxc.filters.resultCount", {
shown: filteredVMs.length,
total: safeVMData.length,
})}
</span>
)}
</div>
</CardHeader>
<CardContent>
@@ -3031,7 +3137,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="text-center py-8 text-muted-foreground">{t("vmLxc.empty")}</div>
) : filteredVMs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{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")}
</div>
) : (
<div className="space-y-3">
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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äť",
+76
View File
@@ -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,