diff --git a/gui/admin.html b/gui/admin.html index 1e7d6a9..4a8a78e 100644 --- a/gui/admin.html +++ b/gui/admin.html @@ -154,42 +154,162 @@ Systemstatus Telemetry -
Netzwerk
-
- - WLAN - - -
-
- - Signal - - -
-
- - IP - - -
-
Hardware
-
- Temp -
- -- -
-
- - Spannung - - -
-
- - Uptime - - -
-
- - Disk frei - - +
+
+
Netzwerk
+
+ + WLAN + - +
+
+ + SSID + - +
+
+ + Signal + - +
+
+ + Signal dBm + - +
+
+ + Kanal + - +
+
+ + Frequenz + - +
+
+ + IP + - +
+
+ +
+
System
+
+ + Pi-Modell + - +
+
+ + Hostname + - +
+
+ + Kernel + - +
+
+ + Pi-Zeit + - +
+
+ + Zeitzone + - +
+
+ + Zeit-Sync + - +
+
+ +
+
Hardware
+
+ Temp +
+ -- +
+
+ + GPU-/SoC-Temp + - +
+
+ + CPU gesamt + - +
+
+ + CPU Kerne + - +
+
+ + RAM + - +
+
+ + Spannung + - +
+
+ + Throttling + - +
+
+ + Uptime + - +
+
+ + Load 1/5/15 + - +
+
+ +
+
Speicher & Container
+
+ + Root-Dateisystem + - +
+
+ + Docker-Speicher + - +
+
+ + Inodes frei + - +
+
+ + Container-Uptime + - +
+
+ + Container-Restarts + - +
+
+ + Disk frei + - +
+
@@ -784,10 +904,31 @@ function clearAdminApiDisplay() { setText('info_ips', '--'); setText('info_wifi', '--'); + setText('info_wifi_ssid', '--'); setText('info_wifi_signal', '--'); + setText('info_wifi_signal_dbm', '--'); + setText('info_wifi_channel', '--'); + setText('info_wifi_frequency', '--'); + setText('info_pi_model', '--'); + setText('info_hostname', '--'); + setText('info_kernel', '--'); + setText('info_system_time', '--'); + setText('info_system_timezone', '--'); + setText('info_time_sync', '--'); setText('info_temp', '--'); + setText('info_gpu_temp', '--'); + setText('info_cpu_usage', '--'); + setText('info_cpu_per_core', '--'); + setText('info_memory_details', '--'); setText('info_uptime', '--'); setText('info_disk', '--'); + setText('info_disk_root', '--'); + setText('info_disk_docker', '--'); + setText('info_inode_free', '--'); + setText('info_load_average', '--'); + setText('info_throttling', '--'); + setText('info_container_uptime', '--'); + setText('info_container_restarts', '--'); setUndervoltageState('info_undervoltage', '--', 'unknown'); setServiceState('service_camera', '-'); setServiceState('camera_service_state', '-'); @@ -2285,20 +2426,43 @@ } function renderStatus(data) { + var system = data.system || {}; + var services = data.services || {}; setAdminStatus(data.status || 'Bereit'); - setText('info_ips', data.system && data.system.ips); - setText('info_wifi', data.system && data.system.wifi); - setText('info_wifi_signal', data.system && data.system.wifi_signal); - setText('info_temp', data.system && data.system.cpu_temperature); + setText('info_ips', system.ips); + setText('info_wifi', system.wifi); + setText('info_wifi_ssid', system.wifi_ssid); + setText('info_wifi_signal', system.wifi_signal); + setText('info_wifi_signal_dbm', system.wifi_signal_dbm); + setText('info_wifi_channel', system.wifi_channel); + setText('info_wifi_frequency', system.wifi_frequency); + setText('info_pi_model', system.pi_model); + setText('info_hostname', system.hostname); + setText('info_kernel', system.kernel_version); + setText('info_system_time', system.system_time); + setText('info_system_timezone', system.system_timezone); + setText('info_time_sync', 'NTP: ' + (system.ntp_sync || '--') + ' | Uhr: ' + (system.clock_sync || '--')); + setText('info_temp', system.cpu_temperature); + setText('info_gpu_temp', system.gpu_temperature); + setText('info_cpu_usage', system.cpu_usage); + setText('info_cpu_per_core', system.cpu_usage_per_core); + setText('info_memory_details', system.memory_details || system.memory_usage); var bar = document.getElementById('bar-temp-adm'); - if (bar && data.system) bar.style.width = temperatureToWidth(data.system.cpu_temperature); - setUndervoltageState('info_undervoltage', data.system && data.system.undervoltage, data.system && data.system.undervoltage_state); - setText('info_uptime', data.system && data.system.uptime); - setText('info_disk', data.system && data.system.disk_free); - setServiceState('service_camera', data.services && data.services.camera); - setServiceState('camera_service_state', data.services && data.services.camera); - setServiceState('service_admin_api', data.services && data.services.admin_api); - setServiceState('service_exomy', data.services && data.services.exomy); + if (bar) bar.style.width = temperatureToWidth(system.cpu_temperature); + setUndervoltageState('info_undervoltage', system.undervoltage, system.undervoltage_state); + setText('info_throttling', system.throttling_details); + setText('info_uptime', system.uptime); + setText('info_load_average', system.load_average); + setText('info_disk', system.disk_free); + setText('info_disk_root', system.disk_root); + setText('info_disk_docker', system.disk_docker); + setText('info_inode_free', system.inode_free); + setText('info_container_uptime', services.exomy_uptime); + setText('info_container_restarts', services.exomy_restarts); + setServiceState('service_camera', services.camera); + setServiceState('camera_service_state', services.camera); + setServiceState('service_admin_api', services.admin_api); + setServiceState('service_exomy', services.exomy); } async function fetchStatus() { diff --git a/gui/app.css b/gui/app.css index fd85f45..dee960a 100644 --- a/gui/app.css +++ b/gui/app.css @@ -975,6 +975,24 @@ body.page-admin .ss-hdr{ } body.page-admin .ss-hdr-line{ flex:1; height:1px; background:repeating-linear-gradient(90deg, rgba(0,255,145,.22) 0, rgba(0,255,145,.22) 3px, transparent 3px, transparent 6px); } body.page-admin .ss-hdr-txt{ font-size:7px; letter-spacing:4px; color:rgba(210,255,235,.9); text-transform:uppercase; } +body.page-admin .systemstatus-grid{ + display:grid; + grid-template-columns:repeat(2, minmax(0, 1fr)); + gap:12px; + padding:12px; +} +body.page-admin .systemstatus-section{ + min-width:0; + border:1px solid rgba(0,255,145,.08); + background:rgba(0,255,145,.025); +} +body.page-admin .systemstatus-section .ss-hdr{ + margin:0; + border-top:none; +} +body.page-admin .systemstatus-section .sr:last-child{ + border-bottom:none; +} /* ── GAUGE ──────────────────────────────────────── */ body.page-admin .sr-gauge{ padding-bottom:6px; } @@ -1595,6 +1613,7 @@ body.page-admin .leaflet-popup-tip{ body.page-admin .drive-estimator-value{ font-size:28px; } body.page-admin .drive-calibration-toolbar{ flex-direction:column; align-items:stretch; } body.page-admin .drive-calibration-grid{ grid-template-columns:1fr; } + body.page-admin .systemstatus-grid{ grid-template-columns:1fr; } body.page-admin .admin-section-grid_map{ grid-template-columns:1fr; } body.page-admin .admin-section-grid_gps{ grid-template-columns:1fr; } body.page-admin .map-panel{ min-height:0; } diff --git a/scripts/exomy_admin_api.py b/scripts/exomy_admin_api.py index c50769f..7cbf13b 100644 --- a/scripts/exomy_admin_api.py +++ b/scripts/exomy_admin_api.py @@ -7,6 +7,7 @@ import http.server import json import os import shutil +import socket import socketserver import struct import subprocess @@ -85,6 +86,14 @@ def read_cpu_temperature(): return 'unbekannt' +def read_gpu_temperature(): + stdout, _, returncode = run_command(['vcgencmd', 'measure_temp']) + if returncode != 0 or not stdout or '=' not in stdout: + return 'unbekannt' + value = stdout.split('=', 1)[1].strip().replace("'C", ' °C') + return value or 'unbekannt' + + def read_cpu_usage(): try: with open('/proc/stat', 'r', encoding='utf-8') as handle: @@ -108,6 +117,41 @@ def read_cpu_usage(): return 'unbekannt' +def read_per_core_usage(): + try: + with open('/proc/stat', 'r', encoding='utf-8') as handle: + first_lines = [line.split() for line in handle if line.startswith('cpu') and line[3:4].isdigit()] + time.sleep(0.12) + with open('/proc/stat', 'r', encoding='utf-8') as handle: + second_lines = [line.split() for line in handle if line.startswith('cpu') and line[3:4].isdigit()] + except (FileNotFoundError, ValueError): + return 'unbekannt' + + if len(first_lines) != len(second_lines) or not first_lines: + return 'unbekannt' + + results = [] + for first, second in zip(first_lines, second_lines): + try: + name = first[0] + first_values = [int(value) for value in first[1:]] + second_values = [int(value) for value in second[1:]] + idle_first = first_values[3] + first_values[4] + idle_second = second_values[3] + second_values[4] + total_first = sum(first_values) + total_second = sum(second_values) + total_delta = total_second - total_first + idle_delta = idle_second - idle_first + if total_delta <= 0: + continue + usage = (1 - (idle_delta / total_delta)) * 100 + results.append(f'{name.upper()}: {usage:.0f}%') + except (ValueError, IndexError): + continue + + return ' | '.join(results) if results else 'unbekannt' + + def read_memory_usage(): try: values = {} @@ -126,6 +170,27 @@ def read_memory_usage(): return 'unbekannt' +def read_memory_details(): + try: + values = {} + with open('/proc/meminfo', 'r', encoding='utf-8') as handle: + for line in handle: + key, raw_value = line.split(':', 1) + values[key] = int(raw_value.strip().split()[0]) + total = values.get('MemTotal') + available = values.get('MemAvailable') + if not total or available is None: + return 'unbekannt' + used = total - available + total_mb = total / 1024 + used_mb = used / 1024 + free_mb = available / 1024 + percent = (used / total) * 100 + return f'{used_mb:.0f} MB belegt / {free_mb:.0f} MB frei / {total_mb:.0f} MB gesamt ({percent:.0f}%)' + except (FileNotFoundError, ValueError): + return 'unbekannt' + + def read_undervoltage_status(): stdout, _, returncode = run_command(['vcgencmd', 'get_throttled']) if returncode != 0 or not stdout or '=' not in stdout: @@ -161,6 +226,32 @@ def read_undervoltage_status(): } +def read_throttling_details(): + stdout, _, returncode = run_command(['vcgencmd', 'get_throttled']) + if returncode != 0 or not stdout or '=' not in stdout: + return 'unbekannt' + + try: + throttled_value = int(stdout.split('=', 1)[1].strip(), 16) + except ValueError: + return 'unbekannt' + + flags = [ + (0x1, 'Unterspannung aktuell'), + (0x2, 'ARM gedrosselt aktuell'), + (0x4, 'aktuell limitiert'), + (0x8, 'Soft-Temperaturlimit aktuell'), + (0x10000, 'Unterspannung früher'), + (0x20000, 'ARM früher gedrosselt'), + (0x40000, 'früher limitiert'), + (0x80000, 'Soft-Temperaturlimit früher'), + ] + active = [label for bitmask, label in flags if throttled_value & bitmask] + if not active: + return 'keine Auffälligkeiten' + return ' | '.join(active) + + def format_uptime(): try: with open('/proc/uptime', 'r', encoding='utf-8') as handle: @@ -188,6 +279,39 @@ def format_disk_free(): return f'{free_gb:.1f} GB frei / {total_gb:.1f} GB' +def format_path_disk_usage(path): + try: + usage = shutil.disk_usage(path) + free_gb = usage.free / (1024 ** 3) + used_gb = usage.used / (1024 ** 3) + total_gb = usage.total / (1024 ** 3) + return f'{used_gb:.1f} GB belegt / {free_gb:.1f} GB frei / {total_gb:.1f} GB' + except FileNotFoundError: + return 'unbekannt' + + +def read_inode_free(path='/'): + try: + stats = os.statvfs(path) + total = stats.f_files + free = stats.f_ffree + used = total - free + if total <= 0: + return 'unbekannt' + percent_used = (used / total) * 100 + return f'{free:,} frei / {total:,} gesamt ({percent_used:.0f}% belegt)'.replace(',', '.') + except OSError: + return 'unbekannt' + + +def read_load_average(): + try: + load_1, load_5, load_15 = os.getloadavg() + return f'{load_1:.2f} / {load_5:.2f} / {load_15:.2f}' + except (AttributeError, OSError): + return 'unbekannt' + + def get_ip_addresses(): stdout, _, returncode = run_command(['ip', '-4', '-o', 'addr', 'show', 'dev', 'wlan0', 'scope', 'global']) if returncode == 0 and stdout: @@ -235,29 +359,87 @@ def get_wifi_status(): return 'kein wlan0' -def get_wifi_signal(): - stdout, _, returncode = run_command(['nmcli', '-t', '-f', 'GENERAL.STATE,AP.SIGNAL', 'device', 'show', 'wlan0']) +def get_wifi_details(): + details = { + 'ssid': 'unbekannt', + 'signal_percent': 'unbekannt', + 'signal_dbm': 'unbekannt', + 'channel': 'unbekannt', + 'frequency': 'unbekannt', + } + stdout, _, returncode = run_command(['nmcli', '-t', '-f', 'ACTIVE,SSID,SIGNAL,CHAN,FREQ,BARS', 'device', 'wifi', 'list', 'ifname', 'wlan0']) if returncode != 0 or not stdout: - return 'unbekannt' + return details - state = '' - signal = '' for line in stdout.splitlines(): - if line.startswith('GENERAL.STATE:'): - state = line.split(':', 1)[1].strip() - elif line.startswith('AP[1].SIGNAL:'): - signal = line.split(':', 1)[1].strip() + if not line.startswith('yes:'): + continue + parts = line.split(':') + if len(parts) >= 6: + ssid = parts[1].strip() + signal = parts[2].strip() + channel = parts[3].strip() + frequency = parts[4].strip() + details['ssid'] = ssid or 'unbekannt' + details['signal_percent'] = f'{signal} %' if signal else 'unbekannt' + details['channel'] = channel or 'unbekannt' + details['frequency'] = frequency or 'unbekannt' + try: + dbm = (int(float(signal)) / 2) - 100 + details['signal_dbm'] = f'{dbm:.0f} dBm' + except ValueError: + details['signal_dbm'] = 'unbekannt' + break + return details - if 'connected' not in state.lower(): - return 'nicht verbunden' - if not signal: - return 'unbekannt' +def get_wifi_signal(): + details = get_wifi_details() + return details['signal_percent'] + + +def get_hostname(): try: - percent = max(0, min(100, int(float(signal)))) - except ValueError: + return socket.gethostname() + except Exception: return 'unbekannt' - return f'{percent} %' + + +def get_kernel_version(): + stdout, _, returncode = run_command(['uname', '-r']) + if returncode == 0 and stdout: + return stdout + return 'unbekannt' + + +def get_pi_model(): + try: + with open('/proc/device-tree/model', 'r', encoding='utf-8') as handle: + return handle.read().strip('\x00').strip() or 'unbekannt' + except (FileNotFoundError, ValueError): + return 'unbekannt' + + +def get_system_time_details(): + local_time = datetime.datetime.now().astimezone() + timedatectl_stdout, _, timedatectl_returncode = run_command(['timedatectl', 'show', '-p', 'NTPSynchronized', '-p', 'SystemClockSynchronized', '-p', 'Timezone']) + ntp_sync = 'unbekannt' + clock_sync = 'unbekannt' + timezone_name = local_time.tzname() or 'unbekannt' + if timedatectl_returncode == 0 and timedatectl_stdout: + for line in timedatectl_stdout.splitlines(): + if line.startswith('NTPSynchronized='): + ntp_sync = 'ja' if line.split('=', 1)[1].strip().lower() == 'yes' else 'nein' + elif line.startswith('SystemClockSynchronized='): + clock_sync = 'ja' if line.split('=', 1)[1].strip().lower() == 'yes' else 'nein' + elif line.startswith('Timezone='): + timezone_name = line.split('=', 1)[1].strip() or timezone_name + return { + 'local': local_time.strftime('%Y-%m-%d %H:%M:%S'), + 'timezone': timezone_name, + 'ntp_sync': ntp_sync, + 'clock_sync': clock_sync, + } def get_systemd_state(service_name): @@ -274,6 +456,59 @@ def get_container_state(container_name): return 'unknown' +def get_container_details(container_name): + stdout, _, returncode = run_command(['docker', 'inspect', container_name]) + if returncode != 0 or not stdout: + return { + 'uptime': 'unbekannt', + 'restarts': 'unbekannt', + 'docker_disk': 'unbekannt', + } + + try: + payload = json.loads(stdout) + info = payload[0] if payload else {} + state = info.get('State', {}) + started_at = state.get('StartedAt') + restart_count = info.get('RestartCount', state.get('RestartCount', 'unbekannt')) + uptime = 'unbekannt' + if started_at and started_at != '0001-01-01T00:00:00Z': + started = datetime.datetime.fromisoformat(started_at.replace('Z', '+00:00')) + delta = datetime.datetime.now(datetime.timezone.utc) - started + uptime = format_duration(int(delta.total_seconds())) + return { + 'uptime': uptime, + 'restarts': str(restart_count), + 'docker_disk': get_docker_disk_usage(), + } + except (ValueError, KeyError, TypeError, IndexError): + return { + 'uptime': 'unbekannt', + 'restarts': 'unbekannt', + 'docker_disk': 'unbekannt', + } + + +def get_docker_disk_usage(): + stdout, _, returncode = run_command(['docker', 'info', '-f', '{{.DockerRootDir}}']) + if returncode != 0 or not stdout: + return 'unbekannt' + return format_path_disk_usage(stdout.strip()) + + +def format_duration(seconds): + days, remainder = divmod(max(0, int(seconds)), 86400) + hours, remainder = divmod(remainder, 3600) + minutes, _ = divmod(remainder, 60) + parts = [] + if days: + parts.append(f'{days}d') + if days or hours: + parts.append(f'{hours}h') + parts.append(f'{minutes}m') + return ' '.join(parts) + + def get_motor_test_container_status(): state = get_container_state(EXOMY_CONTAINER) return { @@ -285,25 +520,49 @@ def get_motor_test_container_status(): def collect_status(): undervoltage = read_undervoltage_status() + wifi_details = get_wifi_details() + time_details = get_system_time_details() + container_details = get_container_details(EXOMY_CONTAINER) return { 'status': 'Bereit', 'system': { 'wifi': get_wifi_status(), 'wifi_signal': get_wifi_signal(), + 'wifi_signal_dbm': wifi_details['signal_dbm'], + 'wifi_ssid': wifi_details['ssid'], + 'wifi_channel': wifi_details['channel'], + 'wifi_frequency': wifi_details['frequency'], 'ips': get_ip_addresses(), 'undervoltage': undervoltage['text'], 'undervoltage_state': undervoltage['state'], + 'throttling_details': read_throttling_details(), 'cpu_temperature': read_cpu_temperature(), + 'gpu_temperature': read_gpu_temperature(), 'cpu_usage': read_cpu_usage(), + 'cpu_usage_per_core': read_per_core_usage(), 'memory_usage': read_memory_usage(), + 'memory_details': read_memory_details(), 'uptime': format_uptime(), 'disk_free': format_disk_free(), + 'disk_root': format_path_disk_usage('/'), + 'disk_docker': container_details['docker_disk'], + 'inode_free': read_inode_free('/'), + 'load_average': read_load_average(), + 'hostname': get_hostname(), + 'kernel_version': get_kernel_version(), + 'pi_model': get_pi_model(), + 'system_time': time_details['local'], + 'system_timezone': time_details['timezone'], + 'ntp_sync': time_details['ntp_sync'], + 'clock_sync': time_details['clock_sync'], }, 'services': { 'camera': get_systemd_state('exomy-camera-stream.service'), 'camera_settings_version': read_camera_settings_version(), 'admin_api': get_systemd_state('exomy-admin-api.service'), 'exomy': get_container_state(EXOMY_CONTAINER), + 'exomy_uptime': container_details['uptime'], + 'exomy_restarts': container_details['restarts'], } }