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