Skip to content

Commit 77b42f1

Browse files
Harden Docker order sync flow
1 parent b5d5a87 commit 77b42f1

8 files changed

Lines changed: 181 additions & 18 deletions

File tree

archive/folderview.plus-2026.04.04.04.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+
5c42497b4282fb90ed2510fa0ca33ac4d36d666a7e05ca39f61c61b6ff3e5a88 folderview.plus-2026.04.04.29.txz

folderview.plus.plg

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@
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.04.28">
10-
<!ENTITY md5 "0313bd051b11259e7bac227e4bb4bd25">
9+
<!ENTITY version "2026.04.04.29">
10+
<!ENTITY md5 "f95141f79d3c0b0885a3bf7d47f3b147">
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.04.29
17+
- UX: Folder editor flows, previews, and bootstrap behavior.
18+
- Fix: Server endpoints, runtime payloads, and persistence or validation paths.
19+
- Quality: Release automation, CI smoke coverage, and packaging guards.
20+
21+
1622
###2026.04.04.28
1723
- Fix: Docker runtime rows, folder state, and container interactions.
1824
- Fix: VM runtime rows, folder state, and VM actions.

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

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,10 +1124,13 @@ const queueBackgroundMutationPost = (url, data = {}) => {
11241124
}
11251125
};
11261126

