mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-15 03:06:45 +00:00
Event-only modal cache + Settings edit gating + ES i18n polish
Modal caches now refresh on events only — the periodic prewarmer runs one-shot at startup, mount points split into static/runtime endpoints, and backups get a client 6-hour gate. Updates tab shows post-apply feedback and the script terminal no longer closes the parent modal. Settings adds edit gating on 3 cards with the 3-level contrast rule applied consistently. ES translation batch (~25 fixes) and What's New for 1.2.4.1-beta refreshed.
This commit is contained in:
@@ -1616,11 +1616,30 @@ _vm_backups_cache: dict = {} # vmid -> (ts, payload)
|
||||
_vm_apps_cache: dict = {} # vmid -> (ts, payload)
|
||||
_vm_schedule_cache: dict = {} # vmid -> (ts, payload)
|
||||
_vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only
|
||||
_VM_DETAILS_TTL = 300 # config rarely changes without a user action
|
||||
_VM_BACKUPS_TTL = 120 # storage scans — a new backup is an event
|
||||
_VM_APPS_TTL = 600 # LXC apps register/unregister is rare
|
||||
_VM_SCHEDULE_TTL = 900 # persisted schedule almost never changes
|
||||
_VM_MOUNTS_TTL = 600 # mpX entries only change on manual edit
|
||||
# Effective TTL is "indefinite": these caches are refreshed only
|
||||
# by explicit event-based invalidation (`_vm_cache_invalidate` calls
|
||||
# on start/stop/reboot, add/edit/delete app, apply update, edit
|
||||
# schedule, create backup). No periodic poll — the prewarmer runs
|
||||
# once at startup and then stays quiet. The rationale is that all
|
||||
# of these payloads are backed by files (.conf / sidecar JSON /
|
||||
# storage inventory) that only change through actions the Monitor
|
||||
# either performs itself (invalidates in-line) or that require a
|
||||
# guest restart (start/stop invalidates too). Runtime data that DOES
|
||||
# change without an invalidation event lives in a different path:
|
||||
# - Live CPU/mem/disk/network per guest → served by /api/vms (SWR
|
||||
# poll every 2.5 s from the client, no cache here).
|
||||
# - Firewall log → served on-demand, no cache at all.
|
||||
# - Mount points runtime (df/stat/ad-hoc) → to be split into a
|
||||
# separate always-fresh endpoint (Fase 5).
|
||||
# - Backups appearing outside Monitor (cron/scheduled/retention)
|
||||
# → client passes ?fresh=1 when its own cache is older than 6 h
|
||||
# (Fase 4).
|
||||
_VM_CACHE_INDEFINITE = 315_360_000 # 10 years — effectively infinite
|
||||
_VM_DETAILS_TTL = _VM_CACHE_INDEFINITE
|
||||
_VM_BACKUPS_TTL = _VM_CACHE_INDEFINITE
|
||||
_VM_APPS_TTL = _VM_CACHE_INDEFINITE
|
||||
_VM_SCHEDULE_TTL = _VM_CACHE_INDEFINITE
|
||||
_VM_MOUNTS_TTL = _VM_CACHE_INDEFINITE
|
||||
_vm_modal_cache_lock = threading.Lock()
|
||||
|
||||
def _vm_cache_get(cache: dict, vmid: int, ttl: int):
|
||||
@@ -11506,7 +11525,14 @@ def _node_metrics_prewarmer_loop():
|
||||
def _vm_modal_prewarmer_pass():
|
||||
"""One full sweep of every guest's modal caches. Called both
|
||||
from the startup warm-up and from the recurring loop. Returns
|
||||
the number of guests successfully touched."""
|
||||
the number of guests successfully touched.
|
||||
|
||||
The recurring loop is deliberately cheap: for each guest and
|
||||
each cache we check the TTL FIRST and only invoke the handler
|
||||
when the entry is actually stale. On steady state (all caches
|
||||
fresh) a pass is O(guests) dict reads with no request contexts
|
||||
created, no handlers entered, no pvesh spawned — the CPU cost
|
||||
disappears until something genuinely expires."""
|
||||
from flask import g as _flask_g
|
||||
warmed = 0
|
||||
resources = get_cached_pvesh_cluster_resources_vm() or []
|
||||
@@ -11515,83 +11541,74 @@ def _vm_modal_prewarmer_pass():
|
||||
vm_type = r.get('type') # 'qemu' or 'lxc'
|
||||
if vmid is None:
|
||||
continue
|
||||
try:
|
||||
with app.test_request_context(f'/api/vms/{vmid}'):
|
||||
_flask_g._internal_call = True
|
||||
get_vm_config(vmid)
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] vm-modal prewarmer details {vmid}: {e}",
|
||||
file=sys.stderr, flush=True)
|
||||
try:
|
||||
with app.test_request_context(f'/api/vms/{vmid}/backups'):
|
||||
_flask_g._internal_call = True
|
||||
api_vm_backups(vmid)
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] vm-modal prewarmer backups {vmid}: {e}",
|
||||
file=sys.stderr, flush=True)
|
||||
|
||||
endpoints = [
|
||||
(_vm_details_cache, _VM_DETAILS_TTL, get_vm_config,
|
||||
f'/api/vms/{vmid}', 'details'),
|
||||
(_vm_backups_cache, _VM_BACKUPS_TTL, api_vm_backups,
|
||||
f'/api/vms/{vmid}/backups', 'backups'),
|
||||
]
|
||||
if vm_type == 'lxc':
|
||||
endpoints.extend([
|
||||
(_vm_apps_cache, _VM_APPS_TTL, api_vm_apps_get,
|
||||
f'/api/vms/{vmid}/apps', 'apps'),
|
||||
(_vm_schedule_cache, _VM_SCHEDULE_TTL, api_vm_apps_schedule,
|
||||
f'/api/vms/{vmid}/schedule', 'schedule'),
|
||||
(_vm_mounts_cache, _VM_MOUNTS_TTL, api_lxc_mount_points,
|
||||
f'/api/lxc/{vmid}/mount-points', 'mounts'),
|
||||
])
|
||||
|
||||
did_work = False
|
||||
for cache, ttl, handler, route, label in endpoints:
|
||||
if _vm_cache_get(cache, vmid, ttl) is not None:
|
||||
continue # still fresh — skip the request-context overhead
|
||||
try:
|
||||
with app.test_request_context(f'/api/vms/{vmid}/apps'):
|
||||
with app.test_request_context(route):
|
||||
_flask_g._internal_call = True
|
||||
api_vm_apps_get(vmid)
|
||||
handler(vmid)
|
||||
did_work = True
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] vm-modal prewarmer apps {vmid}: {e}",
|
||||
file=sys.stderr, flush=True)
|
||||
try:
|
||||
with app.test_request_context(f'/api/vms/{vmid}/schedule'):
|
||||
_flask_g._internal_call = True
|
||||
api_vm_apps_schedule(vmid)
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] vm-modal prewarmer schedule {vmid}: {e}",
|
||||
file=sys.stderr, flush=True)
|
||||
try:
|
||||
with app.test_request_context(f'/api/lxc/{vmid}/mount-points'):
|
||||
_flask_g._internal_call = True
|
||||
api_lxc_mount_points(vmid)
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] vm-modal prewarmer mounts {vmid}: {e}",
|
||||
print(f"[ProxMenux] vm-modal prewarmer {label} {vmid}: {e}",
|
||||
file=sys.stderr, flush=True)
|
||||
warmed += 1
|
||||
time.sleep(0.2) # brief breath so pvesh isn't hammered
|
||||
if did_work:
|
||||
time.sleep(0.2) # breath only when we actually ran a handler
|
||||
return warmed
|
||||
|
||||
|
||||
def _vm_modal_prewarmer_loop():
|
||||
"""Keep the per-VM modal caches (details / backups / apps /
|
||||
schedule) hot from the backend, so modals always open instantly
|
||||
— even after the browser tab has been closed for a long time.
|
||||
The old React prefetcher only ran while the page was open.
|
||||
"""One-shot warmup at service startup. Populates every per-VM
|
||||
modal cache (details / backups / apps / schedule / mount points)
|
||||
exactly once, then exits — no periodic refresh loop.
|
||||
|
||||
Design (matches user's expectation of "heavy at startup, near-
|
||||
zero during runtime"):
|
||||
* One full warm-up pass right after startup so every cache is
|
||||
primed before the user opens the UI.
|
||||
* After that, a slow refresh loop at 180 s intervals. Since
|
||||
the underlying TTLs are 5-15 min, the vast majority of
|
||||
these calls are cache-hits (essentially free); real work
|
||||
only happens when a cache is about to expire.
|
||||
* Write actions (start/stop/reboot, apply update, edit
|
||||
schedule) call `_vm_cache_invalidate(vmid, ...)` — the
|
||||
loop then refreshes just that guest on its next tick, and
|
||||
an on-demand user open refreshes it immediately.
|
||||
Rationale: every cache in this family is refreshed by explicit
|
||||
event invalidation (see `_vm_cache_invalidate` calls scattered
|
||||
across write endpoints). A periodic loop was double work and
|
||||
lit up the CPU on hosts with many guests. The previous 5 min
|
||||
tick meant ~500-1000 background subprocess/pvesh calls per hour
|
||||
on a 25-guest host, entirely for data that hadn't changed.
|
||||
|
||||
LXC-only endpoints (apps, schedule) are skipped for qemu VMs."""
|
||||
Trade-offs handled elsewhere:
|
||||
* Backups added out-of-band (cron / scheduled / retention)
|
||||
→ client passes `?fresh=1` on modal open when its local
|
||||
cache is older than 6 h; server ignores the indefinite TTL
|
||||
for that call and re-scans (Fase 4).
|
||||
* Mount points runtime state (df/stat/ad-hoc) that changes
|
||||
continuously → served by a separate always-fresh endpoint
|
||||
the client fetches on modal open (Fase 5).
|
||||
|
||||
Called once from the startup section. Thread exits after the
|
||||
initial pass — no `while True` loop."""
|
||||
time.sleep(3) # let Flask finish binding before we invoke handlers
|
||||
try:
|
||||
t0 = time.time()
|
||||
n = _vm_modal_prewarmer_pass()
|
||||
print(f"[ProxMenux] VM-modal prewarmer: initial warm-up complete "
|
||||
f"({n} guests in {time.time()-t0:.1f}s)", flush=True)
|
||||
print(f"[ProxMenux] VM-modal prewarmer: warm-up complete "
|
||||
f"({n} guests in {time.time()-t0:.1f}s) — no periodic refresh, "
|
||||
f"caches held by event invalidation only", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] VM-modal prewarmer initial pass failed: {e}",
|
||||
file=sys.stderr, flush=True)
|
||||
while True:
|
||||
time.sleep(180) # 3 min — most passes are cache-hits, near-zero cost
|
||||
try:
|
||||
_vm_modal_prewarmer_pass()
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] VM-modal prewarmer refresh error: {e}",
|
||||
file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
@app.route('/api/node/metrics', methods=['GET'])
|
||||
@@ -12569,11 +12586,26 @@ def api_create_backup(vmid):
|
||||
@app.route('/api/vms/<int:vmid>/backups', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_backups(vmid):
|
||||
"""Get list of backups for a specific VM/LXC"""
|
||||
"""Get list of backups for a specific VM/LXC.
|
||||
|
||||
The backend cache is indefinite (event-invalidated only). Out-of-
|
||||
band backups — cron jobs, scheduled vzdump, PBS retention pruning
|
||||
— never call `_vm_cache_invalidate`, so a naked GET would keep
|
||||
serving the last snapshot for hours after a new file appeared.
|
||||
|
||||
To reconcile that without a background poll, the client tracks
|
||||
the age of its own copy and, when older than its 6-hour gate,
|
||||
calls this endpoint with `?fresh=1`. Server ignores the cached
|
||||
entry for that call, re-scans every storage, writes the result
|
||||
back into the cache and returns it. Subsequent openings within
|
||||
the next 6 hours hit the freshened cache instantly.
|
||||
"""
|
||||
try:
|
||||
cached = _vm_cache_get(_vm_backups_cache, vmid, _VM_BACKUPS_TTL)
|
||||
if cached is not None:
|
||||
return jsonify(cached)
|
||||
force_fresh = request.args.get('fresh') in ('1', 'true', 'yes')
|
||||
if not force_fresh:
|
||||
cached = _vm_cache_get(_vm_backups_cache, vmid, _VM_BACKUPS_TTL)
|
||||
if cached is not None:
|
||||
return jsonify(cached)
|
||||
|
||||
backups = []
|
||||
|
||||
@@ -13928,15 +13960,17 @@ def get_vm_config(vmid):
|
||||
@app.route('/api/lxc/<int:vmid>/mount-points', methods=['GET'])
|
||||
@require_auth
|
||||
def api_lxc_mount_points(vmid):
|
||||
"""Sprint 13.29: per-LXC mount points enumeration.
|
||||
"""Static half of the per-LXC mount-points payload — parsed mp
|
||||
entries, source/target, PVE storage classification, host source
|
||||
existence flags. Runtime state (`df` capacity, `stat` health,
|
||||
ad-hoc NFS/CIFS discovery, runtime_mounted flag) lives in the
|
||||
sibling `/api/lxc/<vmid>/mount-points/runtime` endpoint that
|
||||
the client fetches on demand every time the tab opens.
|
||||
|
||||
Returns the parsed ``mpX:`` entries from the container config plus,
|
||||
when the container is running, runtime status (mounted/not, real
|
||||
fstype, options, stale detection) and any ad-hoc NFS/CIFS/SMB the
|
||||
user mounted from inside the CT. Capacity is always populated from
|
||||
the host-side source (PVE storage or `df` of the host path) so the
|
||||
info is meaningful even on stopped containers.
|
||||
"""
|
||||
Backed by the indefinite `_vm_mounts_cache` (invalidated on
|
||||
start/stop of the guest, since config-visible fields normally
|
||||
only change through a guest reboot). The runtime endpoint is
|
||||
NEVER cached — it must reflect the live state at click time."""
|
||||
cached = _vm_cache_get(_vm_mounts_cache, vmid, _VM_MOUNTS_TTL)
|
||||
if cached is not None:
|
||||
return jsonify(cached)
|
||||
@@ -13945,7 +13979,7 @@ def api_lxc_mount_points(vmid):
|
||||
except ImportError as e:
|
||||
return jsonify({"ok": False, "error": f"helper unavailable: {e}"}), 503
|
||||
try:
|
||||
result = lxc_mount_points.get_lxc_mount_points(str(vmid))
|
||||
result = lxc_mount_points.get_lxc_mount_points_static(str(vmid))
|
||||
if not result.get("ok"):
|
||||
return jsonify(result), 400
|
||||
_vm_cache_put(_vm_mounts_cache, vmid, result)
|
||||
@@ -13954,6 +13988,31 @@ def api_lxc_mount_points(vmid):
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/lxc/<int:vmid>/mount-points/runtime', methods=['GET'])
|
||||
@require_auth
|
||||
def api_lxc_mount_points_runtime(vmid):
|
||||
"""Runtime half — always fresh, no cache. Returns per-target
|
||||
runtime state + capacity, plus ad-hoc NFS/CIFS mounts detected
|
||||
inside the running CT. Called by the client on every open of
|
||||
the Mount Points tab so `df` usage and `stat` reachability are
|
||||
real at click time; skips the whole prewarmer loop entirely.
|
||||
|
||||
Ad-hoc mounts and capacity are what the operator actually
|
||||
watches (a stale NFS export shows here as `runtime_reachable
|
||||
= false`), so caching them would defeat the point."""
|
||||
try:
|
||||
import lxc_mount_points
|
||||
except ImportError as e:
|
||||
return jsonify({"ok": False, "error": f"helper unavailable: {e}"}), 503
|
||||
try:
|
||||
result = lxc_mount_points.get_lxc_mount_points_runtime(str(vmid))
|
||||
if not result.get("ok"):
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/logs', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_logs(vmid):
|
||||
@@ -20595,7 +20654,7 @@ if __name__ == '__main__':
|
||||
try:
|
||||
vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer')
|
||||
vm_modal_thread.start()
|
||||
print("[ProxMenux] VM-modal prewarmer started (initial warm-up + 180s refresh)")
|
||||
print("[ProxMenux] VM-modal prewarmer started (one-shot warm-up; caches refreshed by event invalidation only)")
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] VM-modal prewarmer failed to start: {e}")
|
||||
|
||||
|
||||
@@ -42,7 +42,17 @@ _APPS_DIR = "/etc/proxmenux/apps"
|
||||
_PCT_BIN = "/usr/sbin/pct"
|
||||
_PROBE_TIMEOUT_SEC = 15
|
||||
_GITHUB_TIMEOUT_SEC = 15
|
||||
_UPSTREAM_CACHE_TTL_SEC = 6 * 3600 # 6 h — GitHub is polite this way
|
||||
# Aligned with the master LXC update cycle in
|
||||
# notification_events.PollingCollector (UPDATE_CHECK_INTERVAL = 24 h).
|
||||
# Previously this was 6 h — half a day out of sync with the apt/apk
|
||||
# scan — so `refresh_all_apps` inside the 24 h collector would still
|
||||
# hit GitHub for apps whose upstream TTL had elapsed, doubling
|
||||
# checks. Unifying both to 24 h means one poll per day drives every
|
||||
# update flavour (OS packages + community-scripts app upstream).
|
||||
# Manual "Check" button + post-apply hook still pass force=True and
|
||||
# ignore this TTL, so the user never has to wait for the timer to
|
||||
# see a fresh result they explicitly asked for.
|
||||
_UPSTREAM_CACHE_TTL_SEC = 24 * 3600
|
||||
|
||||
_VALID_METHODS = ("dpkg", "apk", "file", "binary",
|
||||
"python_dist", "docker_label", "docker_exec",
|
||||
|
||||
@@ -539,18 +539,197 @@ def _stat_via_host(host_pid: str, ct_target: str,
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
|
||||
"""Top-level entry point used by the Flask route.
|
||||
def get_lxc_mount_points_static(vmid: str) -> dict[str, Any]:
|
||||
"""Static half of the mount-points payload — safe to cache
|
||||
indefinitely because it only reads config and classifies against
|
||||
PVE's storage inventory.
|
||||
|
||||
Returns:
|
||||
- ``ok`` (bool)
|
||||
- ``vmid`` (str)
|
||||
- ``mount_points`` — list of configured mp0/mp1/... entries with
|
||||
source / target / type / origin classification / host source
|
||||
existence flags. No `df`, no `stat`, no ad-hoc detection.
|
||||
|
||||
The runtime enrichment (capacity, health, ad-hoc mounts,
|
||||
runtime_mounted flag) lives in `get_lxc_mount_points_runtime`
|
||||
and is fetched fresh on every modal open by the client. That
|
||||
split lets the backend cache this half indefinitely (with event
|
||||
invalidation on start/stop) while still giving the user real-
|
||||
time capacity when they actually look."""
|
||||
if not re.match(r"^\d+$", vmid):
|
||||
return {"ok": False, "error": "invalid vmid"}
|
||||
|
||||
config_entries = _read_lxc_config(vmid)
|
||||
pve_storages = _list_pve_storages()
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for entry in config_entries:
|
||||
source = entry.get("source", "")
|
||||
target = entry.get("target", "")
|
||||
cls = _classify(source, pve_storages)
|
||||
host_src = _host_source_state(source)
|
||||
out.append({
|
||||
"mp_index": entry.get("mp_index", ""),
|
||||
"source": source,
|
||||
"target": target,
|
||||
"type": cls["type"],
|
||||
"origin_storage": cls.get("origin_storage", ""),
|
||||
"origin_storage_type": cls.get("origin_storage_type", ""),
|
||||
"origin_label": cls.get("origin_label", source),
|
||||
"config_options": entry.get("config_options", {}),
|
||||
"config_flags": entry.get("config_flags", []),
|
||||
"host_source_exists": host_src["exists"],
|
||||
"host_source_is_mountpoint": host_src["is_mountpoint"],
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"vmid": vmid,
|
||||
"mount_points": out,
|
||||
}
|
||||
|
||||
|
||||
def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
|
||||
"""Runtime half — always fresh, no cache. Fetched by the client
|
||||
every time the Mount Points tab is opened so the operator sees
|
||||
live capacity + reachability, plus any ad-hoc NFS/CIFS mounts
|
||||
the container itself has made since the last static snapshot.
|
||||
|
||||
Returns:
|
||||
- ``ok`` (bool)
|
||||
- ``vmid`` (str)
|
||||
- ``running`` (bool)
|
||||
- ``mount_points`` — list of configured mp0/mp1/... entries
|
||||
- ``ad_hoc`` — list of NFS/CIFS/SMB mounts found inside the running
|
||||
CT that aren't backed by an mp config line
|
||||
"""
|
||||
# Validate vmid format — the value comes from a URL parameter, so
|
||||
# we keep it strict to avoid path-traversal weirdness.
|
||||
- ``runtime`` — dict keyed by target, containing runtime state
|
||||
+ capacity per configured mount point
|
||||
- ``ad_hoc`` — list of NFS/CIFS/SMB mounts done inside the CT
|
||||
that aren't backed by an mp config line
|
||||
|
||||
The client merges `runtime[target]` onto the matching card from
|
||||
the static payload; ad-hoc mounts render as their own cards
|
||||
under a "Mounted inside container" divider. If the CT is down
|
||||
or the client had no static payload for a target, the tab still
|
||||
renders whatever runtime info is available (never blanks)."""
|
||||
if not re.match(r"^\d+$", vmid):
|
||||
return {"ok": False, "error": "invalid vmid"}
|
||||
|
||||
config_entries = _read_lxc_config(vmid)
|
||||
pve_storages = _list_pve_storages()
|
||||
running, host_pid = _ct_status(vmid)
|
||||
rt_mounts = _read_ct_proc_mounts(host_pid) if running else []
|
||||
|
||||
# Same parallelisation as the pre-split path: `df`/`stat` per
|
||||
# mount point are I/O-bound. Serialised, a CT with 5+ binds
|
||||
# tripped Caddy's 3s reverse-proxy timeout.
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
rt_by_target: dict[str, dict[str, Any]] = {m["rt_target"]: m for m in rt_mounts}
|
||||
|
||||
runtime_by_target: dict[str, dict[str, Any]] = {}
|
||||
matched_targets: set[str] = set()
|
||||
|
||||
def _gather_one(entry):
|
||||
src = entry.get("source", "")
|
||||
tgt = entry.get("target", "")
|
||||
classification = _classify(src, pve_storages)
|
||||
capacity = _capacity_for(
|
||||
src, classification, pve_storages,
|
||||
config_options=entry.get("config_options", {}),
|
||||
host_pid=host_pid if running else "",
|
||||
target=tgt,
|
||||
)
|
||||
live_target = bool(running and tgt and tgt in rt_by_target)
|
||||
health = _stat_via_host(host_pid, tgt) if live_target else None
|
||||
return entry, capacity, live_target, health
|
||||
|
||||
if config_entries:
|
||||
max_workers = max(2, min(8, len(config_entries)))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
gathered = list(pool.map(_gather_one, config_entries))
|
||||
else:
|
||||
gathered = []
|
||||
|
||||
for entry, cap, live_target, health in gathered:
|
||||
target = entry.get("target", "")
|
||||
rt_item: dict[str, Any] = {**cap}
|
||||
if live_target:
|
||||
rt = rt_by_target[target]
|
||||
rt_item.update({
|
||||
"runtime_mounted": True,
|
||||
"runtime_source": rt["rt_source"],
|
||||
"runtime_fstype": rt["rt_fstype"],
|
||||
"runtime_options": rt["rt_options"],
|
||||
"runtime_readonly": rt["rt_readonly"],
|
||||
"runtime_reachable": health["reachable"],
|
||||
"runtime_error": health["error"],
|
||||
})
|
||||
matched_targets.add(target)
|
||||
elif running:
|
||||
rt_item["runtime_mounted"] = False
|
||||
rt_item["runtime_error"] = "configured but not mounted"
|
||||
else:
|
||||
rt_item["runtime_mounted"] = None # CT down
|
||||
runtime_by_target[target] = rt_item
|
||||
|
||||
# Ad-hoc remote mounts inside the running CT — same logic and
|
||||
# parallelisation as before.
|
||||
ad_hoc: list[dict[str, Any]] = []
|
||||
if running:
|
||||
ad_hoc_candidates = [
|
||||
rt for rt in rt_mounts
|
||||
if rt["rt_target"] not in matched_targets
|
||||
and _REMOTE_FS_RE.match(rt["rt_fstype"])
|
||||
]
|
||||
if ad_hoc_candidates:
|
||||
max_workers = max(2, min(8, len(ad_hoc_candidates)))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
def _gather_adhoc(rt):
|
||||
h = _stat_via_host(host_pid, rt["rt_target"])
|
||||
if h.get("reachable"):
|
||||
cap = _df_via_pct_exec(vmid, rt["rt_target"])
|
||||
else:
|
||||
cap = {"total_bytes": None, "used_bytes": None,
|
||||
"available_bytes": None}
|
||||
return rt, h, cap
|
||||
results = list(pool.map(_gather_adhoc, ad_hoc_candidates))
|
||||
for rt, health, cap in results:
|
||||
ad_hoc.append({
|
||||
"mp_index": "",
|
||||
"source": rt["rt_source"],
|
||||
"target": rt["rt_target"],
|
||||
"type": "ad_hoc",
|
||||
"origin_storage": "",
|
||||
"origin_storage_type": "",
|
||||
"origin_label": rt["rt_source"],
|
||||
"config_options": {},
|
||||
"config_flags": [],
|
||||
"total_bytes": cap["total_bytes"],
|
||||
"used_bytes": cap["used_bytes"],
|
||||
"available_bytes": cap["available_bytes"],
|
||||
"runtime_mounted": True,
|
||||
"runtime_source": rt["rt_source"],
|
||||
"runtime_fstype": rt["rt_fstype"],
|
||||
"runtime_options": rt["rt_options"],
|
||||
"runtime_readonly": rt["rt_readonly"],
|
||||
"runtime_reachable": health["reachable"],
|
||||
"runtime_error": health["error"],
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"vmid": vmid,
|
||||
"running": running,
|
||||
"runtime": runtime_by_target,
|
||||
"ad_hoc": ad_hoc,
|
||||
}
|
||||
|
||||
|
||||
def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
|
||||
"""Legacy combined entry point — kept for backwards compatibility
|
||||
with any caller that still wants the pre-split shape. New code
|
||||
should hit the static/runtime pair separately.
|
||||
|
||||
Merges the two halves so the returned dict matches what the
|
||||
single-endpoint route used to return before the split."""
|
||||
if not re.match(r"^\d+$", vmid):
|
||||
return {"ok": False, "error": "invalid vmid"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user