fix: include all LXC IPs in VM search

This commit is contained in:
VAIO73
2026-09-25 09:36:08 +02:00
parent f3a2b70219
commit 42bb91868f
5 changed files with 250 additions and 58 deletions
+16 -31
View File
@@ -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 (
<div key={vm.vmid}>
@@ -3332,6 +3309,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<span className="text-sm text-foreground flex items-center gap-1">
<Network className="h-3 w-3 text-green-500" />
{lxcIP}
{additionalIpCount > 0 && (
<span
className="text-xs text-muted-foreground"
title={(vm.ips || []).join(", ")}
>
+{additionalIpCount}
</span>
)}
</span>
)}
</div>
+42
View File
@@ -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))
}
+57 -27
View File
@@ -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
@@ -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()
+42
View File
@@ -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")