diff --git a/README.md b/README.md index 62efeb9..4d8d8ea 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,10 @@ Oder manuell: ```bash # .txz von Gitea laden -curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.22.r015-x86_64-1.txz +curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.23.r016-x86_64-1.txz # Installieren -upgradepkg --install-new mua-2026.08.22.r015-x86_64-1.txz +upgradepkg --install-new mua-2026.08.23.r016-x86_64-1.txz ``` ### 2. Service starten @@ -94,7 +94,7 @@ Der Service startet automatisch bei jedem Boot (SysVinit). ```bash curl http://192.168.1.2:3002/health -# → {"status":"ok","version":"2026.08.22.r015","auth":"required"} +# → {"status":"ok","version":"2026.08.23.r016","auth":"required"} ``` --- diff --git a/dist/mua-2026.08.23.r016-x86_64-1.txz b/dist/mua-2026.08.23.r016-x86_64-1.txz new file mode 100644 index 0000000..57ef71d Binary files /dev/null and b/dist/mua-2026.08.23.r016-x86_64-1.txz differ diff --git a/mcp/helpers.php b/mcp/helpers.php index 646d372..acc29ea 100644 --- a/mcp/helpers.php +++ b/mcp/helpers.php @@ -10,7 +10,7 @@ error_reporting(E_ALL); ini_set('display_errors', 0); // Fehler werden als JSON-RPC-Error zurückgegeben, nicht als HTML -const MUA_VERSION = '2026.08.22.r015'; +const MUA_VERSION = '2026.08.23.r016'; const MUA_SERVER_NAME = 'mua'; /** diff --git a/mcp/server.php b/mcp/server.php index f4a93ac..1ee07f1 100644 --- a/mcp/server.php +++ b/mcp/server.php @@ -21,7 +21,7 @@ const MUA_PORT = 3002; const MUA_BIND = '0.0.0.0'; const MUA_SESSION_TTL = 3600; // Sekunden const MUA_SERVER_NAME = 'mua'; -const MUA_VERSION = '2026.08.22.r015'; +const MUA_VERSION = '2026.08.23.r016'; // ── Session-Management (in-memory) ─────────────────────────────────────── $sessions = []; // session_id => ['created' => time, 'initialized' => bool] diff --git a/package.json b/package.json index ecfb57f..250ac1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mua", - "version": "2026.08.22.r015", + "version": "2026.08.23.r016", "description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid", "type": "module", "main": "src/index.ts", diff --git a/plugin/mua.plg b/plugin/mua.plg index ed0ee1d..f5b5c44 100644 --- a/plugin/mua.plg +++ b/plugin/mua.plg @@ -2,13 +2,13 @@ - + - - + + ]> +### 2026.08.23.r016 +- Docker-Update-Status wird primär über Unraids nativen Containernamen-Schlüssel aufgelöst; Image-, `library/`- und `:latest`-Varianten bleiben als kompatible Fallbacks erhalten. +- Alternative Cacheformate und boolesche oder textuelle Statuswerte werden global normalisiert, statt einzelne Container wie Windows11 und WindowsXP als `unknown` zu verlieren. +- Unvollständige Ergebnisse enthalten `unknown_count`, `status_complete` und kennzeichnen `updates_available` ausdrücklich als Mindestzahl. ### 2026.08.22.r015 - Docker-Liste kann serverseitig nach running/stopped/created/exited filtern und liefert verbindliche Zustandszähler. - Kompakte Antworten ohne Live-Metriken sind nun Standard; CPU/RAM/I/O und Detailfelder werden nur bei Bedarf erzeugt. @@ -115,7 +119,7 @@ Das .txz enthält: install/doinst.sh (läuft nach Installation) =========================================== --> - + &txzURL; &txzSHA256; diff --git a/plugin/plugin.json b/plugin/plugin.json index 8a462d5..d9c0463 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mua", "author": "Michael", - "version": "2026.08.22.r015", + "version": "2026.08.23.r016", "minver": "7.0.0", "pluginDirectory": "/usr/local/emhttp/plugins/mua", "configDirectory": "/boot/config/plugins/mua", diff --git a/scripts/test-docker-update-status.php b/scripts/test-docker-update-status.php new file mode 100644 index 0000000..58f84fc --- /dev/null +++ b/scripts/test-docker-update-status.php @@ -0,0 +1,44 @@ + 'false', + 'WindowsXP' => false, + 'AirConnect' => ['status' => 'true'], +]; +expect_same('native container-name key', 'update-available', docker_update_state($native, 'Windows11', 'dockurr/windows')); +expect_same('boolean false is an update', 'update-available', docker_update_state($native, 'WindowsXP', 'dockurr/windows')); +expect_same('object status is understood', 'up-to-date', docker_update_state($native, 'AirConnect', '1activegeek/airconnect:latest')); + +$legacy = [ + 'library/redis:latest' => ['status' => 'false'], + 'dockurr/windows:latest' => ['updated' => true], +]; +expect_same('Docker Hub library alias', 'update-available', docker_update_state($legacy, 'Redis', 'redis')); +expect_same('implicit latest tag', 'up-to-date', docker_update_state($legacy, 'Windows11', 'dockurr/windows')); + +$list = [ + ['container' => 'Virtual-DSM', 'image' => 'vdsm/virtual-dsm:latest', 'updateAvailable' => true], +]; +expect_same('list format with updateAvailable', 'update-available', docker_update_state($list, 'Virtual-DSM', 'vdsm/virtual-dsm:latest')); + +$summary = docker_update_status_from( + ['Windows11' => 'false', 'AirConnect' => 'true'], + ['Windows11|dockurr/windows', 'AirConnect|1activegeek/airconnect:latest', 'Mystery|example/mystery'], +); +expect_same('known update count', 1, $summary['updates_available']); +expect_same('unknown count', 1, $summary['unknown_count']); +expect_same('incomplete cache is explicit', false, $summary['status_complete']); +expect_same('reported count marked as minimum', true, $summary['updates_available_is_minimum']); + +echo "All Docker update cache tests passed.\n"; diff --git a/scripts/unraid-mcp-status-helper.php b/scripts/unraid-mcp-status-helper.php index 5e4d02c..333c17e 100644 --- a/scripts/unraid-mcp-status-helper.php +++ b/scripts/unraid-mcp-status-helper.php @@ -234,36 +234,129 @@ function shares_list(?string $requested): array { return ['schema_version' => '1.0', 'share_count' => count($items), 'shares' => $items]; } +function docker_update_state_value(mixed $value): ?string { + if (is_array($value)) { + foreach (['update_available', 'updateAvailable'] as $field) { + if (!array_key_exists($field, $value)) continue; + $flag = $value[$field]; + if (is_bool($flag)) return $flag ? 'update-available' : 'up-to-date'; + $flag = strtolower(trim((string)$flag)); + if (in_array($flag, ['true', '1', 'yes'], true)) return 'update-available'; + if (in_array($flag, ['false', '0', 'no'], true)) return 'up-to-date'; + } + foreach (['status', 'updated', 'update_status', 'updateStatus'] as $field) { + if (array_key_exists($field, $value)) return docker_update_state_value($value[$field]); + } + return null; + } + if (is_bool($value)) return $value ? 'up-to-date' : 'update-available'; + if (!is_string($value) && !is_int($value)) return null; + $state = strtolower(trim((string)$value)); + if (in_array($state, ['true', '1', 'up-to-date', 'up_to_date', 'current', 'updated'], true)) { + return 'up-to-date'; + } + if (in_array($state, ['false', '0', 'update-available', 'update_available', 'outdated'], true)) { + return 'update-available'; + } + return null; +} + +function docker_image_aliases(string $image): array { + $image = trim($image); + $without_digest = preg_replace('/@sha256:[a-f0-9]+$/i', '', $image) ?? $image; + $aliases = [$image, $without_digest]; + $last_part = basename($without_digest); + if (!str_contains($last_part, ':')) $aliases[] = $without_digest . ':latest'; + if (!str_contains($without_digest, '/')) { + $aliases[] = 'library/' . $without_digest; + if (!str_contains($last_part, ':')) $aliases[] = 'library/' . $without_digest . ':latest'; + } elseif (str_starts_with($without_digest, 'library/')) { + $aliases[] = substr($without_digest, strlen('library/')); + } + return array_values(array_unique(array_filter($aliases, fn($value) => $value !== ''))); +} + +function docker_update_state(array $updates, string $name, string $image): ?string { + // Unraid's native Docker manager stores the cache by container name. + // Older/plugin-generated caches may instead use an image reference, so + // keep those forms as compatibility fallbacks. + $cache = isset($updates['containers']) && is_array($updates['containers']) + ? $updates['containers'] : $updates; + $keys = array_values(array_unique(array_merge([$name], docker_image_aliases($image)))); + $key_lookup = []; + foreach ($cache as $key => $_value) { + if (is_string($key)) $key_lookup[strtolower($key)] = $key; + } + foreach ($keys as $candidate) { + $actual = $key_lookup[strtolower($candidate)] ?? null; + if ($actual === null) continue; + $state = docker_update_state_value($cache[$actual]); + if ($state !== null) return $state; + } + + // Also understand list/digest keyed formats that carry their identity in + // the entry itself. This prevents a future Unraid cache format change from + // turning every container into an unexplained `unknown` again. + foreach ($cache as $entry) { + if (!is_array($entry)) continue; + $entry_name = (string)($entry['container'] ?? $entry['container_name'] ?? $entry['name'] ?? ''); + $entry_image = (string)($entry['image'] ?? $entry['repository'] ?? ''); + $name_matches = $entry_name !== '' && strcasecmp($entry_name, $name) === 0; + $image_matches = $entry_image !== '' && in_array( + strtolower($entry_image), + array_map('strtolower', docker_image_aliases($image)), + true, + ); + if (!$name_matches && !$image_matches) continue; + $state = docker_update_state_value($entry); + if ($state !== null) return $state; + } + return null; +} + +function docker_update_status_from(array $updates, array $rows): array { + $items = []; + foreach ($rows as $row) { + [$name, $image] = array_pad(explode('|', $row, 2), 2, ''); + $state = docker_update_state($updates, $name, $image); + $items[] = [ + 'container' => $name, + 'image' => $image, + 'status' => $state ?? 'unknown', + ]; + } + $available = count(array_filter($items, fn($x) => $x['status'] === 'update-available')); + $unknown = count(array_filter($items, fn($x) => $x['status'] === 'unknown')); + return [ + 'schema_version' => '1.1', + 'container_count' => count($items), + 'updates_available' => $available, + 'unknown_count' => $unknown, + 'status_complete' => $unknown === 0, + 'updates_available_is_minimum' => $unknown > 0, + 'containers' => $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]; + return docker_update_status_from($updates, $rows); } -$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'); +if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { + $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'); + } } diff --git a/src/helpers.ts b/src/helpers.ts index 1fd13b8..315f581 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -14,7 +14,7 @@ import { createHash, randomUUID } from "node:crypto"; // ── Konstanten ────────────────────────────────────────────────────────── export const MUA_SERVER_NAME = "mua"; -export const MUA_VERSION = "2026.08.22.r015"; +export const MUA_VERSION = "2026.08.23.r016"; export const MUA_PROTOCOL_VERSION = "2025-03-26"; export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php"; export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-helper.php"; diff --git a/src/tools.ts b/src/tools.ts index fcfe78a..370504e 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -188,7 +188,7 @@ export const TOOLS: ToolDef[] = [ { name: "unraid_docker_update_status", description: - "Read Unraid's cached Docker image update state and return a compact per-container summary. Does not pull or update images.", + "Read Unraid's cached Docker image update state and return a compact per-container summary. Does not pull or update images. Treat updates_available as exhaustive only when status_complete is true; otherwise it is a known minimum and every unknown container must be disclosed.", inputSchema: empty, handler: () => runStatusHelper("docker-update-status"), },