mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
create 1.2.2.3 beta
This commit is contained in:
@@ -237,6 +237,22 @@ def _list_target_disks() -> list[str]:
|
||||
return fresh
|
||||
|
||||
|
||||
def _is_disk_usb(disk_name: str) -> bool:
|
||||
"""True if the disk sits behind a USB bus, checked via the resolved
|
||||
sysfs device path. USB-NVMe bridges (ASMedia, JMicron, Realtek) and
|
||||
plain USB-HDDs both report `/sys/block/<disk>/removable = 0`, so the
|
||||
older removable-flag heuristic missed them and the temperature
|
||||
poller never tried the snt* driver variants that are the only way
|
||||
to reach the NVMe controller behind those bridges."""
|
||||
try:
|
||||
base = disk_name[5:] if disk_name.startswith('/dev/') else disk_name
|
||||
real = os.path.realpath(f'/sys/block/{base}')
|
||||
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
|
||||
for seg in real.split('/'))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _smartctl_cmd_for(disk_name: str, probe: str) -> list[str]:
|
||||
"""Build the smartctl invocation for a given probe key.
|
||||
|
||||
@@ -354,7 +370,15 @@ def _read_temperature(disk_name: str) -> Optional[float]:
|
||||
# Cached probe stopped working — fall through and re-detect.
|
||||
|
||||
# Slow path: try every probe and remember the first one that works.
|
||||
for probe in ("auto", "nvme", "ata", "sat"):
|
||||
# For USB-attached disks we prepend the three snt* driver variants —
|
||||
# USB-NVMe bridges (ASMedia / JMicron / Realtek) don't answer the
|
||||
# plain probes with real SMART; only snt* passes through to the NVMe
|
||||
# controller so temperature actually comes back. Non-USB disks skip
|
||||
# them, so this adds zero overhead on internal drives.
|
||||
probes: tuple[str, ...] = ("auto", "nvme", "ata", "sat")
|
||||
if _is_disk_usb(disk_name):
|
||||
probes = ("sntasmedia", "sntjmicron", "sntrealtek") + probes
|
||||
for probe in probes:
|
||||
if probe == cached_probe:
|
||||
continue # already tried above
|
||||
temp = _handle(_try_probe(disk_name, probe))
|
||||
|
||||
@@ -2658,6 +2658,29 @@ def is_disk_removable(disk_name):
|
||||
return False
|
||||
|
||||
|
||||
def is_disk_usb(disk_name):
|
||||
"""Return True if the disk is attached via USB, using the sysfs device
|
||||
path. Reliable for USB-attached HDDs and USB-NVMe bridges (which
|
||||
both report `/sys/block/<disk>/removable = 0` even though they ARE
|
||||
USB) — the previous heuristic based on the removable flag missed
|
||||
them entirely, so snt* pass-through was never attempted and the
|
||||
bridge's own identity + missing temperature/hours were cached.
|
||||
|
||||
Uses `os.path.realpath` (no subprocess) so it's cheap enough to be
|
||||
called on every SMART probe.
|
||||
"""
|
||||
try:
|
||||
real = os.path.realpath(f'/sys/block/{disk_name}')
|
||||
# sysfs path segment like `usb1`, `usb2`, ... always precedes a
|
||||
# USB-attached block device. Match on the segment prefix rather
|
||||
# than a substring so a directory happening to contain "usb" in
|
||||
# its literal name can't false-positive.
|
||||
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
|
||||
for seg in real.split('/'))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_system_mount(mountpoint):
|
||||
"""Check if mountpoint is a critical system path (matching bash scripts logic)."""
|
||||
system_mounts = ('/', '/boot', '/boot/efi', '/efi', '/usr', '/var', '/etc',
|
||||
@@ -3676,12 +3699,15 @@ def _get_smart_data_uncached(disk_name):
|
||||
# passes through to the actual NVMe controller and returns real
|
||||
# model, serial, temperature and health.
|
||||
#
|
||||
# For removable disks we prepend the three snt* variants so the
|
||||
# cascade tries them FIRST — otherwise the plain variant "succeeds"
|
||||
# (>50 chars of bridge chatter), the probe cache locks it in, and
|
||||
# temperature is never seen. For non-removable disks the cascade
|
||||
# is unchanged (zero regression risk on internal SATA/NVMe).
|
||||
if is_disk_removable(disk_name):
|
||||
# For USB-attached disks we prepend the three snt* variants so
|
||||
# the cascade tries them FIRST — otherwise the plain variant
|
||||
# "succeeds" (>50 chars of bridge chatter), the probe cache locks
|
||||
# it in, and temperature is never seen. USB detection is by sysfs
|
||||
# path (`is_disk_usb`) rather than the `removable` flag: USB-NVMe
|
||||
# bridges and USB-HDDs both report `removable=0` even though they
|
||||
# ARE USB, so the older `is_disk_removable` check missed them.
|
||||
# For internal SATA/NVMe the cascade is unchanged (zero regression).
|
||||
if is_disk_usb(disk_name) or is_disk_removable(disk_name):
|
||||
all_commands = [
|
||||
['smartctl', '-a', '-j', '-d', 'sntasmedia', f'/dev/{disk_name}'],
|
||||
['smartctl', '-a', '-j', '-d', 'sntjmicron', f'/dev/{disk_name}'],
|
||||
@@ -10027,45 +10053,40 @@ def api_network_summary():
|
||||
bridge_interfaces = []
|
||||
|
||||
for interface_name, stats in net_if_stats.items():
|
||||
# Skip loopback and special interfaces
|
||||
if interface_name in ['lo', 'docker0'] or interface_name.startswith(('veth', 'tap', 'fw')):
|
||||
# Delegate classification to the shared helper so this endpoint
|
||||
# matches the full /api/network path. The inline prefix-based
|
||||
# check that lived here missed operator-renamed NICs (e.g.
|
||||
# `nic0` via systemd .link rules) and reported 0 physical
|
||||
# interfaces even when the host clearly had one — surfaces as
|
||||
# "Network Interfaces: N/A" on the System Overview card.
|
||||
iface_type = get_interface_type(interface_name)
|
||||
if iface_type not in ('physical', 'bridge'):
|
||||
continue
|
||||
|
||||
|
||||
is_up = stats.isup
|
||||
|
||||
# Classify interface type
|
||||
if interface_name.startswith(('enp', 'eth', 'eno', 'ens', 'wlan', 'wlp')):
|
||||
addresses = []
|
||||
if interface_name in net_if_addrs:
|
||||
for addr in net_if_addrs[interface_name]:
|
||||
if addr.family == socket.AF_INET:
|
||||
addresses.append({'ip': addr.address, 'netmask': addr.netmask})
|
||||
|
||||
if iface_type == 'physical':
|
||||
physical_total += 1
|
||||
if is_up:
|
||||
physical_active += 1
|
||||
# Get IP addresses
|
||||
addresses = []
|
||||
if interface_name in net_if_addrs:
|
||||
for addr in net_if_addrs[interface_name]:
|
||||
if addr.family == socket.AF_INET:
|
||||
addresses.append({'ip': addr.address, 'netmask': addr.netmask})
|
||||
|
||||
physical_interfaces.append({
|
||||
'name': interface_name,
|
||||
'status': 'up' if is_up else 'down',
|
||||
'addresses': addresses
|
||||
'status': 'up',
|
||||
'addresses': addresses,
|
||||
})
|
||||
|
||||
elif interface_name.startswith(('vmbr', 'br')):
|
||||
else: # bridge
|
||||
bridge_total += 1
|
||||
if is_up:
|
||||
bridge_active += 1
|
||||
# Get IP addresses
|
||||
addresses = []
|
||||
if interface_name in net_if_addrs:
|
||||
for addr in net_if_addrs[interface_name]:
|
||||
if addr.family == socket.AF_INET:
|
||||
addresses.append({'ip': addr.address, 'netmask': addr.netmask})
|
||||
|
||||
bridge_interfaces.append({
|
||||
'name': interface_name,
|
||||
'status': 'up' if is_up else 'down',
|
||||
'addresses': addresses
|
||||
'status': 'up',
|
||||
'addresses': addresses,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
@@ -13803,6 +13824,151 @@ def api_pbs_recovery_status():
|
||||
})
|
||||
|
||||
|
||||
# ─── Restore progress state (written by apply_cluster_postboot.sh) ──
|
||||
# The postboot script maintains /var/lib/proxmenux/restore-state.json
|
||||
# with a `running|complete|failed` status plus milestone counters,
|
||||
# per-component results, sanity warnings and a rollback delta. The
|
||||
# Backups tab polls status() to render a live card while the restore
|
||||
# is running and a summary once it's done; dismiss() flips the
|
||||
# `acknowledged` flag so the card collapses; history() lists archived
|
||||
# runs; log() returns a filtered tail of the postboot log.
|
||||
|
||||
_RESTORE_STATE_FILE = '/var/lib/proxmenux/restore-state.json'
|
||||
_RESTORE_HISTORY_DIR = '/var/lib/proxmenux/restore-history'
|
||||
_RESTORE_LOG_DIR = '/var/log/proxmenux'
|
||||
|
||||
|
||||
def _read_restore_state(path):
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
@app.route('/api/host-backups/restore/status', methods=['GET'])
|
||||
@require_auth
|
||||
def api_host_backups_restore_status():
|
||||
"""Current restore-state.json content — empty response means no
|
||||
restore has ever run on this host (or the state dir is missing)."""
|
||||
state = _read_restore_state(_RESTORE_STATE_FILE)
|
||||
if state is None:
|
||||
return jsonify({'state': None})
|
||||
return jsonify({'state': state})
|
||||
|
||||
|
||||
@app.route('/api/host-backups/restore/dismiss', methods=['POST'])
|
||||
@require_auth
|
||||
def api_host_backups_restore_dismiss():
|
||||
"""Flip acknowledged=true on the current state file so the Backups
|
||||
tab card collapses. The state file itself is preserved until the
|
||||
next restore overwrites it — the operator can still open the
|
||||
history modal to review this run's details later."""
|
||||
state = _read_restore_state(_RESTORE_STATE_FILE)
|
||||
if state is None:
|
||||
return jsonify({'error': 'no restore state to dismiss'}), 404
|
||||
state['acknowledged'] = True
|
||||
try:
|
||||
tmp = _RESTORE_STATE_FILE + '.tmp'
|
||||
with open(tmp, 'w') as f:
|
||||
json.dump(state, f)
|
||||
os.replace(tmp, _RESTORE_STATE_FILE)
|
||||
except OSError as e:
|
||||
return jsonify({'error': f'cannot persist state: {e}'}), 500
|
||||
return jsonify({'status': 'ok'})
|
||||
|
||||
|
||||
@app.route('/api/host-backups/restore/history', methods=['GET'])
|
||||
@require_auth
|
||||
def api_host_backups_restore_history():
|
||||
"""List archived restore runs (newest first). Each entry carries
|
||||
just the summary metadata; the detail modal fetches the full
|
||||
payload on demand via ?file=<name>."""
|
||||
file_arg = request.args.get('file')
|
||||
if file_arg:
|
||||
# Reject anything that could climb out of the history dir.
|
||||
# Filenames are always <YYYYmmdd_HHMMSS>-<complete|failed>.json.
|
||||
if '/' in file_arg or '..' in file_arg or not file_arg.endswith('.json'):
|
||||
return jsonify({'error': 'invalid file name'}), 400
|
||||
full = os.path.join(_RESTORE_HISTORY_DIR, file_arg)
|
||||
payload = _read_restore_state(full)
|
||||
if payload is None:
|
||||
return jsonify({'error': 'not found'}), 404
|
||||
return jsonify({'state': payload})
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for name in os.listdir(_RESTORE_HISTORY_DIR):
|
||||
if not name.endswith('.json'):
|
||||
continue
|
||||
full = os.path.join(_RESTORE_HISTORY_DIR, name)
|
||||
try:
|
||||
mtime = os.stat(full).st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
payload = _read_restore_state(full) or {}
|
||||
entries.append({
|
||||
'file': name,
|
||||
'mtime': mtime,
|
||||
'status': payload.get('status', 'unknown'),
|
||||
'started_at': payload.get('started_at'),
|
||||
'finished_at': payload.get('finished_at'),
|
||||
'duration': (payload.get('summary') or {}).get('duration'),
|
||||
})
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
entries.sort(key=lambda e: e['mtime'], reverse=True)
|
||||
return jsonify({'entries': entries})
|
||||
|
||||
|
||||
@app.route('/api/host-backups/restore/log', methods=['GET'])
|
||||
@require_auth
|
||||
def api_host_backups_restore_log():
|
||||
"""Return a tail of the postboot log — optionally filtered to just
|
||||
error/warning lines. Path is taken from the state file's log_path
|
||||
(or from ?path=… for a history entry) and validated to live under
|
||||
/var/log/proxmenux so an operator can't read arbitrary files."""
|
||||
path = request.args.get('path')
|
||||
filter_mode = request.args.get('filter', 'all') # all | issues
|
||||
try:
|
||||
tail_lines = min(int(request.args.get('tail', '400')), 4000)
|
||||
except ValueError:
|
||||
tail_lines = 400
|
||||
|
||||
if not path:
|
||||
state = _read_restore_state(_RESTORE_STATE_FILE) or {}
|
||||
path = state.get('log_path')
|
||||
if not path:
|
||||
return jsonify({'lines': [], 'path': None})
|
||||
|
||||
# Resolve to guard against traversal; the log MUST live inside
|
||||
# /var/log/proxmenux for the endpoint to expose it.
|
||||
real = os.path.realpath(path)
|
||||
if not real.startswith(_RESTORE_LOG_DIR + os.sep) and real != _RESTORE_LOG_DIR:
|
||||
return jsonify({'error': 'log path outside allowed directory'}), 400
|
||||
if not os.path.isfile(real):
|
||||
return jsonify({'lines': [], 'path': path}), 200
|
||||
|
||||
try:
|
||||
with open(real, 'r', errors='replace') as f:
|
||||
# Simple tail: read the whole file (postboot logs are
|
||||
# small — a few hundred KB at worst) and slice.
|
||||
data = f.readlines()
|
||||
except OSError as e:
|
||||
return jsonify({'error': f'cannot read log: {e}'}), 500
|
||||
|
||||
if filter_mode == 'issues':
|
||||
needle = re.compile(r'(error|warning|failed|✗|✘|traceback)', re.IGNORECASE)
|
||||
data = [ln for ln in data if needle.search(ln)]
|
||||
|
||||
return jsonify({
|
||||
'path': path,
|
||||
'total_lines': len(data),
|
||||
'lines': [ln.rstrip('\n') for ln in data[-tail_lines:]],
|
||||
'filter': filter_mode,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/host-backups/pbs-encryption/import', methods=['POST'])
|
||||
@require_auth
|
||||
def api_pbs_encryption_import():
|
||||
|
||||
@@ -83,6 +83,22 @@ def _is_disk_removable(disk_name: str) -> bool:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_disk_usb(disk_name: str) -> bool:
|
||||
"""True if the disk sits behind a USB bus. Reads the resolved sysfs
|
||||
device path — reliable for USB-NVMe bridges and USB-attached HDDs
|
||||
that report `removable=0` even though they ARE USB (so the older
|
||||
`_is_disk_removable` heuristic skipped snt* driver probes and left
|
||||
NVMe-behind-a-bridge disks with the bridge's own chatter cached
|
||||
forever)."""
|
||||
try:
|
||||
base = _disk_base_for_sysfs(disk_name)
|
||||
real = os.path.realpath(f'/sys/block/{base}')
|
||||
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
|
||||
for seg in real.split('/'))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
class HealthMonitor:
|
||||
"""
|
||||
Monitors system health across multiple components with minimal impact.
|
||||
@@ -2431,13 +2447,16 @@ class HealthMonitor:
|
||||
try:
|
||||
dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name
|
||||
|
||||
# Removable disks may be USB-NVMe bridges: try the snt* driver
|
||||
# variants first so identity reflects the drive (Samsung 990 PRO)
|
||||
# rather than the enclosure (ASMT 2462 NVME). If all snt* fail,
|
||||
# fall through to the plain call — that's still correct for
|
||||
# USB-SATA sticks and non-USB devices.
|
||||
# USB-attached disks may sit behind an NVMe bridge: try the
|
||||
# snt* driver variants first so identity reflects the drive
|
||||
# (Samsung 990 PRO) rather than the enclosure (ASMT 2462 NVME).
|
||||
# If all snt* fail, fall through to the plain call — that's
|
||||
# still correct for USB-SATA sticks and non-USB devices.
|
||||
# USB detection is by sysfs path (`_is_disk_usb`) rather than
|
||||
# the `removable` flag, since USB-NVMe and USB-HDD both report
|
||||
# `removable=0` even though they ARE USB.
|
||||
attempts = []
|
||||
if _is_disk_removable(disk_name):
|
||||
if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
|
||||
for drv in _USB_NVME_DRIVERS:
|
||||
attempts.append(['smartctl', '-i', '-j', '-d', drv, dev_path])
|
||||
attempts.append(['smartctl', '-i', '-j', dev_path])
|
||||
@@ -2495,12 +2514,14 @@ class HealthMonitor:
|
||||
# sleep — issue #232. The "UNKNOWN" branch below correctly
|
||||
# keeps the previous cached result alive on exit code 2.
|
||||
#
|
||||
# Removable disks may be USB-NVMe bridges: try snt* drivers
|
||||
# first so health reflects the actual NVMe controller. A
|
||||
# bridge that fakes "PASSED" while the drive behind it is
|
||||
# USB-attached disks may sit behind an NVMe bridge: try snt*
|
||||
# drivers first so health reflects the actual NVMe controller.
|
||||
# A bridge that fakes "PASSED" while the drive behind it is
|
||||
# failing is exactly the false-negative we want to avoid.
|
||||
# USB detection uses the sysfs path so USB-NVMe bridges (which
|
||||
# report removable=0) are caught too.
|
||||
attempts = []
|
||||
if _is_disk_removable(disk_name):
|
||||
if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
|
||||
for drv in _USB_NVME_DRIVERS:
|
||||
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', '-d', drv, dev_path])
|
||||
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', dev_path])
|
||||
|
||||
Reference in New Issue
Block a user