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")