false, 'error' => $message]); exit(1); } function ini_sections(string $path): array { if (!is_readable($path)) return []; $result = parse_ini_file($path, true, INI_SCANNER_RAW); return is_array($result) ? $result : []; } function ini_flat(string $path): array { if (!is_readable($path)) return []; $result = parse_ini_file($path, false, INI_SCANNER_RAW); return is_array($result) ? $result : []; } function number_value($value): int|float { if (!is_numeric($value)) return 0; return str_contains((string)$value, '.') ? (float)$value : (int)$value; } function clean_text(string $text, int $max = 500): string { $text = preg_replace('/\x1b\[[0-9;]*[A-Za-z]/', '', $text) ?? ''; $text = preg_replace('/((?:api[_-]?key|token|secret|password|passwd|authorization|cookie)\s*[=:]\s*)([^\s,;]+)/i', '$1[REDACTED]', $text) ?? ''; $text = preg_replace('/("(?:api[_-]?key|token|secret|password|passwd|authorization|cookie)"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', $text) ?? ''; $text = trim(preg_replace('/\s+/', ' ', $text) ?? ''); return mb_substr($text, 0, $max); } function bytes_from_meminfo(string $key): int { $raw = @file_get_contents('/proc/meminfo') ?: ''; if (preg_match('/^' . preg_quote($key, '/') . ':\s+(\d+)\s+kB$/mi', $raw, $m)) { return (int)$m[1] * 1024; } return 0; } function thermal_readings(): array { $out = []; foreach (glob('/sys/class/hwmon/hwmon*') ?: [] as $dir) { $chip = trim(@file_get_contents($dir . '/name') ?: basename($dir)); foreach (glob($dir . '/temp*_input') ?: [] as $input) { $raw = trim(@file_get_contents($input) ?: ''); if (!is_numeric($raw)) continue; $value = (float)$raw / 1000; if ($value < -20 || $value > 150) continue; $prefix = preg_replace('/_input$/', '', $input); $label = trim(@file_get_contents($prefix . '_label') ?: basename((string)$prefix)); $out[] = ['sensor' => $chip . ':' . $label, 'celsius' => round($value, 1)]; if (count($out) >= 24) break 2; } } return $out; } function system_health(): array { $total = bytes_from_meminfo('MemTotal'); $available = bytes_from_meminfo('MemAvailable'); $load = array_map('floatval', array_slice(preg_split('/\s+/', trim(@file_get_contents('/proc/loadavg') ?: '0 0 0')), 0, 3)); $uptime = (int)floor((float)explode(' ', trim(@file_get_contents('/proc/uptime') ?: '0'))[0]); return [ 'schema_version' => '1.0', 'hostname' => gethostname() ?: '', 'uptime_seconds' => $uptime, 'cpu_logical' => (int)trim((string)shell_exec('getconf _NPROCESSORS_ONLN 2>/dev/null')), 'load_average' => ['one' => $load[0] ?? 0, 'five' => $load[1] ?? 0, 'fifteen' => $load[2] ?? 0], 'memory' => [ 'total_bytes' => $total, 'available_bytes' => $available, 'used_bytes' => max(0, $total - $available), 'used_percent' => $total > 0 ? round((($total - $available) / $total) * 100, 1) : 0, ], 'temperatures' => thermal_readings(), ]; } function selected_disk(array $disk): array { $size = number_value($disk['fsSize'] ?? $disk['size'] ?? 0); $used = number_value($disk['fsUsed'] ?? 0); $free = number_value($disk['fsFree'] ?? 0); return [ 'name' => $disk['name'] ?? '', 'device' => $disk['device'] ?? '', 'type' => $disk['type'] ?? '', 'status' => $disk['status'] ?? '', 'state' => $disk['state'] ?? '', 'temperature_c' => number_value($disk['temp'] ?? 0), 'errors' => number_value($disk['numErrors'] ?? 0), 'filesystem' => $disk['fsType'] ?? '', 'filesystem_status' => $disk['fsStatus'] ?? '', 'size_bytes' => $size, 'used_bytes' => $used, 'free_bytes' => $free, 'used_percent' => $size > 0 ? round(($used / $size) * 100, 1) : 0, ]; } function storage_status(): array { $var = ini_flat('/var/local/emhttp/var.ini'); $disks = array_values(array_map('selected_disk', ini_sections('/var/local/emhttp/disks.ini'))); $disabled = number_value($var['mdNumDisabled'] ?? 0); $missing = number_value($var['mdNumMissing'] ?? 0); $invalid = number_value($var['mdNumInvalid'] ?? 0); $alerts = []; if ($disabled > 0) $alerts[] = "$disabled disk(s) disabled"; if ($missing > 0) $alerts[] = "$missing disk(s) missing"; if ($invalid > 0) $alerts[] = "$invalid disk(s) invalid"; return [ 'schema_version' => '1.0', 'array_state' => $var['mdState'] ?? '', 'filesystem_state' => $var['fsState'] ?? '', 'disabled_disks' => $disabled, 'missing_disks' => $missing, 'invalid_disks' => $invalid, 'parity' => [ 'running' => number_value($var['mdResync'] ?? 0) > 0, 'action' => $var['mdResyncAction'] ?? '', 'position' => number_value($var['mdResyncPos'] ?? 0), 'size' => number_value($var['mdResyncSize'] ?? 0), 'corrections' => number_value($var['mdResyncCorr'] ?? 0), ], 'alerts' => $alerts, 'disk_count' => count($disks), 'disks' => $disks, ]; } function smart_attributes(string $name): array { $path = '/var/local/emhttp/smart/' . $name; if (!is_readable($path)) return []; $wanted = [5, 9, 187, 188, 197, 198, 199, 202, 231, 241, 242]; $result = []; foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) { if (!preg_match('/^\s*(\d+)\s+([A-Za-z0-9_-]+)\s+.*?\s(-|FAILING_NOW)\s+(.+)$/', $line, $m)) continue; $id = (int)$m[1]; if (!in_array($id, $wanted, true)) continue; $result[] = ['id' => $id, 'name' => $m[2], 'when_failed' => $m[3], 'raw' => clean_text($m[4], 80)]; } return $result; } function disk_health(?string $requested): array { $sections = ini_sections('/var/local/emhttp/disks.ini'); $result = []; foreach ($sections as $section => $disk) { $name = trim((string)($disk['name'] ?? trim($section, '"'))); if ($requested !== null && $requested !== '' && $name !== $requested) continue; $entry = selected_disk($disk); $entry['smart_attributes'] = smart_attributes($name); $entry['healthy'] = number_value($disk['numErrors'] ?? 0) === 0 && !in_array($disk['status'] ?? '', ['DISK_DSBL', 'DISK_NP'], true); $result[] = $entry; } if ($requested && count($result) === 0) fail_status('Unknown disk name'); return ['schema_version' => '1.0', 'disk_count' => count($result), 'disks' => $result]; } function notification_list(int $limit, ?string $importance): array { $limit = max(1, min(50, $limit)); $files = array_merge(glob('/tmp/notifications/*.notify') ?: [], glob('/tmp/notifications/archive/*.notify') ?: []); usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a)); $items = []; foreach ($files as $file) { $n = parse_ini_file($file, false, INI_SCANNER_RAW); if (!is_array($n)) continue; $level = trim((string)($n['importance'] ?? 'normal'), '"'); if ($importance && $importance !== 'all' && $level !== $importance) continue; $items[] = [ 'timestamp' => number_value(trim((string)($n['timestamp'] ?? filemtime($file)), '"')), 'event' => clean_text(trim((string)($n['event'] ?? ''), '"'), 100), 'importance' => $level, 'subject' => clean_text(trim((string)($n['subject'] ?? ''), '"'), 180), 'description' => clean_text(trim((string)($n['description'] ?? ''), '"'), 500), 'archived' => str_contains($file, '/archive/'), ]; if (count($items) >= $limit) break; } return ['schema_version' => '1.0', 'count' => count($items), 'notifications' => $items]; } function share_entry(array $share, bool $details): array { $size = number_value($share['size'] ?? 0); $used = number_value($share['used'] ?? 0); $entry = [ 'name' => $share['name'] ?? '', 'pool' => $share['cachePool'] ?? '', 'secondary_pool' => $share['cachePool2'] ?? '', 'size_bytes' => $size, 'used_bytes' => $used, 'free_bytes' => number_value($share['free'] ?? 0), 'used_percent' => $size > 0 ? round(($used / $size) * 100, 1) : 0, 'exclusive' => ($share['exclusive'] ?? '') === 'yes', 'status_color' => $share['color'] ?? '', ]; if ($details) { $entry += [ 'comment' => clean_text((string)($share['comment'] ?? ''), 300), 'allocation_method' => $share['allocator'] ?? '', 'split_level' => $share['splitLevel'] ?? '', 'minimum_free_space' => number_value($share['floor'] ?? 0), 'included_disks' => $share['include'] ?? '', 'excluded_disks' => $share['exclude'] ?? '', 'cache_mode' => $share['useCache'] ?? '', 'has_config' => ($share['hasCfg'] ?? '') === 'yes', ]; } return $entry; } function shares_list(?string $requested): array { $sections = ini_sections('/var/local/emhttp/shares.ini'); $items = []; foreach ($sections as $section => $share) { $name = (string)($share['name'] ?? trim($section, '"')); if ($requested !== null && $requested !== '' && $name !== $requested) continue; $items[] = share_entry($share, $requested !== null && $requested !== ''); } if ($requested && count($items) === 0) fail_status('Unknown share name'); return ['schema_version' => '1.0', 'share_count' => count($items), 'shares' => $items]; } function docker_update_status(): array { $path = '/var/lib/docker/unraid-update-status.json'; $updates = is_readable($path) ? json_decode((string)file_get_contents($path), true) : []; if (!is_array($updates)) $updates = []; $rows = []; exec('/usr/bin/docker ps -a --format ' . escapeshellarg('{{.Names}}|{{.Image}}'), $rows); $items = []; foreach ($rows as $row) { [$name, $image] = array_pad(explode('|', $row, 2), 2, ''); $key = $image; if (!isset($updates[$key]) && !str_contains($key, '/')) $key = 'library/' . $key; $state = $updates[$key]['status'] ?? null; $items[] = [ 'container' => $name, 'image' => $image, 'status' => $state === 'true' ? 'up-to-date' : ($state === 'false' ? 'update-available' : 'unknown'), ]; } $available = count(array_filter($items, fn($x) => $x['status'] === 'update-available')); return ['schema_version' => '1.0', 'container_count' => count($items), 'updates_available' => $available, 'containers' => $items]; } $action = $argv[1] ?? ''; switch ($action) { case 'system-health': respond(system_health()); break; case 'storage-status': respond(storage_status()); break; case 'disk-health': respond(disk_health($argv[2] ?? null)); break; case 'notifications': respond(notification_list((int)($argv[2] ?? 20), $argv[3] ?? 'all')); break; case 'shares-list': respond(shares_list(null)); break; case 'share-inspect': respond(shares_list($argv[2] ?? '')); break; case 'docker-update-status': respond(docker_update_status()); break; default: fail_status('Unknown read-only status action'); }