Skip to content

Commit c7606ee

Browse files
author
FolderView Plus Test
committed
Repair orphaned member diagnostics
1 parent e5e3aef commit c7606ee

6 files changed

Lines changed: 162 additions & 10 deletions

File tree

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.activity-diagnostics.js

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ const DIAGNOSTICS_ACTION_CONFIG = Object.freeze({
5252
icon: 'fa-picture-o',
5353
handler: "repairDiagnostics('repair_missing_custom_icons')"
5454
}),
55+
repair_orphaned_members: Object.freeze({
56+
label: 'Remove orphaned member refs',
57+
icon: 'fa-chain-broken',
58+
handler: "repairDiagnostics('repair_orphaned_members')"
59+
}),
5560
run_theme_self_heal: Object.freeze({
5661
label: 'Theme self-heal now',
5762
icon: 'fa-magic',
@@ -469,7 +474,7 @@ const runDiagnosticAction = async (action, type, privacy = 'sanitized') => {
469474
if (!response.ok) {
470475
throw new Error(response.error || 'Diagnostics action failed.');
471476
}
472-
return response.diagnostics || {};
477+
return response;
473478
};
474479

475480
const trackDiagnosticsEvent = async ({ eventType, type = null, status = 'ok', source = 'ui', details = {} }) => {
@@ -1196,13 +1201,14 @@ const runDiagnostics = async () => {
11961201
}
11971202
};
11981203

1199-
const repairDiagnostics = async (action) => {
1204+
const repairDiagnostics = async (action, type = '') => {
12001205
try {
1201-
const diagnostics = await runDiagnosticAction(action);
1206+
const response = await runDiagnosticAction(action, type);
1207+
const diagnostics = response?.diagnostics || {};
12021208
renderDiagnostics(diagnostics);
12031209
swal({
12041210
title: 'Repair complete',
1205-
text: 'Repair action finished successfully.',
1211+
text: String(response?.message || 'Repair action finished successfully.'),
12061212
type: 'success'
12071213
});
12081214
await Promise.all([refreshType('docker'), refreshType('vm'), refreshBackups('docker'), refreshBackups('vm')]);
@@ -1274,6 +1280,22 @@ const exportFullSupportBundle = () => {
12741280
void exportSupportBundleByMode('full');
12751281
};
12761282

1283+
const formatIssueReportCount = (value, fallback = 0) => {
1284+
const numeric = Number(value);
1285+
return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback;
1286+
};
1287+
1288+
const formatIssueReportBackupDetail = (countValue, lastBackup) => {
1289+
const count = formatIssueReportCount(countValue);
1290+
if (!lastBackup || typeof lastBackup !== 'object') {
1291+
return `count=${count}, latest=none`;
1292+
}
1293+
const name = String(lastBackup.name || '').trim() || 'unknown';
1294+
const reason = String(lastBackup.reason || '').trim() || 'unspecified';
1295+
const createdAt = String(lastBackup.createdAt || '').trim() || 'unknown';
1296+
return `count=${count}, latest=${name}, reason=${reason}, createdAt=${createdAt}`;
1297+
};
1298+
12771299
const issueReportFromDiagnostics = (diagnostics) => {
12781300
const report = normalizeSupportBundleV2Payload(diagnostics || {}, diagnostics?.bundleMeta?.privacyMode || 'sanitized');
12791301
const lines = [];
@@ -1295,11 +1317,22 @@ const issueReportFromDiagnostics = (diagnostics) => {
12951317
for (const type of ['docker', 'vm']) {
12961318
const typeData = report.pluginState?.[type] || {};
12971319
const integrity = report.healthAndHistory?.integrityFindings?.[type] || {};
1298-
const issueCount = Number.isFinite(Number(integrity.issuesCount))
1299-
? Number(integrity.issuesCount)
1300-
: Number(integrity.issueCount || 0);
1320+
const issueCount = formatIssueReportCount(integrity.issuesCount, formatIssueReportCount(integrity.issueCount));
13011321
const counts = typeData.counts || {};
1302-
lines.push(`- ${type.toUpperCase()}: folders=${counts.folders || 0}, rules=${counts.rules || 0}, backups=${counts.backups || 0}, templates=${counts.templates || 0}, issueCount=${issueCount}`);
1322+
const folderMeta = typeData.folders || {};
1323+
const prefs = typeData.prefs || {};
1324+
const orphanedMembers = formatIssueReportCount(integrity.orphanedMembers?.count);
1325+
const assignmentConflicts = formatIssueReportCount(integrity.duplicateAssignments?.effective?.count);
1326+
const invalidRules = formatIssueReportCount(integrity.invalidAutoRules?.count);
1327+
const pathIssues = Array.isArray(integrity.pathHealth?.issues) ? integrity.pathHealth.issues : [];
1328+
const pathIssueCount = pathIssues.length;
1329+
lines.push(`- ${type.toUpperCase()}: folders=${formatIssueReportCount(counts.folders)}, rules=${formatIssueReportCount(counts.rules)}, backups=${formatIssueReportCount(counts.backups)}, templates=${formatIssueReportCount(counts.templates)}, issueCount=${issueCount}`);
1330+
lines.push(` Folder details: file=${folderMeta.path || `${type}.json`}, exists=${folderMeta.exists === false ? 'no' : 'yes'}, manualOrder=${formatIssueReportCount(folderMeta.manualOrderCount, formatIssueReportCount(counts.manualOrder))}, pinned=${formatIssueReportCount(folderMeta.pinnedFolderCount, formatIssueReportCount(counts.pinnedFolders))}`);
1331+
lines.push(` Rules details: sortMode=${prefs.sortMode || 'created'}, settingsMode=${prefs.settingsMode || 'basic'}, rules=${formatIssueReportCount(counts.rules)}, templates=${formatIssueReportCount(counts.templates)}`);
1332+
lines.push(` Backup details: ${formatIssueReportBackupDetail(counts.backups, typeData.lastBackup)}`);
1333+
if (issueCount > 0 || orphanedMembers > 0 || assignmentConflicts > 0 || invalidRules > 0 || pathIssueCount > 0) {
1334+
lines.push(` Integrity details: orphanedMembers=${orphanedMembers}, assignmentConflicts=${assignmentConflicts}, invalidRules=${invalidRules}, pathIssues=${pathIssueCount}`);
1335+
}
13031336
}
13041337
lines.push('');
13051338

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
fvplus_json_try(function (): array {
55
$action = (string)($_REQUEST['action'] ?? 'report');
66
$privacyMode = normalizeDiagnosticsPrivacyMode((string)($_REQUEST['privacy'] ?? FVPLUS_DIAGNOSTICS_DEFAULT_PRIVACY));
7-
$mutatingActions = ['track_event', 'sync_docker_order', 'normalize_prefs', 'repair_paths', 'repair_missing_custom_icons', 'create_backup'];
7+
$mutatingActions = ['track_event', 'sync_docker_order', 'normalize_prefs', 'repair_paths', 'repair_missing_custom_icons', 'repair_orphaned_members', 'create_backup'];
88
if (in_array($action, $mutatingActions, true)) {
99
requireMutationRequestGuard();
1010
}
@@ -89,6 +89,15 @@
8989
];
9090
}
9191

92+
if ($action === 'repair_orphaned_members') {
93+
$repair = fvplusRepairOrphanedMemberReferences();
94+
return [
95+
'message' => 'Orphaned member references removed.',
96+
'repair' => $repair,
97+
'diagnostics' => getDiagnosticsSnapshot($privacyMode)
98+
];
99+
}
100+
92101
if ($action === 'create_backup') {
93102
$type = ensureType((string)($_POST['type'] ?? ''));
94103
$backup = createBackupSnapshot($type, 'manual-diagnostics');

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,16 @@ function diagnosticsBuildRecommendedActions(array $typesData, array $customIcons
16141614
);
16151615
}
16161616

1617+
$orphanedMemberCount = max(0, (int)($dockerIntegrity['orphanedMembers']['count'] ?? 0))
1618+
+ max(0, (int)($vmIntegrity['orphanedMembers']['count'] ?? 0));
1619+
if ($orphanedMemberCount > 0) {
1620+
$addAction(
1621+
'repair_orphaned_members',
1622+
'Remove orphaned member refs',
1623+
'Saved folder members still reference Docker or VM items that no longer exist.'
1624+
);
1625+
}
1626+
16171627
$prefsNeedCleanup = false;
16181628
foreach ([$dockerIntegrity, $vmIntegrity] as $integrity) {
16191629
$prefsNeedCleanup = $prefsNeedCleanup

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3907,6 +3907,88 @@ function fvplusRepairMissingCustomIconReferences(): array {
39073907
];
39083908
}
39093909

3910+
function fvplusRepairOrphanedMemberReferences(): array {
3911+
$repairedFolders = [];
3912+
$repairedMembers = [];
3913+
$typeCounts = ['docker' => 0, 'vm' => 0];
3914+
3915+
foreach (FVPLUS_ALLOWED_TYPES as $type) {
3916+
$folders = readRawFolderMap($type);
3917+
$infoByName = readInfo($type);
3918+
$validNames = array_fill_keys(array_keys(is_array($infoByName) ? $infoByName : []), true);
3919+
$updated = false;
3920+
$typeFolderCount = 0;
3921+
3922+
foreach ($folders as $folderId => &$folder) {
3923+
if (!is_array($folder)) {
3924+
continue;
3925+
}
3926+
$normalizedFolder = normalizeFolderContentPayload($folder);
3927+
$members = normalizeFolderMembers($normalizedFolder['containers'] ?? []);
3928+
if (count($members) <= 0) {
3929+
$folder = $normalizedFolder;
3930+
continue;
3931+
}
3932+
3933+
$orphanedMembers = array_values(array_filter($members, static function ($memberName) use ($validNames): bool {
3934+
return !isset($validNames[(string)$memberName]);
3935+
}));
3936+
if (count($orphanedMembers) <= 0) {
3937+
$folder = $normalizedFolder;
3938+
continue;
3939+
}
3940+
3941+
$normalizedFolder['containers'] = array_values(array_filter($members, static function ($memberName) use ($validNames): bool {
3942+
return isset($validNames[(string)$memberName]);
3943+
}));
3944+
$folder = $normalizedFolder;
3945+
3946+
$repairedFolders[] = [
3947+
'type' => $type,
3948+
'folderId' => (string)$folderId,
3949+
'folderName' => trim((string)($normalizedFolder['name'] ?? (string)$folderId)),
3950+
'removedCount' => count($orphanedMembers),
3951+
'removedMembers' => $orphanedMembers
3952+
];
3953+
foreach ($orphanedMembers as $memberName) {
3954+
$repairedMembers[(string)$memberName] = true;
3955+
}
3956+
$typeCounts[$type] = (int)($typeCounts[$type] ?? 0) + count($orphanedMembers);
3957+
$typeFolderCount++;
3958+
$updated = true;
3959+
}
3960+
unset($folder);
3961+
3962+
if (!$updated) {
3963+
continue;
3964+
}
3965+
3966+
createBackupSnapshot($type, 'before-repair-orphaned-members');
3967+
writeRawFolderMap($type, $folders);
3968+
if ($type === 'docker') {
3969+
syncContainerOrder('docker');
3970+
}
3971+
appendDiagnosticsHistoryEvent(
3972+
'repair_orphaned_members',
3973+
$type,
3974+
[
3975+
'repairedFolderCount' => $typeFolderCount,
3976+
'repairedMemberCount' => (int)($typeCounts[$type] ?? 0)
3977+
],
3978+
'ok',
3979+
'server'
3980+
);
3981+
}
3982+
3983+
return [
3984+
'repairedFolderCount' => count($repairedFolders),
3985+
'repairedMemberCount' => count($repairedMembers),
3986+
'repairedMembers' => array_values(array_keys($repairedMembers)),
3987+
'repairedFolders' => $repairedFolders,
3988+
'typeCounts' => $typeCounts
3989+
];
3990+
}
3991+
39103992
function repairPluginPaths(): array {
39113993
global $configDir;
39123994
$created = [];

tests/server-response-helpers.test.mjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ test('lib.php repairs custom icon directories and can clear missing custom icon
9393
assert.match(libPhp, /'customIconDir'\s*=>\s*\$customIconDir/);
9494
});
9595

96+
test('lib.php can prune orphaned member references from saved folders', () => {
97+
assert.match(libPhp, /function fvplusRepairOrphanedMemberReferences\(\): array/);
98+
assert.match(libPhp, /\$infoByName = readInfo\(\$type\);/);
99+
assert.match(libPhp, /\$normalizedFolder\['containers'\] = array_values\(array_filter\(\$members,/);
100+
assert.match(libPhp, /createBackupSnapshot\(\$type, 'before-repair-orphaned-members'\)/);
101+
assert.match(libPhp, /appendDiagnosticsHistoryEvent\(\s*'repair_orphaned_members'/);
102+
assert.match(libPhp, /'repairedMemberCount'\s*=>\s*count\(\$repairedMembers\)/);
103+
});
104+
96105
test('lib.php normalizes compose manager and compose project labels', () => {
97106
assert.match(libPhp, /function getComposeProjectValueFromLabels\s*\(/);
98107
assert.match(libPhp, /function getNormalizedDockerManagerFromLabels\s*\(/);
@@ -204,6 +213,7 @@ test('lib.php diagnostics include user-facing summary cards and recommended acti
204213
assert.match(libDiagnosticsPhp, /'Update check'/);
205214
assert.match(libDiagnosticsPhp, /'repair_paths',\s*'Repair plugin paths'/);
206215
assert.match(libDiagnosticsPhp, /'repair_missing_custom_icons',\s*'Reset missing custom icon refs'/);
216+
assert.match(libDiagnosticsPhp, /'repair_orphaned_members',\s*'Remove orphaned member refs'/);
207217
assert.match(libDiagnosticsPhp, /'normalize_prefs',\s*'Validate and normalize prefs'/);
208218
assert.match(libDiagnosticsPhp, /'sync_docker_order',\s*'Rebuild Docker order index'/);
209219
});
@@ -260,10 +270,12 @@ test('diagnostics endpoint emits support bundle v2 shape only', () => {
260270
assert.match(libDiagnosticsPhp, /'saltScope'\s*=>\s*\$privacyMode === 'full' \? 'none' : 'per-bundle'/);
261271
assert.match(libDiagnosticsPhp, /'saltHash'\s*=>\s*\$privacyMode === 'full' \? null : \(\$redactor\['saltFingerprint'\] \?\? null\)/);
262272
assert.match(libDiagnosticsPhp, /getDiagnosticsSnapshot\('full'\)/);
263-
assert.match(diagnosticsEndpointPhp, /\$mutatingActions = \['track_event', 'sync_docker_order', 'normalize_prefs', 'repair_paths', 'repair_missing_custom_icons', 'create_backup'\];/);
273+
assert.match(diagnosticsEndpointPhp, /\$mutatingActions = \['track_event', 'sync_docker_order', 'normalize_prefs', 'repair_paths', 'repair_missing_custom_icons', 'repair_orphaned_members', 'create_backup'\];/);
264274
assert.match(diagnosticsEndpointPhp, /if \(\$action === 'repair_missing_custom_icons'\) \{/);
265275
assert.match(diagnosticsEndpointPhp, /'repair'\s*=>\s*\$repair,/);
266276
assert.match(diagnosticsEndpointPhp, /fvplusRepairMissingCustomIconReferences\(\)/);
277+
assert.match(diagnosticsEndpointPhp, /if \(\$action === 'repair_orphaned_members'\) \{/);
278+
assert.match(diagnosticsEndpointPhp, /fvplusRepairOrphanedMemberReferences\(\)/);
267279
assert.doesNotMatch(diagnosticsEndpointPhp, /'bundleType'\s*=>\s*'FolderViewPlusSupportBundle',\s*[\r\n]+\s*'bundleVersion'\s*=>\s*1,/);
268280
assert.doesNotMatch(diagnosticsEndpointPhp, /'diagnostics'\s*=>\s*\$diagnostics/);
269281
});

tests/settings-surface-regression.test.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,17 @@ test('settings diagnostics exports client perf and theme telemetry helpers', ()
118118
assert.match(diagnosticsJs, /report\.pluginState\?\.\[type\]/);
119119
assert.match(diagnosticsJs, /report\.healthAndHistory\?\.recentTimeline/);
120120
assert.match(diagnosticsJs, /report\.uiTelemetry\?\.folderEditorDebug/);
121+
assert.match(diagnosticsJs, /Folder details:/);
122+
assert.match(diagnosticsJs, /Rules details:/);
123+
assert.match(diagnosticsJs, /Backup details:/);
124+
assert.match(diagnosticsJs, /Integrity details:/);
121125
assert.match(diagnosticsJs, /surfaceSummary/);
122126
assert.match(diagnosticsJs, /Bootstrap banner:/);
123127
assert.match(diagnosticsJs, /repair_missing_custom_icons:\s*Object\.freeze\(\{/);
128+
assert.match(diagnosticsJs, /repair_orphaned_members:\s*Object\.freeze\(\{/);
124129
assert.match(diagnosticsJs, /repairMissingIconsAction\.parentAction = 'repair_paths';/);
125130
assert.match(diagnosticsJs, /Theme diagnostics are live before a full health check\./);
131+
assert.match(diagnosticsJs, /return response;/);
126132
assert.match(diagnosticsJs, /runThemeDiagnostics\(\);\s*initializeClientDiagnosticsPanels\(\);/);
127133
assert.match(diagnosticsJs, /runThemeSelfHeal/);
128134
assert.doesNotMatch(diagnosticsJs, /payload\.clientTelemetry = existingClientTelemetry;/);

0 commit comments

Comments
 (0)