Skip to content

Commit 61d8a1f

Browse files
author
FolderView Plus Test
committed
Fix Docker folder member update state sync
1 parent 4c8c823 commit 61d8a1f

8 files changed

Lines changed: 158 additions & 11 deletions

archive/folderview.plus-2026.04.05.07.txz.sha256

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
5d69e28ba26b50cbde7e2a3831f3bf83811dd5ea0cbb4cd56bb8095376d48974 folderview.plus-2026.04.06.18.txz

folderview.plus.plg

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@
66
<!ENTITY launch "Settings/FolderViewPlus">
77
<!ENTITY plugdir "/usr/local/emhttp/plugins/&name;">
88
<!ENTITY pluginURL "https://raw.githubusercontent.com/&github;/dev/folderview.plus.plg">
9-
<!ENTITY version "2026.04.06.17">
10-
<!ENTITY md5 "d860b0e553470296af956d5c42b9b09a">
9+
<!ENTITY version "2026.04.06.18">
10+
<!ENTITY md5 "08c16d91844c385df24ecedb66436006">
1111
]>
1212

1313
<PLUGIN name="&name;" author="&author;" version="&version;" launch="&launch;" pluginURL="&pluginURL;" icon="folder-icon.png" support="https://forums.unraid.net/topic/197631-plugin-folderview-plus/" min="7.0.0">
1414
<CHANGES>
1515

