mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
Merge pull request #385 from Vaso73/fix/lxc-ip-search
fix(monitor): include all LXC IPs in VM search
This commit is contained in:
@@ -26,6 +26,7 @@ import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
|
|||||||
import { formatStorage } from "../lib/utils"
|
import { formatStorage } from "../lib/utils"
|
||||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search"
|
||||||
import DOMPurify from "dompurify"
|
import DOMPurify from "dompurify"
|
||||||
import { marked } from "marked"
|
import { marked } from "marked"
|
||||||
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
|
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
|
||||||
@@ -216,6 +217,9 @@ interface VMData {
|
|||||||
diskread?: number
|
diskread?: number
|
||||||
diskwrite?: number
|
diskwrite?: number
|
||||||
ip?: string
|
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
|
update_check?: LxcUpdateCheck
|
||||||
// Proxmox tags as a raw string ("prod;web;monitoring" — PVE
|
// Proxmox tags as a raw string ("prod;web;monitoring" — PVE
|
||||||
// separator is ';' but ',' is also accepted). Rendered as
|
// separator is ';' but ',' is also accepted). Rendered as
|
||||||
@@ -233,36 +237,6 @@ interface VMData {
|
|||||||
modal_cache_revision?: number
|
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 {
|
function hasLxcPendingUpdates(vm: VMData): boolean {
|
||||||
if (vm.type !== "lxc") return false
|
if (vm.type !== "lxc") return false
|
||||||
const osUpdates = vm.update_check?.count ?? 0
|
const osUpdates = vm.update_check?.count ?? 0
|
||||||
@@ -3247,13 +3221,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
// immediately without waiting for the batch.
|
// immediately without waiting for the batch.
|
||||||
let lxcIP: string | null | undefined = null
|
let lxcIP: string | null | undefined = null
|
||||||
if (vm.type === "lxc") {
|
if (vm.type === "lxc") {
|
||||||
lxcIP = vmConfigs[vm.vmid]
|
lxcIP = vm.ip || vmConfigs[vm.vmid]
|
||||||
if (!lxcIP) {
|
if (!lxcIP) {
|
||||||
const cached = vmModalCacheRef.current.details.get(vm.vmid) as any
|
const cached = vmModalCacheRef.current.details.get(vm.vmid) as any
|
||||||
lxcIP = cached?.lxc_ip_info?.primary_ip
|
lxcIP = cached?.lxc_ip_info?.primary_ip
|
||||||
|| (cached?.config ? extractIPFromConfig(cached.config, cached.lxc_ip_info) : null)
|
|| (cached?.config ? extractIPFromConfig(cached.config, cached.lxc_ip_info) : null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const additionalIpCount = lxcIP
|
||||||
|
? (vm.ips || []).filter((ip) => ip !== lxcIP).length
|
||||||
|
: 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={vm.vmid}>
|
<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">
|
<span className="text-sm text-foreground flex items-center gap-1">
|
||||||
<Network className="h-3 w-3 text-green-500" />
|
<Network className="h-3 w-3 text-green-500" />
|
||||||
{lxcIP}
|
{lxcIP}
|
||||||
|
{additionalIpCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
title={(vm.ips || []).join(", ")}
|
||||||
|
>
|
||||||
|
+{additionalIpCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -611,13 +611,14 @@ def parse_lxc_hardware_config(vmid, node):
|
|||||||
return hardware_info
|
return hardware_info
|
||||||
|
|
||||||
|
|
||||||
def _get_lxc_primary_ip_cached(vmid):
|
def _get_lxc_ip_info_cached(vmid):
|
||||||
"""Return the LXC's primary non-Docker IP with an indefinite
|
"""Return an LXC's complete IP classification from the lifecycle cache.
|
||||||
cache. First read per CT spawns one `lxc-info` subprocess;
|
|
||||||
subsequent reads are free until the CT's lifecycle event drops
|
First read per CT spawns one `lxc-info` subprocess; subsequent reads are
|
||||||
the entry via `_invalidate_lxc_ip`. A running CT's IP doesn't
|
free until the CT's lifecycle event drops the entry via
|
||||||
change on its own — the invalidation on start/stop/reboot is the
|
`_invalidate_lxc_ip`. Keeping the complete result (rather than only the
|
||||||
only path that requires re-probing.
|
primary address) lets `/api/vms` expose every address without changing
|
||||||
|
the cache's lifecycle semantics.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
vmid_int = int(vmid)
|
vmid_int = int(vmid)
|
||||||
@@ -626,11 +627,39 @@ def _get_lxc_primary_ip_cached(vmid):
|
|||||||
if vmid_int in _lxc_ip_cache:
|
if vmid_int in _lxc_ip_cache:
|
||||||
return _lxc_ip_cache[vmid_int]
|
return _lxc_ip_cache[vmid_int]
|
||||||
info = get_lxc_ip_from_lxc_info(vmid_int)
|
info = get_lxc_ip_from_lxc_info(vmid_int)
|
||||||
ip = None
|
_lxc_ip_cache[vmid_int] = info
|
||||||
if info:
|
return info
|
||||||
ip = info.get('primary_ip') or (info.get('real_ips') or [None])[0]
|
|
||||||
_lxc_ip_cache[vmid_int] = ip
|
|
||||||
return ip
|
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):
|
def _invalidate_lxc_ip(vmid):
|
||||||
@@ -669,11 +698,7 @@ def _warmup_lxc_ip_cache() -> int:
|
|||||||
continue
|
continue
|
||||||
if parts[1].lower() != 'running':
|
if parts[1].lower() != 'running':
|
||||||
continue
|
continue
|
||||||
info = get_lxc_ip_from_lxc_info(vmid_int)
|
_lxc_ip_cache[vmid_int] = 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
|
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
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_TTL = 30
|
||||||
_vm_list_search_notes_cache: dict = {"ts": 0.0, "signature": (), "notes": {}}
|
_vm_list_search_notes_cache: dict = {"ts": 0.0, "signature": (), "notes": {}}
|
||||||
_vm_list_search_notes_lock = threading.Lock()
|
_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
|
# 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
|
# the CT's lifecycle event fires (start/stop/reboot), so no periodic
|
||||||
# polling is needed. See `_handle_guest_lifecycle`.
|
# polling is needed. See `_handle_guest_lifecycle`.
|
||||||
@@ -6716,6 +6741,20 @@ def get_proxmox_vms():
|
|||||||
upd = lxc_updates_map.get(str(resource.get('vmid')))
|
upd = lxc_updates_map.get(str(resource.get('vmid')))
|
||||||
if upd is not None:
|
if upd is not None:
|
||||||
vm_data['update_check'] = upd
|
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
|
# App Watch (Phase 2c) — list of registered
|
||||||
# apps per CT (0..N). Populates header badge,
|
# apps per CT (0..N). Populates header badge,
|
||||||
# Updates modal connected row, and the App
|
# 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 []
|
app_list = lxc_app_map.get(str(resource.get('vmid'))) or []
|
||||||
if app_list:
|
if app_list:
|
||||||
vm_data['app_watches'] = 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_inventory = lxc_docker_map.get(str(resource.get('vmid')))
|
||||||
# Docker image drift is an Updates-tab feature,
|
# Docker image drift is an Updates-tab feature,
|
||||||
# not an automatic app detection. Do not attach
|
# 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()
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user