1127-
const flushPostSaveDockerSync = async () => {
1127+
const flushPostSaveDockerSync = async (options = {}) => {
11281128
if (type !== 'docker') {
11291129
return;
11301130
}
1131+
if (!shouldSyncDockerOrderAfterSave(options.folder, options)) {
1132+
return;
1133+
}
11311134
const scheduled = queueBackgroundMutationPost('/plugins/folderview.plus/server/sync_order.php', { type });
11321135
if (scheduled) {
11331136
return;
@@ -4167,6 +4170,44 @@ const buildFolderPayloadFromForm = (e) => {
41674170
};
41684171
};
41694172

4173+
const buildDockerSyncComparableFolder = (folderRecord) => {
4174+
const normalized = normalizeFolderRecordForEditor(folderRecord || {});
4175+
const containers = Array.from(new Set(
4176+
(Array.isArray(normalized.containers) ? normalized.containers : [])
4177+
.map((entry) => String(entry || '').trim())
4178+
.filter(Boolean)
4179+
)).sort();
4180+
return {
4181+
name: String(normalized.name || '').trim(),
4182+
regex: String(normalized.regex || ''),
4183+
containers
4184+
};
4185+
};
4186+
4187+
const shouldSyncDockerOrderAfterSave = (nextFolder, options = {}) => {
4188+
if (type !== 'docker') {
4189+
return false;
4190+
}
4191+
if (options.force === true) {
4192+
return true;
4193+
}
4194+
const currentFolderId = String(options.folderId || activeFolderEditorFolderId || folderId || '').trim();
4195+
if (!currentFolderId) {
4196+
return true;
4197+
}
4198+
const previousFolderRecord = options.previousFolder && typeof options.previousFolder === 'object'
4199+
? options.previousFolder
4200+
: allFoldersById[currentFolderId];
4201+
if (!previousFolderRecord || typeof previousFolderRecord !== 'object') {
4202+
return true;
4203+
}
4204+
const previousComparable = buildDockerSyncComparableFolder(previousFolderRecord);
4205+
const nextComparable = buildDockerSyncComparableFolder(nextFolder || {});
4206+
return previousComparable.name !== nextComparable.name
4207+
|| previousComparable.regex !== nextComparable.regex
4208+
|| JSON.stringify(previousComparable.containers) !== JSON.stringify(nextComparable.containers);
4209+
};
4210+
41704211
const buildFolderSettingsSummaryHtml = (entry) => {
41714212
const transferApi = getFolderSettingsTransferApi();
41724213
const summary = transferApi?.summarizeClipboardEntry(entry) || {
@@ -4341,6 +4382,10 @@ const submitForm = async (e, saveAsCopy = false) => {
43414382
return false;
43424383
}
43434384
const folder = buildFolderPayloadFromForm(e);
4385+
const currentFolderId = String(activeFolderEditorFolderId || folderId || '').trim();
4386+
const previousFolder = !saveAsCopy && currentFolderId && allFoldersById[currentFolderId]
4387+
? normalizeFolderRecordForEditor(allFoldersById[currentFolderId])
4388+
: null;
43444389
if (saveAsCopy) {
43454390
folder.name = generateCopyName(folder.name, folder.parentId);
43464391
}
@@ -4363,7 +4408,12 @@ const submitForm = async (e, saveAsCopy = false) => {
43634408
});
43644409
}
43654410

4366-
await flushPostSaveDockerSync();
4411+
await flushPostSaveDockerSync({
4412+
force: saveAsCopy || !currentFolderId,
4413+
folder,
4414+
folderId: currentFolderId,
4415+
previousFolder
4416+
});
43674417
} catch (error) {
43684418
const message = extractAjaxErrorMessage(error, 'folder save');
43694419
if (typeof swal === 'function') {

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3310,26 +3310,65 @@ function bulkAssignItemsToFolder(string $type, string $folderId, array $items):
33103310
];
33113311
}
33123312

3313-
function syncContainerOrder(string $type): void {
3313+
function dockerSyncOrderLockPath(): string {
33143314
global $configDir;
3315-
fv3_debug_log("syncContainerOrder called for type: $type");
3315+
if (!is_dir($configDir)) {
3316+
@mkdir($configDir, 0770, true);
3317+
}
3318+
return $configDir . '/docker-sync-order.lock';
3319+
}
33163320

3317-
if ($type !== 'docker') { return; }
3321+
function dockerSyncOrderPendingPath(): string {
3322+
global $configDir;
3323+
if (!is_dir($configDir)) {
3324+
@mkdir($configDir, 0770, true);
3325+
}
3326+
return $configDir . '/docker-sync-order.pending';
3327+
}
3328+
3329+
function markDockerSyncOrderPending(): void {
3330+
@file_put_contents(dockerSyncOrderPendingPath(), (string)microtime(true));
3331+
}
3332+
3333+
function clearDockerSyncOrderPending(): void {
3334+
$pendingPath = dockerSyncOrderPendingPath();
3335+
if (file_exists($pendingPath)) {
3336+
@unlink($pendingPath);
3337+
}
3338+
}
3339+
3340+
function hasDockerSyncOrderPending(): bool {
3341+
return file_exists(dockerSyncOrderPendingPath());
3342+
}
3343+
3344+
function syncContainerOrderUnlocked(): void {
3345+
global $configDir;
33183346

33193347
$prefsFile = "/boot/config/plugins/dockerMan/userprefs.cfg";
33203348
if (!file_exists($prefsFile)) { return; }
33213349

3350+
$currentPrefsRaw = @file_get_contents($prefsFile);
33223351
$currentPrefs = @parse_ini_file($prefsFile);
33233352
$currentOrder = $currentPrefs ? array_values($currentPrefs) : [];
33243353

33253354
$foldersFile = "$configDir/docker.json";
33263355
$folders = file_exists($foldersFile) ? (json_decode(file_get_contents($foldersFile), true) ?: []) : [];
33273356

33283357
$dockerClient = new DockerClient();
3329-
$allContainerNames = array_column($dockerClient->getDockerContainers(), 'Name');
3358+
$allContainerNames = [];
3359+
foreach ((array)$dockerClient->getDockerContainers() as $containerMeta) {
3360+
$name = trim((string)($containerMeta['Name'] ?? ''));
3361+
if ($name === '' || in_array($name, $allContainerNames, true)) {
3362+
continue;
3363+
}
3364+
$allContainerNames[] = $name;
3365+
}
33303366
$prefs = readTypePrefs('docker');
33313367
$rules = is_array($prefs['autoRules'] ?? null) ? $prefs['autoRules'] : [];
3332-
$infoByName = readInfo('docker');
3368+
$infoByName = readInfoState('docker');
3369+
if (count($allContainerNames) <= 0) {
3370+
$allContainerNames = array_keys($infoByName);
3371+
}
33333372
$ruleTargetByName = [];
33343373
$labelTargetByName = [];
33353374
foreach ($allContainerNames as $name) {
@@ -3343,11 +3382,11 @@ function syncContainerOrder(string $type): void {
33433382
$folderContainers = [];
33443383
$assignedContainers = [];
33453384
foreach ($folders as $folderId => $folder) {
3346-
$members = $folder['containers'] ?? [];
3385+
$members = normalizeFolderMembers($folder['containers'] ?? []);
33473386
if (!empty($folder['regex'])) {
33483387
$regex = '/' . str_replace('/', '\/', $folder['regex']) . '/';
33493388
foreach ($allContainerNames as $name) {
3350-
if (@preg_match($regex, $name) && !in_array($name, $members)) {
3389+
if (@preg_match($regex, $name) && !in_array($name, $members, true)) {
33513390
$members[] = $name;
33523391
}
33533392
}
@@ -3425,8 +3464,12 @@ function syncContainerOrder(string $type): void {
34253464
foreach ($newOrder as $i => $name) {
34263465
$ini .= ($i + 1) . '="' . $name . '"' . "\n";
34273466
}
3428-
file_put_contents($prefsFile, $ini);
3429-
fv3_debug_log("syncContainerOrder: wrote userprefs.cfg with " . count($newOrder) . " entries");
3467+
if ((string)$currentPrefsRaw !== $ini) {
3468+
file_put_contents($prefsFile, $ini);
3469+
fv3_debug_log("syncContainerOrder: wrote userprefs.cfg with " . count($newOrder) . " entries");
3470+
} else {
3471+
fv3_debug_log("syncContainerOrder: userprefs.cfg already up to date");
3472+
}
34303473

34313474
// Reorder autostart file to match new container order
34323475
$dockerManPaths = @parse_ini_file('/boot/config/plugins/dockerMan/dockerMan.cfg') ?: [];
@@ -3459,8 +3502,52 @@ function syncContainerOrder(string $type): void {
34593502
foreach ($autoStartMap as $line) {
34603503
$newAutoStart[] = $line;
34613504
}
3462-
file_put_contents($autoStartFile, implode("\n", $newAutoStart) . "\n");
3463-
fv3_debug_log("syncContainerOrder: wrote autostart file with " . count($newAutoStart) . " entries");
3505+
$nextAutoStartContent = count($newAutoStart) > 0
3506+
? implode("\n", $newAutoStart) . "\n"
3507+
: '';
3508+
$currentAutoStartContent = @file_get_contents($autoStartFile);
3509+
if ((string)$currentAutoStartContent !== $nextAutoStartContent) {
3510+
file_put_contents($autoStartFile, $nextAutoStartContent);
3511+
fv3_debug_log("syncContainerOrder: wrote autostart file with " . count($newAutoStart) . " entries");
3512+
} else {
3513+
fv3_debug_log("syncContainerOrder: autostart file already up to date");
3514+
}
3515+
}
3516+
}
3517+
3518+
function syncContainerOrder(string $type): void {
3519+
fv3_debug_log("syncContainerOrder called for type: $type");
3520+
3521+
if ($type !== 'docker') { return; }
3522+
3523+
$lockHandle = @fopen(dockerSyncOrderLockPath(), 'c+');
3524+
if (!is_resource($lockHandle)) {
3525+
fv3_debug_log('syncContainerOrder: unable to open lock file, falling back to unlocked run');
3526+
syncContainerOrderUnlocked();
3527+
return;
3528+
}
3529+
3530+
if (!@flock($lockHandle, LOCK_EX | LOCK_NB)) {
3531+
markDockerSyncOrderPending();
3532+
fv3_debug_log('syncContainerOrder: coalesced while another sync is already running');
3533+
@fclose($lockHandle);
3534+
return;
3535+
}
3536+
3537+
try {
3538+
$attempt = 0;
3539+
do {
3540+
$attempt++;
3541+
clearDockerSyncOrderPending();
3542+
$startedAt = microtime(true);
3543+
syncContainerOrderUnlocked();
3544+
$durationMs = (int)round((microtime(true) - $startedAt) * 1000);
3545+
$shouldRerun = hasDockerSyncOrderPending();
3546+
fv3_debug_log("syncContainerOrder: pass $attempt completed in {$durationMs}ms" . ($shouldRerun ? ' (pending rerun requested)' : ''));
3547+
} while ($shouldRerun && $attempt < 3);
3548+
} finally {
3549+
@flock($lockHandle, LOCK_UN);
3550+
@fclose($lockHandle);
34643551
}
34653552
}
34663553

tests/performance-optimizations.test.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,9 +330,12 @@ test('folder editor save queues docker order sync off the submit critical path i
330330
assert.match(folderEditorJs, /const queueBackgroundMutationPost = \(url,\s*data = \{\}\) =>/);
331331
assert.match(folderEditorJs, /navigator\.sendBeacon/);
332332
assert.match(folderEditorJs, /keepalive:\s*true/);
333-
assert.match(folderEditorJs, /const flushPostSaveDockerSync = async \(\) =>/);
333+
assert.match(folderEditorJs, /const shouldSyncDockerOrderAfterSave = \(nextFolder,\s*options = \{\}\) =>/);
334+
assert.match(folderEditorJs, /const flushPostSaveDockerSync = async \(options = \{\}\) =>/);
334335
assert.match(folderEditorJs, /if \(type !== 'docker'\) \{\s*return;\s*\}/);
336+
assert.match(folderEditorJs, /if \(!shouldSyncDockerOrderAfterSave\(options\.folder,\s*options\)\) \{\s*return;\s*\}/);
335337
const modernSubmitBlock = folderEditorJs.match(/const submitForm = async \(e, saveAsCopy = false\) => \{([\s\S]*?)\n\}/)?.[1] || '';
336-
assert.match(modernSubmitBlock, /await flushPostSaveDockerSync\(\);/);
338+
assert.match(modernSubmitBlock, /const currentFolderId = String\(activeFolderEditorFolderId \|\| folderId \|\| ''\)\.trim\(\);/);
339+
assert.match(modernSubmitBlock, /await flushPostSaveDockerSync\(\{[\s\S]*force:\s*saveAsCopy \|\| !currentFolderId,[\s\S]*previousFolder[\s\S]*\}\);/);
337340
assert.doesNotMatch(modernSubmitBlock, /await securePost\('\/plugins\/folderview\.plus\/server\/sync_order\.php'/);
338341
});

tests/server-response-helpers.test.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,23 @@ test('lib.php normalizes compose manager and compose project labels', () => {
119119
);
120120
});
121121

122+
test('lib.php coalesces docker order sync and uses lightweight state snapshots', () => {
123+
assert.match(libPhp, /function dockerSyncOrderLockPath\(\): string/);
124+
assert.match(libPhp, /function dockerSyncOrderPendingPath\(\): string/);
125+
assert.match(libPhp, /function markDockerSyncOrderPending\(\): void/);
126+
assert.match(libPhp, /function clearDockerSyncOrderPending\(\): void/);
127+
assert.match(libPhp, /function hasDockerSyncOrderPending\(\): bool/);
128+
assert.match(libPhp, /function syncContainerOrderUnlocked\(\): void/);
129+
assert.match(libPhp, /\$infoByName = readInfoState\('docker'\);/);
130+
assert.match(libPhp, /@flock\(\$lockHandle, LOCK_EX \| LOCK_NB\)/);
131+
assert.match(libPhp, /markDockerSyncOrderPending\(\);[\s\S]*?return;/);
132+
assert.match(libPhp, /clearDockerSyncOrderPending\(\);[\s\S]*?syncContainerOrderUnlocked\(\);/);
133+
assert.match(libPhp, /while \(\$shouldRerun && \$attempt < 3\)/);
134+
assert.match(libPhp, /\$currentPrefsRaw = @file_get_contents\(\$prefsFile\);/);
135+
assert.match(libPhp, /if \(\(string\)\$currentPrefsRaw !== \$ini\) \{/);
136+
assert.match(libPhp, /if \(\(string\)\$currentAutoStartContent !== \$nextAutoStartContent\) \{/);
137+
});
138+
122139
test('lib.php defines runtime conflict detection and notice helpers', () => {
123140
assert.match(libPhp, /const FVPLUS_RUNTIME_CONFLICT_PLUGINS\s*=\s*\[/);
124141
assert.match(libPhp, /'folder\.view3'\s*=>\s*\[/);

0 commit comments

Comments
 (0)