16+
###2026.04.06.18
17+
- Fix: Hidden Docker folder member rows now resync their own update column from per-container runtime state before expand, so a folder with one pending update no longer shows `Apply update` on every container row.
18+
- Fix: Docker runtime refreshes now keep minimized and expanded folder member update buttons aligned with each container's actual update availability.
19+
20+
1621
###2026.04.06.17
1722
- Fix: Docker runtime rows, folder state, and container interactions.
1823
- UX: Folder editor flows, previews, and bootstrap behavior.

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,7 @@ const getDockerPreviewActionsApi = () => {
525525
dockerPreviewActionsApi = dockerPreviewActionsModule.createApi({
526526
window,
527527
$,
528+
escapeHtml: (value) => escapeHtml(value),
528529
getSafeWebuiUrl: (value) => getSafeWebuiUrl(value),
529530
openWebuiInNewTab: (url) => openWebuiInNewTab(url),
530531
openTerminal: (type, containerName, shellValue) => openTerminal(type, containerName, shellValue),
@@ -2789,6 +2790,7 @@ const syncDockerVisibleFoldersFromRuntimeCache = () => {
27892790
? buildRuntimeContainerMapForFolder(id, true)
27902791
: getFolderRuntimeContainers(folder);
27912792
folder.runtimeContainers = runtimeContainers;
2793+
syncDockerFolderMemberRows(id, runtimeContainers);
27922794
if (folderHasChildren(id)) {
27932795
syncParentFolderVisualState(id, folder?.status?.expanded === true);
27942796
} else {
@@ -4013,6 +4015,7 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container
40134015
if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}): Set border-bottom on last .folder-${id}-element.`);
40144016
folder.containers = newFolder;
40154017
if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}): Replaced folder.containers with newFolder:`, JSON.parse(JSON.stringify(newFolder)));
4018+
syncDockerFolderMemberRows(id, newFolder);
40164019

40174020
$(`tr.folder-id-${id} div.folder-storage i[id^="load-"]`).get().forEach((e) => {
40184021
folderobserver.observe(e, folderobserverConfig);
@@ -4210,6 +4213,13 @@ const syncDockerLeafFolderPreviewActions = (id, folder, runtimeContainers) => {
42104213
}
42114214
};
42124215

4216+
const syncDockerFolderMemberRows = (id, runtimeContainers) => {
4217+
const previewActionsApi = getDockerPreviewActionsApi();
4218+
if (previewActionsApi && typeof previewActionsApi.syncDockerFolderMemberRows === 'function') {
4219+
previewActionsApi.syncDockerFolderMemberRows(id, runtimeContainers);
4220+
}
4221+
};
4222+
42134223
const syncParentFolderVisualState = (id, expanded) => {
42144224
const hierarchyApi = getDockerRuntimeHierarchyApi();
42154225
if (hierarchyApi && typeof hierarchyApi.syncParentFolderVisualState === 'function') {

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@
1414
const createApi = (deps = {}) => {
1515
const win = deps.window || fallbackWindow;
1616
const jq = deps.$ || win?.jQuery || win?.$;
17+
const escapeHtml = typeof deps.escapeHtml === 'function'
18+
? deps.escapeHtml
19+
: ((value) => String(value ?? '')
20+
.replace(/&/g, '&amp;')
21+
.replace(/</g, '&lt;')
22+
.replace(/>/g, '&gt;')
23+
.replace(/"/g, '&quot;')
24+
.replace(/'/g, '&#39;'));
1725
const getSafeWebuiUrl = typeof deps.getSafeWebuiUrl === 'function' ? deps.getSafeWebuiUrl : ((value) => String(value || '').trim());
1826
const openWebuiInNewTab = typeof deps.openWebuiInNewTab === 'function' ? deps.openWebuiInNewTab : (() => {});
1927
const openTerminal = typeof deps.openTerminal === 'function' ? deps.openTerminal : (() => {});
@@ -118,6 +126,38 @@
118126
}
119127
};
120128

129+
const i18nLabel = (key, fallback = '') => {
130+
const safeFallback = String(fallback || key || '').trim();
131+
try {
132+
if (typeof jq?.i18n !== 'function') {
133+
return safeFallback;
134+
}
135+
const localized = String(jq.i18n(key) || '').trim();
136+
return localized && localized !== key ? localized : safeFallback;
137+
} catch (_error) {
138+
return safeFallback;
139+
}
140+
};
141+
142+
const escapeInlineJsSingleQuotedValue = (value) => String(value ?? '')
143+
.replace(/\\/g, '\\\\')
144+
.replace(/'/g, "\\'");
145+
146+
const buildDockerMemberUpdateColumnHtml = (entry = {}) => {
147+
const manager = String(entry?.manager || '').trim();
148+
if (manager === 'composeman') {
149+
return `<span class="folder-update-text"><i class="fa fa-docker fa-fw"></i> ${escapeHtml(i18nLabel('compose', 'compose'))}</span>`;
150+
}
151+
if (manager && manager !== 'dockerman') {
152+
return `<span class="folder-update-text"><i class="fa fa-docker fa-fw"></i> ${escapeHtml(i18nLabel('third-party', 'third-party'))}</span>`;
153+
}
154+
const safeContainerName = escapeInlineJsSingleQuotedValue(String(entry?.name || '').trim());
155+
if (entry?.update === true) {
156+
return `<span class="orange-text folder-update-text" style="white-space:nowrap;"><i class="fa fa-flash fa-fw"></i>${escapeHtml(i18nLabel('update-ready', 'update-ready'))}</span><br><a class="exec" onclick="hideAllTips(); updateContainer('${safeContainerName}');"><span style="white-space:nowrap;"><i class="fa fa-cloud-download fa-fw"></i>${escapeHtml(i18nLabel('apply-update', 'apply-update'))}</span></a>`;
157+
}
158+
return `<span class="green-text folder-update-text"><i class="fa fa-check fa-fw"></i>${escapeHtml(i18nLabel('up-to-date', 'up-to-date'))}</span><br><a class="exec" onclick="hideAllTips(); updateContainer('${safeContainerName}');"><span style="white-space:nowrap;"><i class="fa fa-cloud-download fa-fw"></i>${escapeHtml(i18nLabel('force-update', 'force-update'))}</span></a>`;
159+
};
160+
121161
const getDockerPreviewStatusMeta = (entry = {}) => {
122162
const running = entry?.state === true;
123163
const paused = running && entry?.pause === true;
@@ -239,6 +279,30 @@
239279
}
240280
};
241281

282+
const syncDockerStorageRowUpdateColumn = ($row, entry = {}) => {
283+
if (!$row || !$row.length) {
284+
return;
285+
}
286+
const $updateColumn = $row.find('td.updatecolumn').first();
287+
if (!$updateColumn.length) {
288+
return;
289+
}
290+
$updateColumn.html(buildDockerMemberUpdateColumnHtml(entry));
291+
};
292+
293+
const syncDockerFolderMemberRows = (id, runtimeContainers) => {
294+
const entries = Object.values(runtimeContainers || {});
295+
entries.forEach((entry) => {
296+
const containerName = String(entry?.name || '').trim();
297+
if (!containerName) {
298+
return;
299+
}
300+
const $row = findDockerFolderStorageRow(id, containerName);
301+
syncDockerStorageRowStatus($row, entry);
302+
syncDockerStorageRowUpdateColumn($row, entry);
303+
});
304+
};
305+
242306
const resolveDockerPreviewStateTargets = ($target) => {
243307
if (!$target || !$target.length) {
244308
return {
@@ -306,13 +370,7 @@
306370
}
307371
const actionTargets = collectDockerPreviewActionTargets($preview, settings);
308372
const entries = Object.values(runtimeContainers || {});
309-
entries.forEach((entry) => {
310-
const containerName = String(entry?.name || '').trim();
311-
if (!containerName) {
312-
return;
313-
}
314-
syncDockerStorageRowStatus(findDockerFolderStorageRow(id, containerName), entry);
315-
});
373+
syncDockerFolderMemberRows(id, runtimeContainers);
316374
actionTargets.forEach(($target, index) => {
317375
const entry = entries[index];
318376
if (!$target || !$target.length || !entry) {
@@ -335,6 +393,8 @@
335393

336394
return Object.freeze({
337395
appendDockerPreviewActionButtons,
396+
buildDockerMemberUpdateColumnHtml,
397+
syncDockerFolderMemberRows,
338398
syncDockerLeafFolderPreviewActions
339399
});
340400
};

tests/docker-folder-row-quick-actions.test.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,14 @@ test('docker hydration refreshes existing preview actions in place instead of re
5858
assert.match(dockerPreviewActionsScript, /\$compactStatus\.attr\('title', localizedLabel\);/);
5959
assert.match(dockerPreviewActionsScript, /removeClass\('fa-play fa-pause fa-square started paused stopped green-text orange-text red-text fv-preview-status-started fv-preview-status-paused fv-preview-status-stopped'\)/);
6060
assert.match(dockerPreviewActionsScript, /\$stateLabel\.text\(` \$\{localizedLabel\}`\);/);
61+
assert.match(dockerPreviewActionsScript, /const buildDockerMemberUpdateColumnHtml = \(entry = \{\}\) =>/);
62+
assert.match(dockerPreviewActionsScript, /const syncDockerStorageRowUpdateColumn = \(\$row,\s*entry = \{\}\) =>/);
63+
assert.match(dockerPreviewActionsScript, /const syncDockerFolderMemberRows = \(id,\s*runtimeContainers\) => \{[\s\S]*syncDockerStorageRowStatus\(\$row,\s*entry\);[\s\S]*syncDockerStorageRowUpdateColumn\(\$row,\s*entry\);/s);
6164
assert.match(dockerPreviewActionsScript, /const syncDockerLeafFolderPreviewActions = \(id,\s*folder,\s*runtimeContainers\) =>/);
62-
assert.match(dockerPreviewActionsScript, /entries\.forEach\(\(entry\) => \{\s*const containerName = String\(entry\?\.name \|\| ''\)\.trim\(\);[\s\S]*syncDockerStorageRowStatus\(findDockerFolderStorageRow\(id,\s*containerName\),\s*entry\);/s);
65+
assert.match(dockerPreviewActionsScript, /syncDockerFolderMemberRows\(id,\s*runtimeContainers\);/);
6366
assert.match(dockerPreviewActionsScript, /syncDockerPreviewStatus\(\$target,\s*entry\);/);
6467
assert.match(dockerPreviewActionsScript, /\$preview\.find\('\[id\^="folder-preview-"\]'\)\.each\(\(_,\s*node\) => \{\s*jq\(node\)\.data\('fvTooltipLazyBuilt', false\);/s);
68+
assert.match(dockerScript, /const syncDockerFolderMemberRows = \(id,\s*runtimeContainers\) => \{[\s\S]*previewActionsApi\.syncDockerFolderMemberRows\(id,\s*runtimeContainers\);/s);
6569
assert.match(dockerScript, /const syncDockerLeafFolderPreviewActions = \(id,\s*folder,\s*runtimeContainers\) => \{[\s\S]*previewActionsApi\.syncDockerLeafFolderPreviewActions\(id,\s*folder,\s*runtimeContainers\);/s);
6670
assert.match(dockerScript, /syncDockerLeafFolderPreviewActions\(id,\s*folder,\s*runtimeContainers\);/);
6771
assert.match(dockerScript, /const queueDockerDeferredRuntimeInfoHydration = \(generation,\s*stateSignature,\s*fullInfoPromise = null\) => \{[\s\S]*?syncDockerVisibleFoldersFromRuntimeCache\(\);[\s\S]*?\}\)\s*\.catch\(\(\) => \{\}\);/);

tests/docker-update-status-regression.test.mjs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ import test from 'node:test';
22
import assert from 'node:assert/strict';
33
import fs from 'node:fs';
44
import path from 'node:path';
5+
import { createRequire } from 'node:module';
56

67
const repoRoot = path.resolve(process.cwd());
8+
const require = createRequire(import.meta.url);
79
const dockerJs = fs.readFileSync(
810
path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js'),
911
'utf8'
1012
);
13+
const dockerPreviewActionsModule = require(
14+
path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js')
15+
);
1116
const dockerRuntimeInfoJs = fs.readFileSync(
1217
path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.info.js'),
1318
'utf8'
@@ -61,3 +66,66 @@ test('folder update-column renderer is reused across initial and synced folder s
6166
const helperUsages = dockerJs.match(/renderFolderUpdateColumn\(id,\s*(?:\$\(`tr\.folder-id-\$\{id\} > td\.updatecolumn`\)|\$updateColumn),\s*managerTypes,\s*upToDate,\s*managed\);/g) || [];
6267
assert.ok(helperUsages.length >= 2, 'expected shared folder update-column rendering in both initial and sync paths');
6368
});
69+
70+
test('docker runtime builds member row update markup from per-container runtime state', () => {
71+
const previewActionsApi = dockerPreviewActionsModule.createApi({
72+
window: {},
73+
$: Object.assign(() => ({}), {
74+
i18n: (key) => key
75+
}),
76+
escapeHtml: (value) => String(value ?? '')
77+
});
78+
79+
const updateReadyHtml = previewActionsApi.buildDockerMemberUpdateColumnHtml({
80+
name: 'app-one',
81+
manager: 'dockerman',
82+
update: true
83+
});
84+
const upToDateHtml = previewActionsApi.buildDockerMemberUpdateColumnHtml({
85+
name: 'app-two',
86+
manager: 'dockerman',
87+
update: false
88+
});
89+
const composeHtml = previewActionsApi.buildDockerMemberUpdateColumnHtml({
90+
name: 'stack-one',
91+
manager: 'composeman',
92+
update: true
93+
});
94+
const thirdPartyHtml = previewActionsApi.buildDockerMemberUpdateColumnHtml({
95+
name: 'custom-one',
96+
manager: 'plugin-manager',
97+
update: true
98+
});
99+
const escapedQuoteHtml = previewActionsApi.buildDockerMemberUpdateColumnHtml({
100+
name: "quote'app",
101+
manager: 'dockerman',
102+
update: true
103+
});
104+
105+
assert.match(updateReadyHtml, /update-ready/);
106+
assert.match(updateReadyHtml, /apply-update/);
107+
assert.match(updateReadyHtml, /updateContainer\('app-one'\)/);
108+
assert.doesNotMatch(updateReadyHtml, /force-update/);
109+
assert.match(upToDateHtml, /up-to-date/);
110+
assert.match(upToDateHtml, /force-update/);
111+
assert.match(upToDateHtml, /updateContainer\('app-two'\)/);
112+
assert.doesNotMatch(upToDateHtml, /apply-update/);
113+
assert.match(composeHtml, /compose/);
114+
assert.doesNotMatch(composeHtml, /updateContainer\(/);
115+
assert.match(thirdPartyHtml, /third-party/);
116+
assert.doesNotMatch(thirdPartyHtml, /updateContainer\(/);
117+
assert.match(escapedQuoteHtml, /updateContainer\('quote\\'app'\)/);
118+
});
119+
120+
test('docker runtime sync normalizes hidden member rows before expand', () => {
121+
assert.match(dockerPreviewActionsModule.createApi({
122+
window: {},
123+
$: Object.assign(() => ({}), {
124+
i18n: (key) => key
125+
}),
126+
escapeHtml: (value) => String(value ?? '')
127+
}).buildDockerMemberUpdateColumnHtml({ name: 'demo', manager: 'dockerman', update: true }), /apply-update/);
128+
assert.match(dockerJs, /const syncDockerFolderMemberRows = \(id,\s*runtimeContainers\) => \{[\s\S]*previewActionsApi\.syncDockerFolderMemberRows\(id,\s*runtimeContainers\);/s);
129+
assert.match(dockerJs, /folder\.runtimeContainers = runtimeContainers;\s*syncDockerFolderMemberRows\(id,\s*runtimeContainers\);/s);
130+
assert.match(dockerJs, /folder\.containers = newFolder;[\s\S]*syncDockerFolderMemberRows\(id,\s*newFolder\);/s);
131+
});

0 commit comments

Comments
 (0)