diff --git a/compose.manager.plg b/compose.manager.plg index b4e4240..91a8906 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -2,15 +2,15 @@ - + - - + + - + @@ -36,10 +36,98 @@ > -###2026.08.31.1552 +###2026.08.31 +- Features (icon): cache and normalize icons, seed Docker Manager cache (#141) +- Features (icon): add debug logging for icon cache operations (#141) +- Features (icon): bundle resvg for SVG → PNG conversion, add GD to CI (#141) +- Features (tests): add GD extension to PHP setup in workflow +- Features (icon): seed Docker Manager cache during container icon resolution (#141) +- Features (icon): add fetch, convert, and DM-seed helpers (#141) +- Features (icon): add plugin icon cache path helper (#141) +- Features (settings): enhance effective command display with improved styling and structure +- Features (add-stack): inline validation, slug preview, gated Create button +- Features (add-stack): Automatic/Manual radio for Override File Management +- Features (add-stack): shared discovery-mode badge + toggle +- Features (add-stack): Compose Source radio group via shared helper +- Features (editor): promote Compose Sources & Files to its own tab +- Features (editor): inline settings validation blocks Apply until resolved +- Features (editor): live effective docker compose command preview +- Features (editor): status badge + explicit toggle for compose file discovery +- Features (editor): merge External Compose Path & File into Compose Source radio +- Features: enhance error handling and metadata retrieval in compose loading process +- Features: implement client-side control for compose_info publisher and enhance WebSocket management for hidden tabs +- Features: add additional ignored error messages for PHPStan analysis +- Features: implement plugin-local update check for Compose Manager to enhance image update accuracy +- Features (tests): enhance foreground operation tests for Compose Manager with modal interactions and image validation +- Features (tests): add UI operation flow tests for Compose Manager and extract shared helpers +- Features (notification): add composeSendNotification function for stack delete notifications +- Features (e2e): add Playwright end-to-end tests for Compose Manager +- Features (cache): add function to purge deleted stack caches and update UI accordingly +- Features (settings): enhance auto-check updates functionality with mode selection and custom intervals +- Features (settings-page): add functions to count changed settings and update UI accordingly +- Features (settings-page): enhance global actions and settings form handling +- Features (settings-page): enhance defaults with auto/manual verbage +- Features (settings-page): enhance settings tab with descriptive help text and rephrase section titles +- Features (settings-page): reorganize and enhance update checking section +- Features (column-layout): implement shared column layout and visibility helpers - Bug Fixes (codeql): add sanitization for user-entered icon values in live preview -- [PR #139](https://github.com/mstrhakr/compose_plugin/pull/139) -- [beta release diff](https://github.com/mstrhakr/compose_plugin/compare/v2026.07.02...v2026.08.31.1552) +- Bug Fixes (codeql): avoid html sink in labels renderer +- Bug Fixes (build): add environment variables for RESVG version and SHA256 in Docker build +- Bug Fixes (icon): simplify PNG detection logic and enhance test environment setup for Docker Manager RAM icons +- Bug Fixes (icon): enhance PNG handling in icon processing functions and simplify tests by removing GD dependency checks +- Bug Fixes (stack): update orphan removal logic to handle stack mismatches +- Bug Fixes (icon): fix alpha transparency in GD PNG conversion; add global Clear Icon Cache button (#141) +- Bug Fixes (icon): serve icons through local cache proxy instead of remote URLs (#141) +- Bug Fixes (icon): clear plugin icon cache on clearIconCache action (#141) +- Bug Fixes (icon): guard fallback loop for invalid icons (#141) +- Bug Fixes: trim Bearer auth challenge keys and limit explode +- Bug Fixes: allow symlink deletion in composeDeleteStackFolder +- Bug Fixes: enhance WebSocket subscriber management to prevent stale connections and messages +- Bug Fixes: add composeResolveDockerHostIp function to retrieve Docker host IP across Unraid WebUI versions fixes #127 +- Bug Fixes (tests): use PATH_SEPARATOR for COMPOSE_FILE in env file to support Windows development +- Bug Fixes (icons): resolve native Docker tab/Dashboard icons for local paths (#129) +- Bug Fixes: add missing Playwright dependencies installation step in E2E test setup +- Bug Fixes: ensure function definitions are wrapped in function_exists checks to prevent redeclaration errors +- Bug Fixes: remove HTML coverage report generation from PHPUnit configuration +- Bug Fixes: enhance image digest retrieval and add running container check in auto-update script to match manual update path Fixes #132 +- Bug Fixes: ensure new stacks create app-managed project override templates and update tests accordingly Fixes #134 +- Bug Fixes: ensure proper exit code propagation for compose commands and record operation exit status +- Bug Fixes (nchan): optimize docker stats publishing and address shared-memory exhaustion +- Bug Fixes: enhance stack update handling with improved reload queue management +- Bug Fixes (tests): ensure tests remove containers each time +- Bug Fixes (settings): remove unused referrer handling script for cleaner settings page +- Bug Fixes (settings): add debug logging functions for settings changes and state tracking +- Bug Fixes (stack): simplify removal logic in composeDeleteStackFolder function +- Bug Fixes (ui): make stack deletion dynamic without full reload +- Bug Fixes (stack): guard and harden deleteStack folder removal +- Bug Fixes (menu): centralize header menu visibility condition +- Bug Fixes (compose-pull): refactor ComposePullConfirmed function to use options object for parameters +- Bug Fixes (sync-plugin-url): correct escaping in sed command for pluginURL update +- Refactoring (build): reorganize cache path definitions in build scripts +- Refactoring (Exec): remove null coalescing for project name and directory +- Refactoring (pkg_build): enhance checksum handling in download function +- Refactoring (add-stack): section-based layout with editor vocabulary +- Refactoring (editor): split per-stack Advanced section into three subsections +- Refactoring (settings): move REMOVE_ORPHANS_DEFAULT to global settings page +- Refactoring: remove basic output style handling +- Refactoring (settings-page): reorganize settings and update help funcs +- Documentation: clarify registry authentication section in UpdateCheck +- Tests (icon): fix hardcoded cache path expectation to use COMPOSE_ICON_CACHE_DIR constant +- Tests (icon): add IconCacheTest and bootstrap COMPOSE_ICON_CACHE_DIR temp path (#141) +- Chores: update changelog for v2026.08.31.1552 [skip ci] +- Chores: update changelog for v2026.08.31.1513 [skip ci] +- Chores: update changelog for v2026.08.31.1142 [skip ci] +- Chores: update changelog for v2026.08.30.1848 [skip ci] +- Chores: update changelog for v2026.08.30.1823 [skip ci] +- Chores: update changelog for v2026.08.30.1818 [skip ci] +- Chores: update changelog for v2026.08.27.2133 [skip ci] +- Chores: update changelog for v2026.08.27.2113 [skip ci] +- Chores: update COMPOSE_VERSION to 5.5.0 +- Chores: update changelog for v2026.08.23.2252 [skip ci] +- Chores: sync pluginURL+README for dev branch [skip ci] +- Chores (columns): cleanup advanced/basic view mode references and legacy logic +- Refactor IconTest and remove redundant tests; add ComposeListEndpointTest +- [View all changes](https://github.com/mstrhakr/compose_plugin/compare/v2026.07.02...v2026.08.31) diff --git a/source/compose.manager/IconCache.php b/source/compose.manager/IconCache.php index 46b69b7..aaf73c7 100644 --- a/source/compose.manager/IconCache.php +++ b/source/compose.manager/IconCache.php @@ -1,8 +1,9 @@ — fetch/hit cache then output bytes. - * Falls back to 302 redirect for http(s) sources not yet cached. + * ?src= — always answered from the plugin icon cache. + * ?ct= — also repair that container's Docker Manager icon. + * ?refresh=1 — force a re-fetch before serving. */ require_once __DIR__ . '/include/Defines.php'; require_once __DIR__ . '/include/Util.php'; @@ -13,24 +14,33 @@ exit; } -$cached = compose_fetch_icon_to_cache($src); +$containerName = isset($_GET['ct']) ? trim((string) $_GET['ct']) : ''; +if ($containerName !== '' && preg_match('#^[a-zA-Z0-9][a-zA-Z0-9._-]*$#', $containerName) !== 1) { + $containerName = ''; +} -if ($cached !== '' && file_exists($cached)) { - header('Content-Type: image/png'); - header('Cache-Control: public, max-age=86400'); - header('X-Content-Type-Options: nosniff'); - readfile($cached); - exit; +$forceRefresh = isset($_GET['refresh']) && (string) $_GET['refresh'] === '1'; +$cachePath = compose_get_icon_cache_path($src); + +// Populate only when the cache is missing, stale, or holds non-PNG bytes; a +// failed refresh keeps serving the last known-good PNG. +if ($forceRefresh || compose_icon_cache_is_stale($src, $cachePath)) { + if (compose_fetch_icon_to_cache($src, true) === '' && compose_file_is_png($cachePath)) { + composeLogger('Icon refresh failed; serving cached copy', ['source' => $src], 'system', 'debug', 'icon-cache'); + } } -// Cache miss (conversion unsupported or fetch failed) — redirect to original -// Only follow through for http(s); anything else is a 404 -if ( - filter_var($src, FILTER_VALIDATE_URL) !== false - && (strpos($src, 'https://') === 0 || strpos($src, 'http://') === 0) -) { - header('Location: ' . $src, true, 302); +if (!compose_file_is_png($cachePath)) { + header('Location: /plugins/compose.manager/images/question.png', true, 302); exit; } -http_response_code(404); +if ($containerName !== '') { + compose_seed_docker_manager_icon($cachePath, $containerName); +} + +header('Content-Type: image/png'); +header('Cache-Control: public, max-age=3600'); +header('X-Content-Type-Options: nosniff'); +header('Content-Length: ' . (string) filesize($cachePath)); +readfile($cachePath); diff --git a/source/compose.manager/README.md b/source/compose.manager/README.md index 8baaf81..42be301 100644 --- a/source/compose.manager/README.md +++ b/source/compose.manager/README.md @@ -1,3 +1,3 @@ -**Compose Manager Plus (Beta)** +**Compose Manager Plus** A plugin for unRAID that installs Docker Compose and adds a management interface to the web UI. diff --git a/source/compose.manager/compose.manager.dashboard.page b/source/compose.manager/compose.manager.dashboard.page index 2884e85..f847c9d 100644 --- a/source/compose.manager/compose.manager.dashboard.page +++ b/source/compose.manager/compose.manager.dashboard.page @@ -489,7 +489,7 @@ $script .= <<<'EOT' var stateColor = isRunning ? 'green-text' : 'red-text'; var stateText = isRunning ? 'started' : 'stopped'; var ctElId = 'dash-ct-' + ctIdShort; - var imgSrc = composeIconSrc(ctIcon); + var imgSrc = composeIconSrc(ctIcon, ctName); var shell = ctShell; var shortId = ctIdShort; var webui = resolveContainerWebUI(ctWebUI); diff --git a/source/compose.manager/include/Defines.php b/source/compose.manager/include/Defines.php index bd6614a..1498e38 100644 --- a/source/compose.manager/include/Defines.php +++ b/source/compose.manager/include/Defines.php @@ -28,6 +28,10 @@ function locate_compose_root($name) defined('COMPOSE_ICON_NORMALIZE_DIR') || define('COMPOSE_ICON_NORMALIZE_DIR', '/var/tmp/compose-icon-normalize'); defined('COMPOSE_ICON_CACHE_DIR') || define('COMPOSE_ICON_CACHE_DIR', '/boot/config/plugins/compose.manager/icon-cache'); defined('COMPOSE_RESVG_BIN') || define('COMPOSE_RESVG_BIN', '/usr/local/emhttp/plugins/compose.manager/bin/resvg'); +defined('COMPOSE_DM_ICON_RAM_DIR') || define('COMPOSE_DM_ICON_RAM_DIR', '/usr/local/emhttp/state/plugins/dynamix.docker.manager/images'); +defined('COMPOSE_DM_ICON_PERSIST_DIR') || define('COMPOSE_DM_ICON_PERSIST_DIR', '/var/lib/docker/unraid/images'); +defined('COMPOSE_DM_WEBUI_INFO_FILE') || define('COMPOSE_DM_WEBUI_INFO_FILE', '/usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json'); +defined('COMPOSE_DOCKER_LABEL_ICON') || define('COMPOSE_DOCKER_LABEL_ICON', 'net.unraid.docker.icon'); /** * Reserved filename at the compose root level used by the plugin installer diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 9aab28c..202f641 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -1523,6 +1523,13 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $rawContainer['Icon'] = $labels[$docker_label_icon] ?? ''; $rawContainer['Shell'] = $labels[$docker_label_shell] ?? '/bin/bash'; + if ($ctName !== '' && trim((string) $rawContainer['Icon']) !== '') { + compose_seed_docker_manager_icon( + compose_fetch_icon_to_cache((string) $rawContainer['Icon']), + $ctName + ); + } + // Resolve WebUI URL server-side (matching Unraid's DockerClient logic) $networkMode = $inspect['HostConfig']['NetworkMode'] ?? 'bridge'; if (strpos($networkMode, ':') !== false) { diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index b16d9ee..ff91366 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -94,6 +94,53 @@ function compose_get_icon_cache_path(string $source): string } } +if (!function_exists('compose_bytes_are_png')) { + function compose_bytes_are_png(string $bytes): bool + { + return substr($bytes, 0, 8) === "\x89PNG\r\n\x1a\n"; + } +} + +if (!function_exists('compose_file_is_png')) { + function compose_file_is_png(string $path): bool + { + if ($path === '' || !is_file($path)) { + return false; + } + $fh = @fopen($path, 'rb'); + if ($fh === false) { + return false; + } + $head = (string) fread($fh, 8); + fclose($fh); + return compose_bytes_are_png($head); + } +} + +if (!function_exists('compose_icon_cache_is_stale')) { + /** Cached icons revalidate once per TTL window; local sources follow mtime. */ + function compose_icon_cache_is_stale(string $source, string $cachePath, int $ttlSeconds = 86400): bool + { + if (!is_file($cachePath)) { + return true; + } + if (!compose_file_is_png($cachePath)) { + return true; + } + + $cachedAt = (int) @filemtime($cachePath); + if ($cachedAt <= 0) { + return true; + } + + if (strpos($source, '/') === 0 && is_file($source)) { + return (int) @filemtime($source) > $cachedAt; + } + + return (time() - $cachedAt) > $ttlSeconds; + } +} + if (!function_exists('compose_icon_ext_to_mime')) { function compose_icon_ext_to_mime(string $ext): string { @@ -117,9 +164,21 @@ function compose_icon_browser_url(string $src): string if ($src === '') { return ''; } - if (strncasecmp($src, 'http://', 7) === 0 || strncasecmp($src, 'https://', 8) === 0) { + if (str_starts_with($src, '/plugins/compose.manager/IconCache.php?')) { + return $src; + } + if (str_starts_with($src, '/plugins/compose.manager/images/question.png')) { + return $src; + } + + $isRemote = strncasecmp($src, 'http://', 7) === 0 || strncasecmp($src, 'https://', 8) === 0; + $isData = strncasecmp($src, 'data:image/', 11) === 0; + $isCacheableLocal = str_starts_with($src, '/mnt/') || str_starts_with($src, '/boot/config/plugins/compose.manager/'); + + if ($isRemote || $isData || $isCacheableLocal) { return '/plugins/compose.manager/IconCache.php?src=' . urlencode($src); } + return $src; } } @@ -231,7 +290,7 @@ function compose_fetch_icon_to_cache(string $source, bool $forceRefresh = false) $cachePath = compose_get_icon_cache_path($source); - if (!$forceRefresh && file_exists($cachePath)) { + if (!$forceRefresh && compose_file_is_png($cachePath)) { composeLogger('Icon cache hit', ['source' => $source, 'cache' => $cachePath], 'system', 'debug', 'icon-cache'); return $cachePath; } @@ -331,41 +390,172 @@ function compose_fetch_icon_to_cache(string $source, bool $forceRefresh = false) return ''; } - $written = file_put_contents($cachePath, $pngBytes) !== false; - if ($written) { - composeLogger('Icon cached', ['source' => $source, 'cache' => $cachePath, 'bytes' => strlen($pngBytes)], 'system', 'debug', 'icon-cache'); + // Docker Manager renders cache files as PNG by extension, so never + // store non-PNG bytes even if conversion silently passed them through. + if (!compose_bytes_are_png($pngBytes)) { + composeLogger('Icon conversion did not produce PNG; not caching', ['source' => $source, 'mime' => $mimeHint], 'system', 'warning', 'icon-cache'); + return ''; + } + + $written = false; + $tmpPath = @tempnam($cacheDir, 'icon_'); + if ($tmpPath !== false) { + // Publish atomically so a failed refresh never truncates a good icon. + if (file_put_contents($tmpPath, $pngBytes) !== false && @chmod($tmpPath, 0644) && @rename($tmpPath, $cachePath)) { + $written = true; + } else { + @unlink($tmpPath); + } + } + if (!$written) { + return ''; } - return $written ? $cachePath : ''; + + composeLogger('Icon cached', ['source' => $source, 'cache' => $cachePath, 'bytes' => strlen($pngBytes)], 'system', 'debug', 'icon-cache'); + compose_sync_docker_manager_icons_for_source($source, $cachePath); + + return $cachePath; } } if (!function_exists('compose_seed_docker_manager_icon')) { - /** Copy a cached PNG into both Docker Manager icon cache locations. */ + /** + * Mirror a cached PNG into both Docker Manager icon cache locations. + * + * Docker Manager downloads icon label URLs verbatim, so an SVG (or any + * non-PNG) source leaves a corrupt `-icon.png` behind that the + * browser cannot render. Existing files are therefore replaced whenever + * they are not byte-identical PNG copies of our converted cache entry. + */ function compose_seed_docker_manager_icon(string $cachedPngPath, string $containerName): void { - if ($cachedPngPath === '' || !file_exists($cachedPngPath)) { + if ($cachedPngPath === '' || !compose_file_is_png($cachedPngPath)) { return; } if (!preg_match('#^[a-zA-Z0-9][a-zA-Z0-9._-]*$#', $containerName)) { return; } - $destName = $containerName . '-icon.png'; - $targets = [ - '/usr/local/emhttp/state/plugins/dynamix.docker.manager/images/' . $destName, - '/var/lib/docker/unraid/images/' . $destName, - ]; + $iconName = $containerName . '-icon.png'; + $ramPath = COMPOSE_DM_ICON_RAM_DIR . '/' . $iconName; + $targets = [$ramPath, COMPOSE_DM_ICON_PERSIST_DIR . '/' . $iconName]; + + $sourceHash = @md5_file($cachedPngPath); + $seeded = false; foreach ($targets as $dest) { - if (file_exists($dest)) { - continue; // DM already has it; don't overwrite + if (is_file($dest) && compose_file_is_png($dest) && @md5_file($dest) === $sourceHash) { + continue; } + $dir = dirname($dest); - if (!is_dir($dir)) { - @mkdir($dir, 0755, true); + if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { + continue; } - if (@copy($cachedPngPath, $dest)) { + + $tmp = @tempnam($dir, 'dmicon_'); + if ($tmp === false) { + continue; + } + if (@copy($cachedPngPath, $tmp) && @chmod($tmp, 0644) && @rename($tmp, $dest)) { + $seeded = true; composeLogger('Seeded Docker Manager icon cache', ['container' => $containerName, 'dest' => $dest], 'system', 'debug', 'icon-cache'); + } else { + @unlink($tmp); + } + } + + if ($seeded || is_file($ramPath)) { + compose_point_docker_manager_metadata_at_icon($containerName); + } + } +} + +if (!function_exists('compose_point_docker_manager_metadata_at_icon')) { + /** + * Repair Docker Manager's docker.json entry for a container. + * + * Docker Manager only re-resolves an icon when the recorded path is + * missing, so a stored question.png fallback sticks permanently. Pointing + * the entry at the seeded file makes the Docker page use our cached PNG + * instead of re-fetching the label URL. + */ + function compose_point_docker_manager_metadata_at_icon(string $containerName): void + { + $metadataFile = COMPOSE_DM_WEBUI_INFO_FILE; + if (!is_file($metadataFile)) { + return; + } + + $webPath = '/state/plugins/dynamix.docker.manager/images/' . $containerName . '-icon.png'; + + $fh = @fopen($metadataFile, 'r+'); + if ($fh === false) { + return; + } + if (!flock($fh, LOCK_EX)) { + fclose($fh); + return; + } + + $raw = stream_get_contents($fh); + $info = is_string($raw) ? json_decode($raw, true) : null; + + if ( + is_array($info) + && isset($info[$containerName]) + && is_array($info[$containerName]) + && ($info[$containerName]['icon'] ?? '') !== $webPath + ) { + $info[$containerName]['icon'] = $webPath; + $encoded = json_encode($info); + if ($encoded !== false) { + rewind($fh); + ftruncate($fh, 0); + fwrite($fh, $encoded); + fflush($fh); + composeLogger('Repaired Docker Manager icon metadata', ['container' => $containerName, 'icon' => $webPath], 'system', 'debug', 'icon-cache'); + } + } + + flock($fh, LOCK_UN); + fclose($fh); + } +} + +if (!function_exists('compose_sync_docker_manager_icons_for_source')) { + /** + * Seed every container whose icon label matches a freshly cached source. + * + * Keeps the Docker Manager cache aligned with ours even when the icon was + * cached from a page that did not know the owning container names. + */ + function compose_sync_docker_manager_icons_for_source(string $source, string $cachedPngPath): void + { + static $syncedSources = []; + + $source = trim($source); + if ($source === '' || isset($syncedSources[$source]) || !compose_file_is_png($cachedPngPath)) { + return; + } + if (!is_dir(COMPOSE_DM_ICON_RAM_DIR)) { + return; // Docker Manager not present (CI/test environments) + } + $syncedSources[$source] = true; + + $format = '{{.Names}}\t{{index .Config.Labels "' . COMPOSE_DOCKER_LABEL_ICON . '"}}'; + $output = shell_exec('docker ps -a --no-trunc --format ' . escapeshellarg($format) . ' 2>/dev/null'); + if (!is_string($output) || trim($output) === '') { + return; + } + + foreach (preg_split('/\R/', trim($output)) as $line) { + if (strpos($line, "\t") === false) { + continue; + } + [$name, $label] = explode("\t", $line, 2); + if (trim($label) === $source) { + compose_seed_docker_manager_icon($cachedPngPath, trim($name)); } } } diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index e0b0cbd..1fa5357 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -2469,15 +2469,32 @@ function isValidIconSrc(src) { s.indexOf('data:image/') === 0 || s.indexOf('/') === 0; } -/** Route remote http(s) icons through the local cache proxy; passthrough otherwise. */ -function composeIconSrc(src) { +function isCacheEligibleLocalIconPath(src) { + if (!src) return false; + var s = src.trim(); + return s.indexOf('/mnt/') === 0 || s.indexOf('/boot/config/plugins/compose.manager/') === 0; +} + +/** Route cache-eligible icons through the local cache proxy; passthrough otherwise. */ +function composeIconSrc(src, containerName) { if (!src || !isValidIconSrc(src)) { return '/plugins/compose.manager/images/question.png'; } var s = src.trim(); - if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) { - return '/plugins/compose.manager/IconCache.php?src=' + encodeURIComponent(s); + if (s.indexOf('/plugins/compose.manager/IconCache.php?') === 0) { + return s; } + + var cacheable = s.indexOf('http://') === 0 || s.indexOf('https://') === 0 || + s.indexOf('data:image/') === 0 || isCacheEligibleLocalIconPath(s); + if (cacheable) { + var proxied = '/plugins/compose.manager/IconCache.php?src=' + encodeURIComponent(s); + if (containerName && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(containerName)) { + proxied += '&ct=' + encodeURIComponent(containerName); + } + return proxied; + } + return s; } @@ -5307,7 +5324,7 @@ function renderStackActionDialog(action, displayName, path, profile, containers, var localSha = container.localSha || ''; var remoteSha = container.remoteSha || ''; - var iconSrc = composeIconSrc(container.icon); + var iconSrc = composeIconSrc(container.icon, containerName); iconSrc = composeEscapeAttr(iconSrc); // Grey out containers without updates when showing update dialog @@ -8140,7 +8157,7 @@ function renderContainerDetails(stackId, containers, project) { var containerShell = container.shell || '/bin/sh'; html += ''; // Use actual image like Docker tab - either container icon or default question.png - var iconSrc = composeIconSrc(container.icon); + var iconSrc = composeIconSrc(container.icon, containerName); html += ''; html += ''; html += '' + composeEscapeHtml(shortName) + '
'; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 007226d..e6c5ade 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -40,6 +40,9 @@ function composeLogger($message, $data = null, $type = 'daemon', $level = 'info' define('COMPOSE_TTYD_SOCKET_DIR', sys_get_temp_dir()); define('COMPOSE_SKIP_TTYD_EXEC', true); define('COMPOSE_ICON_CACHE_DIR', sys_get_temp_dir() . '/compose_manager_icon_cache_test'); +define('COMPOSE_DM_ICON_RAM_DIR', sys_get_temp_dir() . '/compose_manager_dm_images_ram'); +define('COMPOSE_DM_ICON_PERSIST_DIR', sys_get_temp_dir() . '/compose_manager_dm_images'); +define('COMPOSE_DM_WEBUI_INFO_FILE', sys_get_temp_dir() . '/compose_manager_dm_docker.json'); // Point to the dev-env resvg binary when present; plugin path used on real Unraid define('COMPOSE_RESVG_BIN', is_executable('/tmp/resvg') ? '/tmp/resvg' : '/usr/local/emhttp/plugins/compose.manager/bin/resvg'); diff --git a/tests/unit/StackInfoTest.php b/tests/unit/StackInfoTest.php index 779dd2c..d6b768a 100644 --- a/tests/unit/StackInfoTest.php +++ b/tests/unit/StackInfoTest.php @@ -737,8 +737,8 @@ public function testSeedDockerManagerIconCopiesFile(): void } $name = 'test-compose-icon-seed'; - $ramDir = '/usr/local/emhttp/state/plugins/dynamix.docker.manager/images'; - $ramPath = '/usr/local/emhttp/state/plugins/dynamix.docker.manager/images/' . $name . '-icon.png'; + $ramDir = COMPOSE_DM_ICON_RAM_DIR; + $ramPath = $ramDir . '/' . $name . '-icon.png'; if (!is_dir($ramDir) && !@mkdir($ramDir, 0755, true)) { $this->markTestSkipped('Docker Manager RAM icon dir is not creatable in this environment'); @@ -752,7 +752,70 @@ public function testSeedDockerManagerIconCopiesFile(): void compose_seed_docker_manager_icon($cached, $name); $this->assertFileExists($ramPath, 'seeding must copy PNG into Docker Manager RAM cache'); - @unlink($ramPath); + $this->removeSeededDockerManagerIcons($name); + } + + public function testSeedDockerManagerIconReplacesNonPngFile(): void + { + $source = 'data:image/png;base64,' . base64_encode($this->minimalPngBytes()); + $cached = compose_fetch_icon_to_cache($source); + + if ($cached === '') { + $this->markTestSkipped('Cache write failed; cannot test DM seeding'); + } + + $name = 'test-compose-icon-repair'; + $ramDir = COMPOSE_DM_ICON_RAM_DIR; + $ramPath = $ramDir . '/' . $name . '-icon.png'; + + if (!is_dir($ramDir) && !@mkdir($ramDir, 0755, true)) { + $this->markTestSkipped('Docker Manager RAM icon dir is not creatable in this environment'); + } + + // Mimic Docker Manager storing raw SVG bytes under a .png filename. + file_put_contents($ramPath, ''); + + compose_seed_docker_manager_icon($cached, $name); + + $this->assertTrue(compose_file_is_png($ramPath), 'corrupt Docker Manager icon must be replaced with real PNG'); + $this->removeSeededDockerManagerIcons($name); + } + + public function testSeedDockerManagerIconRepairsQuestionIconMetadata(): void + { + $source = 'data:image/png;base64,' . base64_encode($this->minimalPngBytes()); + $cached = compose_fetch_icon_to_cache($source); + + if ($cached === '') { + $this->markTestSkipped('Cache write failed; cannot test DM seeding'); + } + + $name = 'test-compose-icon-metadata'; + if (!is_dir(COMPOSE_DM_ICON_RAM_DIR) && !@mkdir(COMPOSE_DM_ICON_RAM_DIR, 0755, true)) { + $this->markTestSkipped('Docker Manager RAM icon dir is not creatable in this environment'); + } + + file_put_contents(COMPOSE_DM_WEBUI_INFO_FILE, json_encode([ + $name => ['icon' => '/plugins/dynamix.docker.manager/images/question.png'], + ])); + + compose_seed_docker_manager_icon($cached, $name); + + $info = json_decode((string) file_get_contents(COMPOSE_DM_WEBUI_INFO_FILE), true); + $this->assertSame( + '/state/plugins/dynamix.docker.manager/images/' . $name . '-icon.png', + $info[$name]['icon'] ?? '', + 'docker.json must stop pointing at question.png once a cached icon exists' + ); + + @unlink(COMPOSE_DM_WEBUI_INFO_FILE); + $this->removeSeededDockerManagerIcons($name); + } + + private function removeSeededDockerManagerIcons(string $containerName): void + { + @unlink(COMPOSE_DM_ICON_RAM_DIR . '/' . $containerName . '-icon.png'); + @unlink(COMPOSE_DM_ICON_PERSIST_DIR . '/' . $containerName . '-icon.png'); } public function testGetWebUIUrl(): void