From 42bb91868ff1347964d9b36847458ada46e32f2f Mon Sep 17 00:00:00 2001
From: VAIO73 <50487331+Vaso73@users.noreply.github.com>
Date: Fri, 25 Sep 2026 09:18:38 +0200
Subject: [PATCH] fix: include all LXC IPs in VM search
---
AppImage/components/virtual-machines.tsx | 47 ++++------
AppImage/lib/vm-search.ts | 42 +++++++++
AppImage/scripts/flask_server.py | 84 +++++++++++------
.../scripts/tests/test_lxc_vm_ip_inventory.py | 93 +++++++++++++++++++
AppImage/scripts/tests/test_vm_search.cjs | 42 +++++++++
5 files changed, 250 insertions(+), 58 deletions(-)
create mode 100644 AppImage/lib/vm-search.ts
create mode 100644 AppImage/scripts/tests/test_lxc_vm_ip_inventory.py
create mode 100644 AppImage/scripts/tests/test_vm_search.cjs
diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx
index 88af9b17..3125ac87 100644
--- a/AppImage/components/virtual-machines.tsx
+++ b/AppImage/components/virtual-machines.tsx
@@ -26,6 +26,7 @@ import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
import { formatStorage } from "../lib/utils"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
+import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search"
import DOMPurify from "dompurify"
import { marked } from "marked"
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
@@ -216,6 +217,9 @@ interface VMData {
diskread?: number
diskwrite?: number
ip?: string
+ // All LXC addresses reported by lxc-info. `ip` remains the primary
+ // address for backwards compatibility and compact card rendering.
+ ips?: string[]
update_check?: LxcUpdateCheck
// Proxmox tags as a raw string ("prod;web;monitoring" — PVE
// separator is ';' but ',' is also accepted). Rendered as
@@ -233,36 +237,6 @@ interface VMData {
modal_cache_revision?: number
}
-function normalizeVmSearchValue(value: unknown): string {
- return String(value ?? "")
- .normalize("NFD")
- .replace(/[\u0300-\u036f]/g, "")
- .toLocaleLowerCase()
-}
-
-// Type synonyms come from the active locale so a user can search by the
-// word they'd naturally use ("contenedor", "machine virtuelle", …) rather
-// than only the internal type token. "lxc" / "qemu" already match through
-// vm.type, so the catalog entries only carry the natural-language terms.
-function matchesVmSearch(
- vm: VMData,
- terms: string[],
- typeSynonyms: { lxc: string; qemu: string },
-): boolean {
- if (terms.length === 0) return true
- const searchable = normalizeVmSearchValue([
- vm.name,
- vm.vmid,
- vm.type,
- vm.type === "lxc" ? typeSynonyms.lxc : typeSynonyms.qemu,
- 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
@@ -3247,13 +3221,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// immediately without waiting for the batch.
let lxcIP: string | null | undefined = null
if (vm.type === "lxc") {
- lxcIP = vmConfigs[vm.vmid]
+ lxcIP = vm.ip || vmConfigs[vm.vmid]
if (!lxcIP) {
const cached = vmModalCacheRef.current.details.get(vm.vmid) as any
lxcIP = cached?.lxc_ip_info?.primary_ip
|| (cached?.config ? extractIPFromConfig(cached.config, cached.lxc_ip_info) : null)
}
}
+ const additionalIpCount = lxcIP
+ ? (vm.ips || []).filter((ip) => ip !== lxcIP).length
+ : 0
return (
@@ -3332,6 +3309,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{lxcIP}
+ {additionalIpCount > 0 && (
+
+ +{additionalIpCount}
+
+ )}
)}
diff --git a/AppImage/lib/vm-search.ts b/AppImage/lib/vm-search.ts
new file mode 100644
index 00000000..cbe5e183
--- /dev/null
+++ b/AppImage/lib/vm-search.ts
@@ -0,0 +1,42 @@
+export interface VMSearchable {
+ vmid: number
+ name: string
+ type: string
+ tags?: string
+ description?: string
+ ip?: string
+ ips?: string[]
+ app_watches?: Array<{ name?: string | null }>
+}
+
+export interface VMTypeSynonyms {
+ lxc: string
+ qemu: string
+}
+
+export function normalizeVmSearchValue(value: unknown): string {
+ return String(value ?? "")
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLocaleLowerCase()
+}
+
+export function matchesVmSearch(
+ vm: VMSearchable,
+ terms: string[],
+ typeSynonyms: VMTypeSynonyms,
+): boolean {
+ if (terms.length === 0) return true
+ const searchable = normalizeVmSearchValue([
+ vm.name,
+ vm.vmid,
+ vm.type,
+ vm.type === "lxc" ? typeSynonyms.lxc : typeSynonyms.qemu,
+ vm.tags,
+ vm.description,
+ vm.ip,
+ ...(vm.ips || []),
+ ...(vm.app_watches || []).map((app) => app.name || ""),
+ ].join(" "))
+ return terms.every((term) => searchable.includes(term))
+}
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index 6790784d..3602e39e 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -611,13 +611,14 @@ def parse_lxc_hardware_config(vmid, node):
return hardware_info
-def _get_lxc_primary_ip_cached(vmid):
- """Return the LXC's primary non-Docker IP with an indefinite
- cache. First read per CT spawns one `lxc-info` subprocess;
- subsequent reads are free until the CT's lifecycle event drops
- the entry via `_invalidate_lxc_ip`. A running CT's IP doesn't
- change on its own — the invalidation on start/stop/reboot is the
- only path that requires re-probing.
+def _get_lxc_ip_info_cached(vmid):
+ """Return an LXC's complete IP classification from the lifecycle cache.
+
+ First read per CT spawns one `lxc-info` subprocess; subsequent reads are
+ free until the CT's lifecycle event drops the entry via
+ `_invalidate_lxc_ip`. Keeping the complete result (rather than only the
+ primary address) lets `/api/vms` expose every address without changing
+ the cache's lifecycle semantics.
"""
try:
vmid_int = int(vmid)
@@ -626,11 +627,39 @@ def _get_lxc_primary_ip_cached(vmid):
if vmid_int in _lxc_ip_cache:
return _lxc_ip_cache[vmid_int]
info = get_lxc_ip_from_lxc_info(vmid_int)
- ip = None
- if info:
- ip = info.get('primary_ip') or (info.get('real_ips') or [None])[0]
- _lxc_ip_cache[vmid_int] = ip
- return ip
+ _lxc_ip_cache[vmid_int] = info
+ return info
+
+
+def _get_lxc_primary_ip_cached(vmid):
+ """Return the primary IP for legacy callers of the LXC IP cache."""
+ info = _get_lxc_ip_info_cached(vmid)
+ if not info:
+ return None
+ return info.get('primary_ip') or (info.get('real_ips') or [None])[0]
+
+
+def _normalise_lxc_ips(ip_info):
+ """Return a stable, JSON-safe ordered list of LXC addresses.
+
+ `lxc-info` already decides which addresses are real or Docker addresses;
+ this helper deliberately does not reclassify or filter them. It merely
+ protects the list endpoint from malformed cache data and removes exact
+ duplicates while retaining the original order.
+ """
+ if not isinstance(ip_info, dict):
+ return []
+ raw_ips = ip_info.get('all_ips')
+ if not isinstance(raw_ips, (list, tuple)):
+ return []
+ ips = []
+ seen = set()
+ for raw_ip in raw_ips:
+ ip = str(raw_ip).strip() if raw_ip is not None else ''
+ if ip and ip not in seen:
+ seen.add(ip)
+ ips.append(ip)
+ return ips
def _invalidate_lxc_ip(vmid):
@@ -669,11 +698,7 @@ def _warmup_lxc_ip_cache() -> int:
continue
if parts[1].lower() != 'running':
continue
- info = get_lxc_ip_from_lxc_info(vmid_int)
- ip = None
- if info:
- ip = info.get('primary_ip') or (info.get('real_ips') or [None])[0]
- _lxc_ip_cache[vmid_int] = ip
+ _lxc_ip_cache[vmid_int] = get_lxc_ip_from_lxc_info(vmid_int)
count += 1
return count
@@ -1719,7 +1744,7 @@ _vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only
_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.
+# LXC IP classification 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
# polling is needed. See `_handle_guest_lifecycle`.
@@ -6716,6 +6741,20 @@ def get_proxmox_vms():
upd = lxc_updates_map.get(str(resource.get('vmid')))
if upd is not None:
vm_data['update_check'] = upd
+ # IP addresses are core LXC inventory, not an App
+ # Watch feature. Keep `ip` for callers that expect
+ # a primary address and add `ips` for search and
+ # detail-aware clients. The cache is still warmed
+ # at startup and invalidated by LXC lifecycle events.
+ vm_data['ips'] = []
+ if resource.get('status') == 'running':
+ lxc_ip_info = _get_lxc_ip_info_cached(resource.get('vmid'))
+ lxc_ips = _normalise_lxc_ips(lxc_ip_info)
+ vm_data['ips'] = lxc_ips
+ if lxc_ip_info:
+ primary_ip = lxc_ip_info.get('primary_ip')
+ if isinstance(primary_ip, str) and primary_ip.strip():
+ vm_data['ip'] = primary_ip.strip()
# App Watch (Phase 2c) — list of registered
# apps per CT (0..N). Populates header badge,
# Updates modal connected row, and the App
@@ -6738,15 +6777,6 @@ def get_proxmox_vms():
app_list = lxc_app_map.get(str(resource.get('vmid'))) or []
if app_list:
vm_data['app_watches'] = app_list
- # Apps dashboard reads this to build
- # weblinks. Only paid on CTs that have
- # registered apps; the IP is cached
- # indefinitely and invalidated by the
- # guest lifecycle hook on start/stop/reboot.
- if vm_type == 'lxc' and resource.get('status') == 'running':
- _ip = _get_lxc_primary_ip_cached(resource.get('vmid'))
- if _ip:
- vm_data['ip'] = _ip
docker_inventory = lxc_docker_map.get(str(resource.get('vmid')))
# Docker image drift is an Updates-tab feature,
# not an automatic app detection. Do not attach
diff --git a/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py b/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py
new file mode 100644
index 00000000..877754d4
--- /dev/null
+++ b/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py
@@ -0,0 +1,93 @@
+"""Contracts for LXC IP inventory returned by the compact `/api/vms` list."""
+
+import ast
+import threading
+import unittest
+from pathlib import Path
+
+
+SERVER_SOURCE = Path(__file__).resolve().parents[1] / "flask_server.py"
+
+
+def load_server_functions(*names):
+ """Compile selected production functions without importing host-bound Flask state."""
+ tree = ast.parse(SERVER_SOURCE.read_text())
+ functions = {
+ node.name: node
+ for node in tree.body
+ if isinstance(node, ast.FunctionDef) and node.name in names
+ }
+ missing = set(names) - functions.keys()
+ if missing:
+ raise AssertionError(f"Missing production functions: {sorted(missing)}")
+ module = ast.Module(body=[functions[name] for name in names], type_ignores=[])
+ ast.fix_missing_locations(module)
+ namespace = {}
+ exec(compile(module, str(SERVER_SOURCE), "exec"), namespace)
+ return namespace
+
+
+class LxcVmIpInventoryTests(unittest.TestCase):
+ def setUp(self):
+ self.server = load_server_functions("_normalise_lxc_ips", "get_proxmox_vms")
+ self.server.update({
+ "_get_lxc_update_status_map": lambda: {},
+ "_get_lxc_app_watch_map": lambda: {},
+ "_get_lxc_docker_inventory_map": lambda: {},
+ "get_proxmox_node_name": lambda: "pve",
+ "_get_cached_vm_list_search_notes": lambda _resources, _node: {},
+ "get_cached_vm_disk": lambda _vmid: None,
+ "_guest_lifecycle_lock": threading.RLock(),
+ "_guest_modal_cache_revision": {},
+ "_guest_modal_cache_epoch": 1,
+ })
+
+ def list_rows(self, resources, ip_info_by_vmid):
+ self.server["get_cached_pvesh_cluster_resources_vm"] = lambda: resources
+ self.server["_get_lxc_ip_info_cached"] = lambda vmid: ip_info_by_vmid.get(vmid)
+ return self.server["get_proxmox_vms"]()
+
+ @staticmethod
+ def running_lxc(vmid):
+ return {
+ "type": "lxc", "node": "pve", "status": "running", "vmid": vmid,
+ "name": f"ct-{vmid}", "cpu": 0, "mem": 0, "maxmem": 0,
+ "disk": 0, "maxdisk": 0, "uptime": 0,
+ }
+
+ def test_unregistered_lxc_keeps_its_primary_ip_in_the_list(self):
+ rows = self.list_rows([self.running_lxc(300)], {
+ 300: {
+ "all_ips": ["10.100.100.218"],
+ "real_ips": ["10.100.100.218"],
+ "docker_ips": [],
+ "primary_ip": "10.100.100.218",
+ },
+ })
+
+ self.assertEqual(rows[0]["ip"], "10.100.100.218")
+ self.assertEqual(rows[0]["ips"], ["10.100.100.218"])
+ self.assertNotIn("app_watches", rows[0])
+
+ def test_lxc_with_multiple_ips_preserves_order_and_docker_classification_output(self):
+ rows = self.list_rows([self.running_lxc(301)], {
+ 301: {
+ "all_ips": ["10.100.100.219", "10.100.100.220", "172.17.0.1"],
+ "real_ips": ["10.100.100.219", "10.100.100.220"],
+ "docker_ips": ["172.17.0.1"],
+ "primary_ip": "10.100.100.219",
+ },
+ })
+
+ self.assertEqual(rows[0]["ip"], "10.100.100.219")
+ self.assertEqual(rows[0]["ips"], ["10.100.100.219", "10.100.100.220", "172.17.0.1"])
+
+ def test_lxc_without_an_ip_returns_an_empty_ips_list(self):
+ rows = self.list_rows([self.running_lxc(302)], {302: None})
+
+ self.assertEqual(rows[0]["ips"], [])
+ self.assertNotIn("ip", rows[0])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/AppImage/scripts/tests/test_vm_search.cjs b/AppImage/scripts/tests/test_vm_search.cjs
new file mode 100644
index 00000000..a1a803af
--- /dev/null
+++ b/AppImage/scripts/tests/test_vm_search.cjs
@@ -0,0 +1,42 @@
+const assert = require("assert")
+const fs = require("fs")
+const path = require("path")
+const ts = require("../../node_modules/typescript")
+
+const sourcePath = path.join(__dirname, "../../lib/vm-search.ts")
+const source = fs.readFileSync(sourcePath, "utf8")
+const compiled = ts.transpileModule(source, {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 },
+}).outputText
+const moduleShim = { exports: {} }
+new Function("exports", "require", "module", "__filename", "__dirname", compiled)(
+ moduleShim.exports,
+ require,
+ moduleShim,
+ sourcePath,
+ path.dirname(sourcePath),
+)
+
+const { matchesVmSearch, normalizeVmSearchValue } = moduleShim.exports
+const synonyms = { lxc: "container", qemu: "virtual machine" }
+
+const multiIpLxc = {
+ vmid: 301,
+ name: "database",
+ type: "lxc",
+ ip: "10.100.100.219",
+ ips: ["10.100.100.219", "10.100.100.220"],
+}
+
+assert.equal(
+ matchesVmSearch(multiIpLxc, [normalizeVmSearchValue("10.100.100.220")], synonyms),
+ true,
+ "A secondary LXC IP must be searchable",
+)
+assert.equal(
+ matchesVmSearch(multiIpLxc, [normalizeVmSearchValue("10.100.100.218")], synonyms),
+ false,
+ "Unrelated IPs must not match",
+)
+
+console.log("VM IP search tests passed")