diff --git a/archive/folderview.plus-2026.08.11.06.txz b/archive/folderview.plus-2026.08.11.06.txz deleted file mode 100644 index 448ffb46..00000000 Binary files a/archive/folderview.plus-2026.08.11.06.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.11.06.txz.sha256 b/archive/folderview.plus-2026.08.11.06.txz.sha256 deleted file mode 100644 index f59a9967..00000000 --- a/archive/folderview.plus-2026.08.11.06.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -70912fe57290f0cdcb04d4c0b4f2273fcceaf9c88411c31f57415c8213c441fb folderview.plus-2026.08.11.06.txz diff --git a/archive/folderview.plus-2026.08.11.07.txz b/archive/folderview.plus-2026.08.11.07.txz deleted file mode 100644 index 7ca878d3..00000000 Binary files a/archive/folderview.plus-2026.08.11.07.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.11.07.txz.sha256 b/archive/folderview.plus-2026.08.11.07.txz.sha256 deleted file mode 100644 index ee7f9d2a..00000000 --- a/archive/folderview.plus-2026.08.11.07.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -49b1d47c296fc0b19d03aea1ea83ac73906d5f393adb8fc610f9ce69d25ef9e9 folderview.plus-2026.08.11.07.txz diff --git a/archive/folderview.plus-2026.08.11.08.txz b/archive/folderview.plus-2026.08.11.08.txz deleted file mode 100644 index ee42e458..00000000 Binary files a/archive/folderview.plus-2026.08.11.08.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.11.08.txz.sha256 b/archive/folderview.plus-2026.08.11.08.txz.sha256 deleted file mode 100644 index fa9225aa..00000000 --- a/archive/folderview.plus-2026.08.11.08.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -3a916739e205eb0dddaaa2cd922067dd86d25ce30b92d324cf3256a5e065293e folderview.plus-2026.08.11.08.txz diff --git a/archive/folderview.plus-2026.08.11.09.txz b/archive/folderview.plus-2026.08.11.09.txz deleted file mode 100644 index 6706996c..00000000 Binary files a/archive/folderview.plus-2026.08.11.09.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.11.09.txz.sha256 b/archive/folderview.plus-2026.08.11.09.txz.sha256 deleted file mode 100644 index 500fefe7..00000000 --- a/archive/folderview.plus-2026.08.11.09.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -2feb6a018603987a524e23c7f5a1cd06ca8e2a792e01e62aa3fd5fe3a2d4bf4d folderview.plus-2026.08.11.09.txz diff --git a/archive/folderview.plus-2026.08.11.10.txz b/archive/folderview.plus-2026.08.11.10.txz deleted file mode 100644 index 54f38c68..00000000 Binary files a/archive/folderview.plus-2026.08.11.10.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.11.10.txz.sha256 b/archive/folderview.plus-2026.08.11.10.txz.sha256 deleted file mode 100644 index 7a8fcd73..00000000 --- a/archive/folderview.plus-2026.08.11.10.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -b5c53c38169731577390df05c1a2b5e3c1df4cfccb0a8e7d6be9ec2e064d3c7d folderview.plus-2026.08.11.10.txz diff --git a/archive/folderview.plus-2026.08.11.11.txz b/archive/folderview.plus-2026.08.11.11.txz deleted file mode 100644 index 10467b6e..00000000 Binary files a/archive/folderview.plus-2026.08.11.11.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.11.11.txz.sha256 b/archive/folderview.plus-2026.08.11.11.txz.sha256 deleted file mode 100644 index 627bcdab..00000000 --- a/archive/folderview.plus-2026.08.11.11.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -5a96ae0d10f6402d1139202e95757c7f7dccd57683f162fecd72c08cc16f81b4 folderview.plus-2026.08.11.11.txz diff --git a/archive/folderview.plus-2026.08.18.08.txz b/archive/folderview.plus-2026.08.18.08.txz deleted file mode 100644 index bd4457f9..00000000 Binary files a/archive/folderview.plus-2026.08.18.08.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.18.08.txz.sha256 b/archive/folderview.plus-2026.08.18.08.txz.sha256 deleted file mode 100644 index 9d2bf3dc..00000000 --- a/archive/folderview.plus-2026.08.18.08.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -2708d72a7e3cef66d5511bfb37252bfd1621c82e579e5d94368c5ffcc3335ca9 folderview.plus-2026.08.18.08.txz diff --git a/archive/folderview.plus-2026.08.19.01.txz b/archive/folderview.plus-2026.08.19.01.txz new file mode 100644 index 00000000..b92a038c Binary files /dev/null and b/archive/folderview.plus-2026.08.19.01.txz differ diff --git a/archive/folderview.plus-2026.08.19.01.txz.sha256 b/archive/folderview.plus-2026.08.19.01.txz.sha256 new file mode 100644 index 00000000..ae2c66a0 --- /dev/null +++ b/archive/folderview.plus-2026.08.19.01.txz.sha256 @@ -0,0 +1 @@ +52fa0f1f1a52ec0e10e2c92d62b72cb26c3869a5fd971aaa43ce841b8b6f48ca folderview.plus-2026.08.19.01.txz diff --git a/archive/folderview.plus-2026.08.19.02.txz b/archive/folderview.plus-2026.08.19.02.txz new file mode 100644 index 00000000..c2cd7cad Binary files /dev/null and b/archive/folderview.plus-2026.08.19.02.txz differ diff --git a/archive/folderview.plus-2026.08.19.02.txz.sha256 b/archive/folderview.plus-2026.08.19.02.txz.sha256 new file mode 100644 index 00000000..592e466a --- /dev/null +++ b/archive/folderview.plus-2026.08.19.02.txz.sha256 @@ -0,0 +1 @@ +530c6a7cfe49db7945aa1b7f368ab8c3f7348a3a436c0d9250eee1d92c78b0a0 folderview.plus-2026.08.19.02.txz diff --git a/archive/folderview.plus-2026.08.19.03.txz b/archive/folderview.plus-2026.08.19.03.txz new file mode 100644 index 00000000..56a1008c Binary files /dev/null and b/archive/folderview.plus-2026.08.19.03.txz differ diff --git a/archive/folderview.plus-2026.08.19.03.txz.sha256 b/archive/folderview.plus-2026.08.19.03.txz.sha256 new file mode 100644 index 00000000..59361cf2 --- /dev/null +++ b/archive/folderview.plus-2026.08.19.03.txz.sha256 @@ -0,0 +1 @@ +75541901c6ba2c09dc486ccdb8a3c68aa69391490875be0acae283d9e0c51784 folderview.plus-2026.08.19.03.txz diff --git a/archive/folderview.plus-2026.08.19.04.txz b/archive/folderview.plus-2026.08.19.04.txz new file mode 100644 index 00000000..3ce306ca Binary files /dev/null and b/archive/folderview.plus-2026.08.19.04.txz differ diff --git a/archive/folderview.plus-2026.08.19.04.txz.sha256 b/archive/folderview.plus-2026.08.19.04.txz.sha256 new file mode 100644 index 00000000..ce40bc66 --- /dev/null +++ b/archive/folderview.plus-2026.08.19.04.txz.sha256 @@ -0,0 +1 @@ +80f3d25e4f5e2ff3328bcd71b5998ec5f320c09f7083f59ff8f619351ab5cfd7 folderview.plus-2026.08.19.04.txz diff --git a/archive/folderview.plus-2026.08.19.05.txz b/archive/folderview.plus-2026.08.19.05.txz new file mode 100644 index 00000000..038e533a Binary files /dev/null and b/archive/folderview.plus-2026.08.19.05.txz differ diff --git a/archive/folderview.plus-2026.08.19.05.txz.sha256 b/archive/folderview.plus-2026.08.19.05.txz.sha256 new file mode 100644 index 00000000..015b426c --- /dev/null +++ b/archive/folderview.plus-2026.08.19.05.txz.sha256 @@ -0,0 +1 @@ +dfd2cf4f4d27815b68ec1950b5e2e5bb5dcfabd4f439c67a97a4e83fd3011f49 folderview.plus-2026.08.19.05.txz diff --git a/archive/folderview.plus-2026.08.19.06.txz b/archive/folderview.plus-2026.08.19.06.txz new file mode 100644 index 00000000..c057a82e Binary files /dev/null and b/archive/folderview.plus-2026.08.19.06.txz differ diff --git a/archive/folderview.plus-2026.08.19.06.txz.sha256 b/archive/folderview.plus-2026.08.19.06.txz.sha256 new file mode 100644 index 00000000..66d94197 --- /dev/null +++ b/archive/folderview.plus-2026.08.19.06.txz.sha256 @@ -0,0 +1 @@ +94d29a56f17577d10242da0fe898eb0cb52a1d9b265ae155532a16d3457b1a59 folderview.plus-2026.08.19.06.txz diff --git a/archive/folderview.plus-2026.08.19.07.txz b/archive/folderview.plus-2026.08.19.07.txz new file mode 100644 index 00000000..245e4e37 Binary files /dev/null and b/archive/folderview.plus-2026.08.19.07.txz differ diff --git a/archive/folderview.plus-2026.08.19.07.txz.sha256 b/archive/folderview.plus-2026.08.19.07.txz.sha256 new file mode 100644 index 00000000..02832086 --- /dev/null +++ b/archive/folderview.plus-2026.08.19.07.txz.sha256 @@ -0,0 +1 @@ +f64322b8fda9127b3c192fea5c16d375e6965c4cec7481818daf5517dfdeab1f folderview.plus-2026.08.19.07.txz diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 4ca94807..d8906b45 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -96,6 +96,10 @@ If rows keep refreshing indefinitely: 4. Export a sanitized support bundle. 5. Refresh once to stop the stale browser session and include the approximate duration in the report. +Docker diagnostics retain the current session and the five most recent completed sessions. Each summary records only timestamps, duration, exit reason, reload/render/request counts, reload cadence, native-busy passes, and whether folders returned. Full reloads are assigned one privacy-safe source category: initial bootstrap, a manual plugin refresh, a plugin configuration revision, plugin action follow-up, Unraid's native busy poll, a host dialog callback, or an unknown host caller. + +The automatic `FVPLUS-DKR-REFRESH-001` verdict is `suspected` after at least three unattended full reloads within 60 seconds and `confirmed` after at least six within 90 seconds. Manual refreshes and host-dialog callbacks do not count toward that verdict. A native busy loop is reported separately with its first/last observation, pass count, duration, cleared state, and folder-restoration outcome. + ### Host List Or View Switching Looks Wrong Host list should restore one copy of every native Unraid Docker row. FolderView should then rebuild one copy of every saved folder row. If rows duplicate, disappear, or a selection appears to do nothing: @@ -142,7 +146,9 @@ Open `Settings -> FolderView Plus -> Advanced -> Diagnostics`, then review the s Use the sanitized export by default. It omits or hashes names, paths, URLs, IPs, and user-agent values and records what was redacted in the v2 `redactionManifest`. -The v2 bundle also includes exact build/package identity, loaded plugin script/style URLs and version queries, recent plugin actions, a bounded FolderView Plus API error-log tail, and browser-side JS error snapshots. +The v2 bundle also includes exact build/package identity, loaded plugin script/style URLs and version queries, recent plugin actions, a bounded FolderView Plus API error-log tail, and browser-side JS error snapshots. Browser errors keep full details for the current session and the retained last-30-day window; older retained entries are reduced to aggregate counts by category. + +Docker support evidence includes recent session summaries, reload-source counts, the refresh-loop verdict, native busy-cycle recovery, and aggregate API identity mismatches. API mismatches record counts and first/last timestamps only. They never include container names or IDs, and they state the `native-structure-authoritative` policy and that an API mismatch did not request a host reload. To compare two systems without exposing their identities, export sanitized bundles from both and run: @@ -150,7 +156,7 @@ To compare two systems without exposing their identities, export sanitized bundl npm run compare:support-bundles -- first-bundle.json second-bundle.json ``` -The comparator refuses full bundles and reports only allowlisted versions, counts, health/status, rendering geometry, modes, and other bounded troubleshooting fields. +The comparator refuses full bundles and reports only allowlisted versions, counts, health/status, rendering geometry, modes, and other bounded troubleshooting fields. It compares Docker reload sources, session counts, busy passes, API mismatch counts, refresh verdicts, DOM mutation records, and transferred resource bytes so a recurrence or performance regression is visible without exposing workload identities. It also includes bounded Dashboard visual-layout snapshots. Its troubleshooting-domain summary keeps layout/rendering evidence separate from configuration, runtime/request, storage, icon, theme, localization, and update findings. diff --git a/docs/docker-runtime-architecture.md b/docs/docker-runtime-architecture.md index afa801fc..778b1a4f 100644 --- a/docs/docker-runtime-architecture.md +++ b/docs/docker-runtime-architecture.md @@ -16,7 +16,7 @@ This document tracks the staged modularization of `docker.js` while preserving U - Owns dynamic capability-path checks, unavailable/legacy snapshots, provider accessors, and privacy-safe compatibility evidence mapping. - `scripts/docker.runtime.api-coordinator.js` - Owns API-first list and targeted reconciliation for the legacy Docker renderer. - - Merges only schema-confirmed runtime fields into the PHP-authoritative identity/metadata map, serializes requests, rejects stale work, schedules structural host refreshes, and applies permanent/transient failure policy. + - Merges only schema-confirmed runtime fields into the PHP-authoritative identity/metadata map, serializes requests, rejects stale work, schedules structural host refreshes only for verified configuration revisions, and applies permanent/transient failure policy. - `scripts/docker.runtime.container-model.js` - Normalizes GraphQL, PHP runtime, and DOM fallback records into one immutable container contract. - `scripts/docker.runtime.provider-health.js` @@ -27,6 +27,10 @@ This document tracks the staged modularization of `docker.js` while preserving U - `scripts/docker.runtime.column-controller.js` - Owns Docker app-column measurement, cached-width bootstrap, gap enforcement, reflow scheduling, theme/viewport/font hooks, resizer observation, and teardown. - Receives runtime state and host dependencies from `docker.js`; it does not own Docker rows, preferences, or render orchestration. +- `scripts/docker.runtime.refresh-diagnostics.js` + - Owns bounded, privacy-safe Docker session summaries, reload-source attribution, native busy-cycle evidence, API mismatch aggregates, and the automatic refresh-loop verdict. + - Counts completed renders by monotonic render generation, while `docker.runtime.diagnostics.js` remains the single owner of deferred page-snapshot scheduling. + - Retains five completed sessions and never stores container names, identifiers, API payloads, URLs, call stacks, or host process details. - `scripts/runtime.folder-ordering.js` - Owns deterministic folder depth, saved-order slot replacement, and Docker order reconciliation shared by Docker, VM, and Dashboard. - Preserves live folder-slot order for the default created mode while applying explicit sort and pin preferences consistently. diff --git a/docs/releases/2026.08.19.01.md b/docs/releases/2026.08.19.01.md new file mode 100644 index 00000000..55cb6a52 --- /dev/null +++ b/docs/releases/2026.08.19.01.md @@ -0,0 +1,5 @@ +- Fix: Stop API container identity differences from recursively scheduling Unraid Docker list reloads after every FolderView render. +- Reliability: Keep Unraid's native Docker/PHP configuration revision path authoritative for structural changes while using GraphQL only to enrich containers already present in the host runtime map. +- Reliability: Detect Unraid's visible Docker busy-poll cycle and temporarily preserve its native rows instead of rebuilding every folder and preview on each host refresh. +- Performance: Avoid repeated full runtime request bundles, folder grouping, preview hydration, and DOM replacement during unresolved API identity differences or native Docker service polling. +- Test: Add coordinator, host busy-indicator, and double-reload regression coverage, then validate the fix through the complete lint/test suite, real-browser fixtures, and large-runtime performance budgets. diff --git a/docs/releases/2026.08.19.02.md b/docs/releases/2026.08.19.02.md new file mode 100644 index 00000000..cd46752a --- /dev/null +++ b/docs/releases/2026.08.19.02.md @@ -0,0 +1,6 @@ +- Diagnostics: Retain up to five bounded Docker-page session summaries with start/end time, duration, exit reason, load/render/request counts, reload cadence, native busy-cycle recovery, and restored-folder evidence. +- Diagnostics: Classify full Docker reloads by fixed privacy-safe sources and derive `healthy`, `suspected`, or `confirmed` refresh-loop verdicts with reference `FVPLUS-DKR-REFRESH-001`. +- Reliability: Record API identity differences as aggregate counts only and keep Unraid's native configuration revision authoritative without requesting a host reload from the mismatch path. +- Diagnostics: Preserve detailed browser-error evidence for the current session and previous 30 days while reducing older retained errors to category counts. +- Maintenance: Extend sanitized support-bundle comparison to highlight refresh-loop, busy-cycle, API-mismatch, mutation, and transfer regressions. +- Privacy: Keep Docker refresh diagnostics bounded and free of container names, IDs, raw API payloads, GraphQL queries, URLs, tokens, cookies, process lists, syslogs, and call stacks. diff --git a/docs/releases/2026.08.19.03.md b/docs/releases/2026.08.19.03.md new file mode 100644 index 00000000..18b70c10 --- /dev/null +++ b/docs/releases/2026.08.19.03.md @@ -0,0 +1,5 @@ +- Diagnostics: Count completed Docker renders from the monotonic render generation even when a later runtime-sync snapshot supplies the stored page evidence. +- Diagnostics: Advance the trace-health top-level timestamp on every trace or page-snapshot write while preserving the other trace sections. +- Performance: Remove the duplicate Docker page-snapshot timer and keep deferred snapshot scheduling owned by the diagnostics module. +- Test: Add regression coverage for runtime-sync render attribution, duplicate-generation suppression, timestamp freshness, and single-owner snapshot scheduling. +- Quality: Complete the full release, workflow, browser, theme, performance, dependency, Git-integrity, and GitHub security audit before the main merge. diff --git a/docs/releases/2026.08.19.04.md b/docs/releases/2026.08.19.04.md new file mode 100644 index 00000000..e8cbea3d --- /dev/null +++ b/docs/releases/2026.08.19.04.md @@ -0,0 +1,8 @@ +- Fix: Stop API container identity differences from recursively scheduling Unraid Docker list reloads and eliminate the resulting 5-7 second Docker-page refresh loop. +- Fix: Stop blank or unreachable container icon URLs from generating repeated requests by using the local fallback immediately and remembering failed sources across later renders for the current page session. +- Reliability: Keep Unraid's native Docker/PHP configuration revision authoritative for structural changes, enrich only matching containers through API-first reads, and preserve native rows while Docker is busy. +- Performance: Replace broad action-followup redraws with incremental runtime reconciliation and remove duplicate deferred support-snapshot scheduling. +- Diagnostics: Add five-session Docker refresh history, privacy-safe reload-source attribution, busy-cycle recovery, API-mismatch aggregates, browser-error aging, and the `FVPLUS-DKR-REFRESH-001` loop verdict. +- Diagnostics: Count completed renders by monotonic generation and keep trace-health timestamps current so exported support bundles accurately describe the observed session. +- Privacy: Keep refresh and API evidence bounded and free of container identities, raw API payloads, GraphQL queries, URLs, tokens, cookies, call stacks, process lists, and syslogs. +- Test: Validate the stable candidate through complete lint, static analysis, unit, workflow, release, install, deterministic-build, browser, theme, responsive, performance, dependency, Git-integrity, and GitHub security checks. diff --git a/docs/releases/2026.08.19.05.md b/docs/releases/2026.08.19.05.md new file mode 100644 index 00000000..1a16106f --- /dev/null +++ b/docs/releases/2026.08.19.05.md @@ -0,0 +1,4 @@ +- Fix: Restore Docker folder boxes for folders created through the Add Folder wizard when Unraid renders container rows with short container IDs instead of container names. +- Reliability: Resolve Docker row identities through the normalized runtime container model and preserve deterministic first-folder ownership when rules overlap. +- Diagnostics: Add privacy-safe aggregate folder-grouping evidence for host-row resolution, claimed members, missing members, rendered folders, and hide-empty removals. +- Test: Reproduce the reported 25-folder and 18-container layout with manual, regex, label, and advanced-rule membership in unit and browser fixtures. diff --git a/docs/releases/2026.08.19.06.md b/docs/releases/2026.08.19.06.md new file mode 100644 index 00000000..1f4c8190 --- /dev/null +++ b/docs/releases/2026.08.19.06.md @@ -0,0 +1,4 @@ +- Fix: Keep populated Docker folders visible when Hide empty folders removes earlier empty rows and later folders have high saved-order positions. +- Reliability: Insert folder shells relative to connected rows in the reconciled host order and leave member rows under Unraid ownership if a shell cannot be attached. +- Diagnostics: Report aggregate successful, fallback, and failed folder-shell insertion counts without exposing folder or container identities. +- Test: Reproduce the reported 25-folder and 18-container layout with Hide empty folders enabled, disabled, and enabled again across repeated renders. diff --git a/docs/releases/2026.08.19.07.md b/docs/releases/2026.08.19.07.md new file mode 100644 index 00000000..b79e871a --- /dev/null +++ b/docs/releases/2026.08.19.07.md @@ -0,0 +1,13 @@ +- Fix: Eliminate the 5–7 second Docker-page refresh loop caused by API container-identity differences recursively scheduling native list reloads after FolderView renders. +- Fix: Prevent FolderView-generated images with blank or unreachable icon URLs from repeatedly requesting the failed source during subsequent FolderView renders, while immediately using the local fallback for blank values. +- Fix: Restore Docker folder boxes created through the Add Folder wizard when Unraid identifies container rows by short container IDs instead of container names. +- Fix: Keep populated folders visible when Hide empty folders removes earlier placeholders and later populated folders have high saved-order positions. +- Reliability: Keep Unraid's native Docker/PHP configuration revision authoritative for structural changes, use GraphQL only to enrich containers already present in the host runtime map, and preserve native rows during Docker busy-poll cycles. +- Reliability: Resolve Docker row identities through the normalized runtime model, retain deterministic first-folder ownership for overlapping rules, insert folder shells relative to connected host rows, and leave member rows under Unraid ownership if a shell cannot be attached safely. +- Performance: Replace broad action-followup redraws with incremental runtime reconciliation and remove duplicate deferred support-snapshot scheduling to avoid unnecessary request bundles, folder grouping, preview hydration, and DOM replacement. +- Diagnostics: Retain five bounded Docker-page session summaries with privacy-safe reload-source attribution, render and request counts, cadence, busy-cycle recovery, API-mismatch aggregates, and the `FVPLUS-DKR-REFRESH-001` healthy/suspected/confirmed verdict. +- Diagnostics: Count completed renders by monotonic generation, keep trace-health timestamps current, age browser-error evidence, and report aggregate host-row resolution, folder claims, missing members, hide-empty removals, and folder-shell insertion outcomes. +- Privacy: Keep runtime, refresh, API, icon, and folder-grouping diagnostics bounded and free of container identities, raw API payloads, GraphQL queries, URLs, tokens, cookies, call stacks, process lists, and syslogs. +- Docs: Expand troubleshooting and Docker runtime/API architecture guidance for native refresh ownership, busy polling, identity mismatches, folder grouping, support-bundle interpretation, and safe escalation. +- Test: Add coordinator, native busy-indicator, double-reload, image fallback, short-ID grouping, high-position hide-empty, repeated-render, browser-fixture, support-bundle, and large-runtime performance regressions. +- Quality: Validate the complete stable promotion through strict lint, static analysis, unit, workflow, release, install, deterministic-build, browser, theme, responsive, performance, dependency, Git-integrity, and GitHub security checks. diff --git a/docs/sbom.cdx.json b/docs/sbom.cdx.json index 0404c247..4d865597 100644 --- a/docs/sbom.cdx.json +++ b/docs/sbom.cdx.json @@ -1,13 +1,13 @@ { "bomFormat": "CycloneDX", "specVersion": "1.5", - "serialNumber": "urn:uuid:f6b26184-7c5b-8984-a57e-75fb80c030ce", + "serialNumber": "urn:uuid:7d763a96-545a-896b-b7a7-d64cb56bf965", "version": 1, "metadata": { "component": { "type": "application", "name": "FolderView Plus", - "version": "2026.08.18.08", + "version": "2026.08.19.07", "properties": [ { "name": "folderview-plus:runtime-dependencies", diff --git a/docs/security/csp-readiness.json b/docs/security/csp-readiness.json index 984134d0..064015b8 100644 --- a/docs/security/csp-readiness.json +++ b/docs/security/csp-readiness.json @@ -3,7 +3,7 @@ "mode": "report-only-shared-unraid-document", "scope": { "scannedRoot": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus", - "scannedFiles": 230, + "scannedFiles": 233, "exclusions": [ "third-party icon asset pack", "vendored scripts", @@ -21,7 +21,7 @@ "totals": { "inlineEventAttributes": 0, "inlineScriptBlocks": 0, - "externalScriptTags": 200, + "externalScriptTags": 205, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, @@ -72,7 +72,7 @@ "htmlStringSinkInventory": [ { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js", - "line": 1230, + "line": 1238, "sink": ".innerHTML =", "risk": "high", "dataClass": "persisted-or-runtime-data", @@ -82,7 +82,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js", - "line": 1241, + "line": 1249, "sink": ".innerHTML =", "risk": "reviewed", "dataClass": "static-or-controlled-ui-template", @@ -92,7 +92,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js", - "line": 1286, + "line": 1294, "sink": ".innerHTML =", "risk": "high", "dataClass": "persisted-or-runtime-data", @@ -102,7 +102,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js", - "line": 3465, + "line": 3468, "sink": ".innerHTML =", "risk": "high", "dataClass": "persisted-or-runtime-data", @@ -486,7 +486,7 @@ "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page", "inlineEventAttributes": 0, "inlineScriptBlocks": 0, - "externalScriptTags": 30, + "externalScriptTags": 31, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, @@ -498,7 +498,7 @@ "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/FolderViewPlus.page", "inlineEventAttributes": 0, "inlineScriptBlocks": 0, - "externalScriptTags": 7, + "externalScriptTags": 8, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, @@ -522,7 +522,7 @@ "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page", "inlineEventAttributes": 0, "inlineScriptBlocks": 0, - "externalScriptTags": 62, + "externalScriptTags": 64, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, @@ -558,7 +558,7 @@ "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/custom.php", "inlineEventAttributes": 0, "inlineScriptBlocks": 0, - "externalScriptTags": 4, + "externalScriptTags": 5, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, diff --git a/docs/unraid-docker-api-integration.md b/docs/unraid-docker-api-integration.md index d86751bc..d882a240 100644 --- a/docs/unraid-docker-api-integration.md +++ b/docs/unraid-docker-api-integration.md @@ -113,6 +113,8 @@ Every mutation: Autostart GraphQL persistence remains capability-gated and is not exposed by FolderView Plus UI. The existing Unraid preference path remains authoritative until an isolated, reproducible mutation contract proves order, wait values, persistence, rollback, and legacy UI synchronization. +API container reads enrich state only when they match the native PHP/DOM identity set. A full-list or targeted identity mismatch is diagnostic evidence, not a reason to call the host `loadlist()` function. FolderView Plus records only provider-only/runtime-only counts and first/last observation times, while native configuration revisions remain the structural refresh authority. + ## Bounded logs The provider can request a 1–500-line Docker log tail when `docker.logs` is available. This is an internal API for a future explicitly reviewed troubleshooting surface: diff --git a/folderview.plus.plg b/folderview.plus.plg index 20df76d5..afae2c98 100644 --- a/folderview.plus.plg +++ b/folderview.plus.plg @@ -6,9 +6,9 @@ - - - + + + @@ -18,15 +18,70 @@ -###2026.08.18.08 -- Feature: Prefer Unraid GraphQL for Docker runtime-state reconciliation on supported hosts while automatically preserving the established PHP snapshot path for legacy, limited, denied, unavailable, or structurally incompatible API responses. -- Feature: Show an accessible, channel-aware Update Available link in FolderView Plus Settings that follows dev or stable manifests and opens Unraid's Plugins page for installation. -- Fix: Keep Docker folders rendering during fresh-install startup when native or third-party container rows omit the status or wrapper markup expected by single-row previews, using the model-based preview as a privacy-safe fallback. -- Reliability: Add lifecycle ownership, stale-response cancellation, bounded cooldowns, targeted API reads, structural-change fallback, and privacy-safe aggregate diagnostics without exposing container identities or server response details. -- Test: Replace live-Unraid scheduled validation and encrypted target secrets with deterministic isolated fixtures covering current, legacy, limited, denied, partial, unavailable, rate-limited, schema-drift, and future-native host profiles. -- UI/UX: Polish the Settings update indicator, responsive typography, README badges, banner presentation, rolling clone-traffic badge, and repository analytics while retaining light, dark, mobile, keyboard, and reduced-motion support. -- Quality: Expand runtime, browser, localization, privacy, compatibility, packaging, install, release, and workflow coverage for the complete stable promotion. -- Docs: Update the Wiki-linked repository documentation, API architecture, compatibility boundaries, prerelease qualification, contribution guidance, and release metadata for the API-first rollout. +###2026.08.19.07 +- Fix: Eliminate the 5–7 second Docker-page refresh loop caused by API container-identity differences recursively scheduling native list reloads after FolderView renders. +- Fix: Prevent FolderView-generated images with blank or unreachable icon URLs from repeatedly requesting the failed source during subsequent FolderView renders, while immediately using the local fallback for blank values. +- Fix: Restore Docker folder boxes created through the Add Folder wizard when Unraid identifies container rows by short container IDs instead of container names. +- Fix: Keep populated folders visible when Hide empty folders removes earlier placeholders and later populated folders have high saved-order positions. +- Reliability: Keep Unraid's native Docker/PHP configuration revision authoritative for structural changes, use GraphQL only to enrich containers already present in the host runtime map, and preserve native rows during Docker busy-poll cycles. +- Reliability: Resolve Docker row identities through the normalized runtime model, retain deterministic first-folder ownership for overlapping rules, insert folder shells relative to connected host rows, and leave member rows under Unraid ownership if a shell cannot be attached safely. +- Performance: Replace broad action-followup redraws with incremental runtime reconciliation and remove duplicate deferred support-snapshot scheduling to avoid unnecessary request bundles, folder grouping, preview hydration, and DOM replacement. +- Diagnostics: Retain five bounded Docker-page session summaries with privacy-safe reload-source attribution, render and request counts, cadence, busy-cycle recovery, API-mismatch aggregates, and the `FVPLUS-DKR-REFRESH-001` healthy/suspected/confirmed verdict. +- Diagnostics: Count completed renders by monotonic generation, keep trace-health timestamps current, age browser-error evidence, and report aggregate host-row resolution, folder claims, missing members, hide-empty removals, and folder-shell insertion outcomes. +- Privacy: Keep runtime, refresh, API, icon, and folder-grouping diagnostics bounded and free of container identities, raw API payloads, GraphQL queries, URLs, tokens, cookies, call stacks, process lists, and syslogs. +- Docs: Expand troubleshooting and Docker runtime/API architecture guidance for native refresh ownership, busy polling, identity mismatches, folder grouping, support-bundle interpretation, and safe escalation. +- Test: Add coordinator, native busy-indicator, double-reload, image fallback, short-ID grouping, high-position hide-empty, repeated-render, browser-fixture, support-bundle, and large-runtime performance regressions. +- Quality: Validate the complete stable promotion through strict lint, static analysis, unit, workflow, release, install, deterministic-build, browser, theme, responsive, performance, dependency, Git-integrity, and GitHub security checks. + + +###2026.08.19.06 +- Fix: Keep populated Docker folders visible when Hide empty folders removes earlier empty rows and later folders have high saved-order positions. +- Reliability: Insert folder shells relative to connected rows in the reconciled host order and leave member rows under Unraid ownership if a shell cannot be attached. +- Diagnostics: Report aggregate successful, fallback, and failed folder-shell insertion counts without exposing folder or container identities. +- Test: Reproduce the reported 25-folder and 18-container layout with Hide empty folders enabled, disabled, and enabled again across repeated renders. + + +###2026.08.19.05 +- Fix: Restore Docker folder boxes for folders created through the Add Folder wizard when Unraid renders container rows with short container IDs instead of container names. +- Reliability: Resolve Docker row identities through the normalized runtime container model and preserve deterministic first-folder ownership when rules overlap. +- Diagnostics: Add privacy-safe aggregate folder-grouping evidence for host-row resolution, claimed members, missing members, rendered folders, and hide-empty removals. +- Test: Reproduce the reported 25-folder and 18-container layout with manual, regex, label, and advanced-rule membership in unit and browser fixtures. + + +###2026.08.19.04 +- Fix: Stop API container identity differences from recursively scheduling Unraid Docker list reloads and eliminate the resulting 5-7 second Docker-page refresh loop. +- Fix: Stop blank or unreachable container icon URLs from generating repeated requests by using the local fallback immediately and remembering failed sources across later renders for the current page session. +- Reliability: Keep Unraid's native Docker/PHP configuration revision authoritative for structural changes, enrich only matching containers through API-first reads, and preserve native rows while Docker is busy. +- Performance: Replace broad action-followup redraws with incremental runtime reconciliation and remove duplicate deferred support-snapshot scheduling. +- Diagnostics: Add five-session Docker refresh history, privacy-safe reload-source attribution, busy-cycle recovery, API-mismatch aggregates, browser-error aging, and the `FVPLUS-DKR-REFRESH-001` loop verdict. +- Diagnostics: Count completed renders by monotonic generation and keep trace-health timestamps current so exported support bundles accurately describe the observed session. +- Privacy: Keep refresh and API evidence bounded and free of container identities, raw API payloads, GraphQL queries, URLs, tokens, cookies, call stacks, process lists, and syslogs. +- Test: Validate the stable candidate through complete lint, static analysis, unit, workflow, release, install, deterministic-build, browser, theme, responsive, performance, dependency, Git-integrity, and GitHub security checks. + + +###2026.08.19.03 +- Diagnostics: Count completed Docker renders from the monotonic render generation even when a later runtime-sync snapshot supplies the stored page evidence. +- Diagnostics: Advance the trace-health top-level timestamp on every trace or page-snapshot write while preserving the other trace sections. +- Performance: Remove the duplicate Docker page-snapshot timer and keep deferred snapshot scheduling owned by the diagnostics module. +- Test: Add regression coverage for runtime-sync render attribution, duplicate-generation suppression, timestamp freshness, and single-owner snapshot scheduling. +- Quality: Complete the full release, workflow, browser, theme, performance, dependency, Git-integrity, and GitHub security audit before the main merge. + + +###2026.08.19.02 +- Diagnostics: Retain up to five bounded Docker-page session summaries with start/end time, duration, exit reason, load/render/request counts, reload cadence, native busy-cycle recovery, and restored-folder evidence. +- Diagnostics: Classify full Docker reloads by fixed privacy-safe sources and derive `healthy`, `suspected`, or `confirmed` refresh-loop verdicts with reference `FVPLUS-DKR-REFRESH-001`. +- Reliability: Record API identity differences as aggregate counts only and keep Unraid's native configuration revision authoritative without requesting a host reload from the mismatch path. +- Diagnostics: Preserve detailed browser-error evidence for the current session and previous 30 days while reducing older retained errors to category counts. +- Maintenance: Extend sanitized support-bundle comparison to highlight refresh-loop, busy-cycle, API-mismatch, mutation, and transfer regressions. +- Privacy: Keep Docker refresh diagnostics bounded and free of container names, IDs, raw API payloads, GraphQL queries, URLs, tokens, cookies, process lists, syslogs, and call stacks. + + +###2026.08.19.01 +- Fix: Stop API container identity differences from recursively scheduling Unraid Docker list reloads after every FolderView render. +- Reliability: Keep Unraid's native Docker/PHP configuration revision path authoritative for structural changes while using GraphQL only to enrich containers already present in the host runtime map. +- Reliability: Detect Unraid's visible Docker busy-poll cycle and temporarily preserve its native rows instead of rebuilding every folder and preview on each host refresh. +- Performance: Avoid repeated full runtime request bundles, folder grouping, preview hydration, and DOM replacement during unresolved API identity differences or native Docker service polling. +- Test: Add coordinator, host busy-indicator, and double-reload regression coverage, then validate the fix through the complete lint/test suite, real-browser fixtures, and large-runtime performance budgets. ###2026.08.18.07 diff --git a/folderview.plus.xml b/folderview.plus.xml index 8a191a68..002d80e1 100644 --- a/folderview.plus.xml +++ b/folderview.plus.xml @@ -10,7 +10,7 @@ FolderView Plus organizes Docker, VM, and Dashboard views into folders in Unraid, with starter setup tools, rules, bulk assignment, recovery, templates, and diagnostics. -2026-08-18 +2026-08-19 7.0.0 folder view docker vm dashboard organization groups sorting import export backup recovery rules templates diagnostics https://forums.unraid.net/topic/197631-plugin-folderview-plus/ diff --git a/scripts/csp_readiness_guard.mjs b/scripts/csp_readiness_guard.mjs index 40fe1dfb..901b0a4d 100644 --- a/scripts/csp_readiness_guard.mjs +++ b/scripts/csp_readiness_guard.mjs @@ -45,8 +45,8 @@ const patterns = { }; const lineNumberAt = (source, index) => source.slice(0, index).split('\n').length; const explicitHtmlSinkReviews = new Map([ - ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js:1286', 'Port endpoints and protocols are escaped by buildDockerPortEndpoint before the markup builder returns.'], - ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js:3465', 'The loading overlay contains plugin-authored static markup only.'], + ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js:1294', 'Port endpoints and protocols are escaped by buildDockerPortEndpoint before the markup builder returns.'], + ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js:3468', 'The loading overlay contains plugin-authored static markup only.'], ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.action-bar.js:274', 'Action, label, icon, title, and menu values are escaped by the local markup builders.'], ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.command-view.js:504', 'Names, identifiers, states, actions, and error text are escaped; counts are normalized numbers and image sources are sanitized.'], ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.shared-controls.js:221', 'The stable-toggle controller accepts only plugin-owned markup builders and validates the expected input after mounting.'], diff --git a/scripts/install_smoke.sh b/scripts/install_smoke.sh index 61d4314a..559764b5 100644 --- a/scripts/install_smoke.sh +++ b/scripts/install_smoke.sh @@ -76,6 +76,7 @@ REQUIRED_ARCHIVE_ENTRIES=( "./usr/local/emhttp/plugins/folderview.plus/scripts/runtime.transport.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.capabilities.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.api-coordinator.js" + "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.refresh-diagnostics.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/runtime.folder-ordering.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/runtime.live-refresh.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.runtime-snapshot.js" @@ -197,6 +198,7 @@ REQUIRED_FILES=( "scripts/runtime.transport.js" "scripts/docker.runtime.capabilities.js" "scripts/docker.runtime.api-coordinator.js" + "scripts/docker.runtime.refresh-diagnostics.js" "scripts/runtime.folder-ordering.js" "scripts/runtime.live-refresh.js" "scripts/folderviewplus.runtime-snapshot.js" diff --git a/scripts/lib/fixture-browser-server.mjs b/scripts/lib/fixture-browser-server.mjs index 3f98688f..f88e10f8 100644 --- a/scripts/lib/fixture-browser-server.mjs +++ b/scripts/lib/fixture-browser-server.mjs @@ -147,6 +147,8 @@ const fixtureServer = http.createServer(async (request, response) => { filePath = path.join(fixtureDir, 'docker-api-legacy.html'); } else if (requestUrl.pathname === '/docker-layout-stability') { filePath = path.join(fixtureDir, 'docker-layout-stability.html'); + } else if (requestUrl.pathname === '/docker-folder-grouping') { + filePath = path.join(fixtureDir, 'docker-folder-grouping.html'); } else if (requestUrl.pathname === '/csp-hardening') { filePath = path.join(fixtureDir, 'csp-hardening.html'); } else if (requestUrl.pathname.startsWith('/security-fixtures/')) { diff --git a/scripts/lib/support_bundle_compare.mjs b/scripts/lib/support_bundle_compare.mjs index 74875e08..e5336846 100644 --- a/scripts/lib/support_bundle_compare.mjs +++ b/scripts/lib/support_bundle_compare.mjs @@ -1,4 +1,4 @@ -const SAFE_KEY = /(version|channel|schema|count|status|health|mode|layout|width|height|viewport|touch|theme|available|enabled|duration|timestamp|checked|source|provider|result|reason|errorcode|severity|category|included|fresh|age|render)/i; +const SAFE_KEY = /(version|channel|schema|count|status|health|mode|layout|width|height|viewport|touch|theme|available|enabled|duration|timestamp|checked|source|provider|result|reason|errorcode|severity|category|included|fresh|age|render|reload|loadlist|listview|request|busy|cadence|mismatch|folder|mutation|record|resource|transfer|session|verdict|started|ended|exit|bootstrap|manual|revision|followup|poll|caller)/i; const SENSITIVE_KEY = /(name|path|url|address|ip|mac|token|cookie|header|agent|identity|uuid|(?:^|_)id$)/i; const MAX_DEPTH = 8; const MAX_ROWS = 300; diff --git a/scripts/load_order_contracts.json b/scripts/load_order_contracts.json index d710b608..6fdc1bf3 100644 --- a/scripts/load_order_contracts.json +++ b/scripts/load_order_contracts.json @@ -5,6 +5,7 @@ "id": "settings-page", "source": "FolderViewPlus.page", "references": [ + "scripts/runtime.image-fallbacks.js", "scripts/folderviewplus.csp-events.js", "scripts/folderviewplus.safe-dom.js", "scripts/folderviewplus.page-bootstrap.js", @@ -119,9 +120,11 @@ "scripts/runtime.host-adapter.js", "scripts/runtime.host-compatibility.js", "scripts/docker.runtime.container-model.js", + "scripts/docker.runtime.folder-grouping.js", "scripts/docker.runtime.capabilities.js", "scripts/docker.runtime.providers.js", "scripts/docker.runtime.api-coordinator.js", + "scripts/docker.runtime.refresh-diagnostics.js", "scripts/docker.runtime.provider-health.js", "scripts/runtime.folder-ordering.js", "scripts/runtime.live-refresh.js", @@ -253,6 +256,7 @@ "id": "folder-editor-page", "source": "Folder.page", "references": [ + "scripts/runtime.image-fallbacks.js", "scripts/folderviewplus.csp-events.js", "scripts/folderviewplus.page-bootstrap.js", "scripts/folder.editor.page-bootstrap.js", @@ -312,6 +316,7 @@ "id": "custom-script-bootstrap", "source": "scripts/custom.php", "references": [ + "scripts/runtime.image-fallbacks.js", "scripts/folderviewplus.csp-events.js", "scripts/folderviewplus.safe-dom.js", "scripts/runtime.custom-script-loader.js" diff --git a/scripts/test_runner_contracts.json b/scripts/test_runner_contracts.json index d7b096ed..18a3f170 100644 --- a/scripts/test_runner_contracts.json +++ b/scripts/test_runner_contracts.json @@ -50,10 +50,10 @@ "scripts/lib/fixture-browser-server.mjs", "scripts/lib/fixture-browser-runner.mjs" ], - "testCount": 41, - "assertionCount": 516, - "pageEvaluateCount": 104, - "orderedTitleSha256": "53ad55e55f53773f656099d739688ea69880e286da018113f8e212813ea64166" + "testCount": 42, + "assertionCount": 534, + "pageEvaluateCount": 105, + "orderedTitleSha256": "ee3028477ae78db47c8f3487c5c43a5779b5b75e80a0f05e1f2c498a53116b44" } } } diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page index 658906c2..4a9a52d0 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page @@ -77,7 +77,7 @@ try { ]; } ?> - + $folderEditorPageType, diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/FolderViewPlus.page b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/FolderViewPlus.page index 0d455e51..f9b6c230 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/FolderViewPlus.page +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/FolderViewPlus.page @@ -15,7 +15,7 @@ emitRequestTokenMetaTag(); $fvplusRuntimeConflicts = fvplus_detect_runtime_plugin_conflicts(); ?> - + diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page index ca8144e8..06dfec8e 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page @@ -69,9 +69,11 @@ $fvplusRuntimePreflightHasFatal = runtimePreflightHasFatal($fvplusRuntimePreflig + + diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/schemas/architecture-contracts.schema.json b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/schemas/architecture-contracts.schema.json index 8845ce71..48571192 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/schemas/architecture-contracts.schema.json +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/schemas/architecture-contracts.schema.json @@ -8,7 +8,7 @@ "maxRegisteredActionGlobals": 11, "fileLineBudgets": { "scripts/folderviewplus.js": { "limit": 11793, "target": 1200, "history": [12092, 11793] }, - "scripts/docker.js": { "limit": 8028, "target": 1000, "history": [8839, 8045, 8028] }, + "scripts/docker.js": { "limit": 7986, "target": 1000, "history": [8839, 8045, 8028, 7986] }, "server/lib.php": { "limit": 2649, "target": 400, "history": [7556, 2649] }, "scripts/folder.js": { "limit": 4773, "target": 700, "history": [5522, 4773] }, "scripts/folderviewplus.wizard.js": { "limit": 4165, "target": 500, "history": [4849, 4165] }, @@ -75,7 +75,7 @@ "scripts/runtime.transport.js": { "limit": 499, "target": 499, "history": [1060, 499] }, "scripts/folderviewplus.bulk-assignment.js": { "limit": 1023, "target": 500, "history": [1023] }, "scripts/folder.editor.chrome.js": { "limit": 1006, "target": 500, "history": [1006] }, - "scripts/docker.runtime.diagnostics.js": { "limit": 946, "target": 500, "history": [946] }, + "scripts/docker.runtime.diagnostics.js": { "limit": 945, "target": 500, "history": [946, 945] }, "scripts/folderviewplus.theme-resolver.js": { "limit": 930, "target": 700, "history": [930] }, "scripts/folderviewplus.theme-surface.js": { "limit": 113, "target": 100, "history": [113] }, "scripts/folderviewplus.diagnostics-actions.js": { "limit": 176, "target": 160, "history": [176] }, @@ -103,7 +103,8 @@ "scripts/folderviewplus.wizard-review.js": { "limit": 322, "target": 280, "history": [322] }, "scripts/docker.runtime.container-model.js": { "limit": 203, "target": 203, "history": [203] }, "scripts/docker.runtime.capabilities.js": { "limit": 77, "target": 70, "history": [77] }, - "scripts/docker.runtime.api-coordinator.js": { "limit": 453, "target": 420, "history": [453] }, + "scripts/docker.runtime.api-coordinator.js": { "limit": 433, "target": 420, "history": [453, 433] }, + "scripts/docker.runtime.refresh-diagnostics.js": { "limit": 263, "target": 250, "history": [263] }, "scripts/docker.runtime.layout-geometry.js": { "limit": 65, "target": 65, "history": [65] }, "scripts/docker.runtime.provider-health.js": { "limit": 123, "target": 123, "history": [123] }, "styles/folderviewplus.css": { "limit": 14477, "target": 14477, "history": [14489, 14477] }, @@ -144,7 +145,7 @@ "kind": "browser-entrypoint", "stateModel": "entrypoint-owned", "consumers": ["docker"], - "dependsOn": ["scripts/folderviewplus.utils.js", "scripts/folderviewplus.request.js", "scripts/runtime.host-adapter.js", "scripts/runtime.folder-ordering.js", "scripts/runtime.live-refresh.js", "scripts/runtime.page-diagnostics.js", "scripts/docker.runtime.shared.js", "scripts/docker.runtime.container-model.js", "scripts/docker.runtime.capabilities.js", "scripts/docker.runtime.providers.js", "scripts/docker.runtime.api-coordinator.js", "scripts/docker.runtime.column-controller.js", "scripts/docker.runtime.provider-health.js"], + "dependsOn": ["scripts/folderviewplus.utils.js", "scripts/folderviewplus.request.js", "scripts/runtime.host-adapter.js", "scripts/runtime.folder-ordering.js", "scripts/runtime.live-refresh.js", "scripts/runtime.page-diagnostics.js", "scripts/docker.runtime.shared.js", "scripts/docker.runtime.container-model.js", "scripts/docker.runtime.folder-grouping.js", "scripts/docker.runtime.capabilities.js", "scripts/docker.runtime.providers.js", "scripts/docker.runtime.api-coordinator.js", "scripts/docker.runtime.refresh-diagnostics.js", "scripts/docker.runtime.column-controller.js", "scripts/docker.runtime.provider-health.js"], "lifecycle": { "startup": "host-bootstrap", "teardown": "pagehide-owned" }, "compatibilityGlobals": ["addDockerFolderContext", "dropDownButton", "editFolder", "createFolderBtn"] }, @@ -642,12 +643,24 @@ "owner": "declarative-action-runtime", "consumers": ["settings", "docker", "vm", "dashboard", "folder-editor", "plugin-pages"], "stateModel": "singleton", - "dependsOn": [], + "dependsOn": ["scripts/runtime.image-fallbacks.js"], "compatibilityGlobals": ["FolderViewPlusCspEvents", "FolderViewPlusCspEventsInstalled"], "global": "FolderViewPlusCspEvents", "loader": "window", "exports": ["allowedHandlers", "execute", "getAction", "install", "parseArgument", "registerActions", "resolveAction", "snapshot", "splitTopLevel", "supportedEvents", "unregisterOwner"] }, + { + "file": "scripts/runtime.image-fallbacks.js", + "owner": "shared-image-fallback-cache", + "consumers": ["settings", "docker", "vm", "dashboard", "folder-editor", "plugin-pages"], + "stateModel": "singleton", + "dependsOn": [], + "compatibilityGlobals": ["FolderViewPlusFoundationModules"], + "global": "FolderViewPlusFoundationModules", + "globalMember": "imageFallbacks", + "loader": "commonjs", + "exports": ["has", "record", "snapshot"] + }, { "file": "scripts/folderviewplus.actions-support.js", "owner": "settings-support-actions", @@ -874,6 +887,22 @@ "typeChecked": true, "exports": ["normalizeName", "deepFreeze", "normalizePort", "normalizeMount", "normalizeContainer", "identityTokens", "resolveContainerIdentity", "mergeContainers"] }, + { + "file": "scripts/docker.runtime.folder-grouping.js", + "owner": "docker-runtime-grouping", + "consumers": ["docker"], + "stateModel": "factory-owned", + "dependsOn": [], + "compatibilityGlobals": ["FolderViewPlusFoundationModules"], + "global": "FolderViewPlusFoundationModules", + "globalMember": "dockerFolderGrouping", + "loader": "commonjs", + "typeChecked": true, + "exports": ["normalizeToken", "buildIdentityIndex", "readRowCandidates", "createSession"], + "lifecycleFactories": [ + { "name": "createSession", "exports": ["readOrder", "beginFolder", "insertFolderRow", "claim", "finishFolder", "snapshot"] } + ] + }, { "file": "scripts/docker.runtime.capabilities.js", "owner": "docker-runtime-capabilities", @@ -916,6 +945,22 @@ "typeChecked": true, "exports": ["readNodeGeometry", "compareGeometry"] }, + { + "file": "scripts/docker.runtime.refresh-diagnostics.js", + "owner": "docker-runtime-diagnostics", + "consumers": ["docker"], + "stateModel": "factory-owned", + "dependsOn": [], + "compatibilityGlobals": ["FolderViewPlusFoundationModules"], + "global": "FolderViewPlusFoundationModules", + "globalMember": "dockerRefreshDiagnostics", + "loader": "commonjs", + "typeChecked": true, + "exports": ["STORAGE_KEY", "SESSION_LIMIT", "SOURCE_VALUES", "buildOrderFingerprint", "buildVerdict", "createTracker"], + "lifecycleFactories": [ + { "name": "createTracker", "exports": ["record", "recordPageSnapshot", "recordApiMismatch", "markReloadSource", "snapshot", "dispose"] } + ] + }, { "file": "scripts/docker.runtime.provider-health.js", "owner": "docker-runtime-providers", diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/custom.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/custom.php index 29da6a4e..31351b50 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/custom.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/custom.php @@ -1,5 +1,6 @@ '; echo ''; echo ''; $conditionalDockerLegacyAssets = !empty($fvplusDockerLegacyConditionalAssets); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js index ae13c8e7..f55ee5ab 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js @@ -59,6 +59,7 @@ const dockerRuntimePerformanceTelemetry = runtimePerformanceTelemetryModule?.get debug: FOLDER_VIEW_DEBUG_MODE }) || null; const dockerRuntimeInfoModule = window.FolderViewPlusDockerRuntimeInfo || null; +const dockerFolderGroupingModule = window.FolderViewPlusFoundationModules?.dockerFolderGrouping || null; const dockerApiCoordinatorModule = window.FolderViewPlusFoundationModules?.dockerApiCoordinator || null; let dockerApiIntegration = null; const dockerPreviewActionsModule = window.FolderViewPlusDockerPreviewActions || null; @@ -399,6 +400,12 @@ if (!dockerRuntimeColumnControllerModule || typeof dockerRuntimeColumnController } else { setDockerFatalBannerModuleStatus('docker.runtime.column-controller.js', 'ok', 'Docker column controller ready'); } +if (!dockerFolderGroupingModule || typeof dockerFolderGroupingModule.createSession !== 'function') { + dockerBootstrapMissingModules.push('docker.runtime.folder-grouping.js'); + setDockerFatalBannerModuleStatus('docker.runtime.folder-grouping.js', 'missing', 'Docker folder grouping unavailable'); +} else { + setDockerFatalBannerModuleStatus('docker.runtime.folder-grouping.js', 'ok', 'Docker folder grouping ready'); +} if (typeof dockerRuntimeShared.createFolderRowActionsController !== 'function') { dockerBootstrapMissingModules.push('folder.runtime.row-actions.js'); setDockerFatalBannerModuleStatus('folder.runtime.row-actions.js', 'missing', 'folder row action lifecycle unavailable'); @@ -519,6 +526,7 @@ markDockerFatalBannerStep('Docker runtime modules resolved'); let dockerHostGuardsApi = null; let dockerRuntimeDiagnosticsApi = null; let dockerRuntimeReconcileApi = null; +let dockerFolderGroupingSession = null; const dockerHostAdapter = runtimeHostAdapters?.getOrCreate?.('docker', { window, document }) || null; const getDockerHostGuardsApi = () => { if (!dockerHostGuardsApi && dockerHostGuardsModule && typeof dockerHostGuardsModule.createApi === 'function') { @@ -1065,7 +1073,8 @@ const getDockerRuntimeDiagnosticsApi = () => { resolveExpectedMemberActionToken: (entry = {}) => resolveDockerSupportBundleExpectedMemberActionToken(entry), getRuntimeInfoEntry: (containerName) => dockerRuntimeInfoByName?.[containerName] || {}, getCorrelationContext: () => buildDockerDiagnosticsCorrelationContext(), - getLayoutStabilityDiagnostics: () => getDockerRuntimeLayoutStabilitySnapshot() + getLayoutStabilityDiagnostics: () => getDockerRuntimeLayoutStabilitySnapshot(), + getFolderGroupingDiagnostics: () => dockerFolderGroupingSession?.snapshot?.() || { available: false } }); } return dockerRuntimeDiagnosticsApi; @@ -1144,7 +1153,6 @@ const WEBUI_OPEN_REL = 'noopener'; const WEBUI_TEMPLATE_TOKEN_REGEX = /\[(?:IP|PORT:[^\]]+|HOSTNAME|MAGICDNS|NOSERVE)\]/i; const DOCKER_HOST_UPDATE_COMMAND_REGEX = /^\s*update_container(?:\s|$)/i; const DOCKER_HOST_UPDATE_SYNC_SUSPENDED_UNTIL_KEY = '__fvplusDockerHostUpdateSyncSuspendedUntil'; -const DOCKER_SUPPORT_BUNDLE_PAGE_STORAGE_KEY = dockerRuntimeDiagnosticsModule?.DOCKER_SUPPORT_BUNDLE_PAGE_STORAGE_KEY || 'fv.support.bundle.docker.page.v1'; const getDockerRuntimePrivacyOptions = (prefs = null) => { const normalized = utils.normalizePrefs(prefs || folderTypePrefs || {}); const dashboard = normalized?.dashboard && typeof normalized.dashboard === 'object' ? normalized.dashboard : {}; @@ -1313,12 +1321,6 @@ const suspendDockerHostUpdateSync = (durationMs = 0) => { window[DOCKER_HOST_UPDATE_SYNC_SUSPENDED_UNTIL_KEY] = resolvedUntil; return resolvedUntil; }; -const updateDockerTraceHealth = (traceName, success, details = {}) => { - const diagnosticsApi = getDockerRuntimeDiagnosticsApi(); - return diagnosticsApi && typeof diagnosticsApi.updateTraceHealth === 'function' - ? diagnosticsApi.updateTraceHealth(traceName, success, details) - : false; -}; const appendDockerBulkUpdateTrace = (eventType, details = {}) => { const diagnosticsApi = getDockerRuntimeDiagnosticsApi(); return diagnosticsApi && typeof diagnosticsApi.appendBulkUpdateTrace === 'function' @@ -1951,6 +1953,7 @@ const buildDockerTooltipContent = (ct) => { const actionMap = new Set(['start', 'resume', 'stop', 'pause', 'restart']); if (actionMap.has(action) && containerId) { const refreshTarget = getDockerRuntimeReconcileApi()?.getLifecycleRefreshCallbackName?.() || 'loadlist'; + if (refreshTarget === 'loadlist') getDockerRuntimeDiagnosticsApi()?.markReloadSource?.('plugin-action-followup'); eventControl({ action, container: containerId }, refreshTarget); return; } @@ -3848,7 +3851,7 @@ if (dockerRuntimeActionBarModule && typeof dockerRuntimeActionBarModule.createAp applyPrefs: (prefs) => applyRuntimePrefs(prefs), savePrefs: saveDockerRuntimeToolbarPrefs, refreshRuntimeView: () => { - queueLoadlistRefresh({ suppressLoadingUi: true }); + queueLoadlistRefresh({ suppressLoadingUi: true, reloadSource: 'manual-host-refresh' }); return Promise.resolve(); }, getFolders: () => globalFolders || {}, @@ -4722,7 +4725,8 @@ const createFolders = async () => { containersInfo = { ...dockerRuntimeInfoByName }; } folders = reconcileDockerMemberIdentities(folders, containersInfo); - let order = readDockerHostOrderFromDom(); + dockerFolderGroupingSession = dockerFolderGroupingModule.createSession({ document, containersInfo }); + let order = dockerFolderGroupingSession.readOrder(); let prefsResponse = {}; try { prefsResponse = prom[3] ? JSON.parse(prom[3]) : {}; @@ -4830,8 +4834,6 @@ const createFolders = async () => { if (container && folderRegex.test(container)) { let id = container.replace(folderRegex, ''); if (folders[id]) { - // Pass 'order' (the live array) to createFolder. - // 'position' is the current 'key' (index of the folder placeholder in the 'order' array). const removedCount = createFolder( folders[id], id, @@ -4850,14 +4852,11 @@ const createFolders = async () => { } dockerPerf.end('createFolders.renderOrdered', { orderedEntries: order.length }); - // Draw the foldes outside of the order dockerPerf.begin('createFolders.renderRemaining'); if (FOLDER_VIEW_DEBUG_MODE) console.log('[FV3_DEBUG] createFolders: Starting loop to draw folders outside of order (remaining).'); - // Preserve original folder order when inserting at the top with unshift. const remainingFolders = Object.entries(getPrefsOrderedFolderMap(folders, folderTypePrefs)).reverse(); for (const [id, value] of remainingFolders) { if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolders: Processing remaining folder: id=${id}`); - // Add the folder on top of the array order.unshift(`folder-${id}`); if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolders: Unshifted folder-${id} to order. New order:`, [...order]); createFolder( @@ -5083,7 +5082,6 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container const advanced = $.cookie('docker_listview_mode') == 'advanced'; - // --- Correctly build combinedContainers --- const precomputed = matchCacheEntry && typeof matchCacheEntry === 'object' ? matchCacheEntry : null; const originalContainersFromDefinition = precomputed ? (Array.isArray(precomputed.explicit) ? [...precomputed.explicit] : []) @@ -5132,7 +5130,6 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container type: 'docker' }); ruleMatches.forEach(pushCombined); - if (FOLDER_VIEW_DEBUG_MODE) { console.log(`[FV3_DEBUG] createFolder (id: ${id}): Containers matched by folder label ('${folder.name}'):`, labelMatches); console.log(`[FV3_DEBUG] createFolder (id: ${id}): Containers matched by auto rules:`, ruleMatches); @@ -5146,8 +5143,7 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container && Number.isFinite(lazyPreviewThreshold) && combinedContainers.length >= Math.max(10, Math.min(200, Math.round(lazyPreviewThreshold))) && !isExpandedByDefault; - // --- End of combinedContainers build --- - + dockerFolderGroupingSession?.beginFolder?.(id, precomputed || {}, combinedContainers.length); const colspan = document.querySelector("#docker_containers > thead > tr").childElementCount - 5; const hoverClass = folder.settings.preview_hover && !FOLDER_VIEW_TOUCH_MODE ? 'hover' : ''; const safeFolderIcon = sanitizeImageSrc(folder.icon); @@ -5164,32 +5160,21 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container const fld = `
folder-${id}${safeFolderName}${pinnedIndicator}
${$.i18n('stopped')}
${$.i18n('up-to-date')}
0%

0 / 0
`; if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}): colspan=${colspan}. Generated folder HTML (fld).`); - if (positionInMainOrder === 0) { - if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}): Inserting folder HTML at position 0 (before).`); - $('#docker_list > tr.sortable').eq(0).before($(fld)); // Always eq(0) for 'before' the first sortable - } else { - // Find the actual DOM element that is currently at positionInMainOrder - 1 in the *visible sortable list* - // This needs to be robust to items already having been moved. - // A safer bet is to find the *last processed item* or *first non-folder item* if the folder is inserted later. - // For now, using the direct index, assuming other sortables are still in place. - if ($('#docker_list > tr.sortable').length > 0 && positionInMainOrder > 0) { - if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}): Inserting folder HTML at position ${positionInMainOrder} (after eq ${positionInMainOrder-1} of current sortables).`); - $('#docker_list > tr.sortable').eq(positionInMainOrder - 1).after($(fld)); - } else if ($('#docker_list > tr.sortable').length === 0 && positionInMainOrder === 0) { - // If no sortables exist yet (e.g., first folder, all others are new) - if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}): No sortables found, inserting folder at the beginning of #docker_list.`); - $('#docker_list').prepend($(fld)); - } else { - if (FOLDER_VIEW_DEBUG_MODE) console.warn(`[FV3_DEBUG] createFolder (id: ${id}): Could not determine insertion point for folder. Position: ${positionInMainOrder}, Sortables count: ${$('#docker_list > tr.sortable').length}`); - // Fallback: append to the list if other logic fails - $('#docker_list').append($(fld)); - } + const $createdFolderRow = $(fld); + const shellInsertion = dockerFolderGroupingSession.insertFolderRow( + id, + $createdFolderRow.get(0), + positionInMainOrder, + liveOrderArray + ); + if (!shellInsertion.inserted) { + dockerFolderGroupingSession.finishFolder(id, { renderedMemberCount: 0, removedByHideEmpty: false }); + if (FOLDER_VIEW_DEBUG_MODE) console.warn(`[FV3_DEBUG] createFolder (id: ${id}): Folder shell insertion failed; member rows were left under host ownership.`); + return remBefore; } + if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}): Folder shell inserted with strategy ${shellInsertion.strategy}.`); const safeDepth = Math.max(0, Math.min(8, Number(depthLevel) || 0)); const depthIndentPx = safeDepth * 20; - const $createdFolderRow = $('#docker_list > tr.folder[data-fv-folder-id]') - .filter((_, element) => String(element.getAttribute('data-fv-folder-id') || '') === id) - .first(); dockerFolderRowActionsController.decorate($createdFolderRow, id); $createdFolderRow .attr('data-folder-depth', String(safeDepth)) @@ -5313,7 +5298,6 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container let newFolder = {}; if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}): Initialized newFolder for processed containers.`); - // Preserve the saved folder order index in lifecycle event details for host integrations. const customOrderCursor = runtimeFolderOrdering.createOrderCursor({ order: orderSnapshotAtFolderStart, completedFolderIds: foldersDone, @@ -5344,8 +5328,10 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}), container ${container_name_in_folder}: Original index ${originalIndexOfContainerInSnapshot} < folder position ${positionInMainOrder}. Incremented remBefore to ${remBefore}.`); } - let $containerTR = $(document.getElementById(`ct-${container_name_in_folder}`)); - if (!$containerTR.length || !$containerTR.hasClass('sortable')) { + let $containerTR = dockerFolderGroupingSession + ? $(dockerFolderGroupingSession.claim(id, container_name_in_folder)) + : $(document.getElementById(`ct-${container_name_in_folder}`)); + if (!dockerFolderGroupingSession && (!$containerTR.length || !$containerTR.hasClass('sortable'))) { if(FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}), container ${container_name_in_folder}: TR not found by ID or not sortable. Fallback search...`); $containerTR = $("#docker_list > tr.sortable").filter(function() { return $(this).find("td.ct-name .appname").text().trim() === container_name_in_folder; @@ -5923,7 +5909,9 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container folder.settings?.folder_update_highlight === true && upToDate === false ); const total = Object.entries(folder.containers).length; - if (folderTypePrefs?.hideEmptyFolders === true && total === 0) { + const removedByHideEmpty = folderTypePrefs?.hideEmptyFolders === true && total === 0; + dockerFolderGroupingSession?.finishFolder?.(id, { renderedMemberCount: total, removedByHideEmpty }); + if (removedByHideEmpty) { $(`tr.folder-id-${id}`).remove(); $(`tr#name-${id}`).remove(); if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}): hideEmptyFolders enabled, removed empty folder row.`); @@ -7085,17 +7073,21 @@ const addDockerFolderContext = (id) => { opts = normalizeDividers(opts); if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] addDockerFolderContext (id: ${id}): Dispatching docker-folder-context event. Options:`, opts); folderEvents.dispatchEvent(new CustomEvent('docker-folder-context', {detail: { id, opts }})); - context.attach('#' + id, opts); queueDockerFolderContextQuickIcons(); queueDockerContextViewportGuard(); dockerPerfTelemetry.end('context-menu-build', { id, optsCount: opts.length }); if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] addDockerFolderContext (id: ${id}): Context menu attached to #${id}. Exit.`); }; - // Route Unraid host lifecycle hooks through the shared adapter while retaining legacy aliases. getDockerHostGuardsApi()?.wrapHostHook?.('listview', ({ invokeOriginal }) => { if (FOLDER_VIEW_DEBUG_MODE) console.log('[FV3_DEBUG] Patched listview: Entry.'); + if (dockerNativeBusyPollInFlight) { + appendDockerRequestBundleTrace('listview', { nativeBusyActive: true, reloadSource: 'unraid-native-busy-poll' }); + dockerNativeBusyPollInFlight = false; + if (typeof window.listview_original === 'function') invokeOriginal(); + return; + } appendDockerRequestBundleTrace('listview', { currentPage: String(location?.pathname || ''), loadedFolder: loadedFolder === true, @@ -7108,7 +7100,6 @@ getDockerHostGuardsApi()?.wrapHostHook?.('listview', ({ invokeOriginal }) => { } else { if (FOLDER_VIEW_DEBUG_MODE) console.error('[FV3_DEBUG] Patched listview: window.listview_original is not a function!'); } - if (!loadedFolder) { dockerHostLoadOwnsLoadingUi = true; if (FOLDER_VIEW_DEBUG_MODE) console.log('[FV3_DEBUG] Patched listview: loadedFolder is false. Queueing createFolders render.'); @@ -7130,13 +7121,20 @@ getDockerHostGuardsApi()?.wrapHostHook?.('listview', ({ invokeOriginal }) => { missingMessage: 'Docker host listview hook was unavailable during bootstrap.', missingDetails: ['window.listview was not a function when FolderView Plus initialized.'] }); - getDockerHostGuardsApi()?.wrapHostHook?.('loadlist', ({ invokeOriginal }) => { if (FOLDER_VIEW_DEBUG_MODE) console.log('[FV3_DEBUG] Patched loadlist: Entry.'); bindDockerHostOpenDockerPatch(); bindDockerLifecycleEventControlPatch(); bindDockerContainerContextStatePatch(); bindDockerListViewModeCookieHook(); + dockerNativeBusyPollInFlight = getDockerHostGuardsApi()?.isNativeBusyPollActive?.() === true; + if (dockerNativeBusyPollInFlight) { + appendDockerRequestBundleTrace('loadlist', { nativeBusyActive: true, reloadSource: 'unraid-native-busy-poll' }); + loadedFolder = true; + folderReq = null; + if (typeof window.loadlist_original === 'function') invokeOriginal(); + return; + } loadedFolder = false; dockerHostLoadOwnsLoadingUi = true; if (FOLDER_VIEW_DEBUG_MODE) console.log('[FV3_DEBUG] Patched loadlist: Set loadedFolder to false.'); @@ -7147,7 +7145,6 @@ getDockerHostGuardsApi()?.wrapHostHook?.('loadlist', ({ invokeOriginal }) => { }); folderReq = ensureDockerFolderReqForHostRender(); if (FOLDER_VIEW_DEBUG_MODE) console.log('[FV3_DEBUG] Patched loadlist: folderReq initialized with a staged Docker runtime request bundle.'); - if (typeof window.loadlist_original === 'function') { invokeOriginal(); if (FOLDER_VIEW_DEBUG_MODE) console.log('[FV3_DEBUG] Patched loadlist: Called original loadlist.'); @@ -7162,7 +7159,6 @@ getDockerHostGuardsApi()?.wrapHostHook?.('loadlist', ({ invokeOriginal }) => { missingMessage: 'Docker host loadlist hook was unavailable during bootstrap.', missingDetails: ['window.loadlist was not a function when FolderView Plus initialized.'] }); - const PINNED_FOLDER_CHANGE_STORAGE_KEY = 'fv.folderviewplus.pinnedFolders.changed.v1'; const PINNED_FOLDER_CHANGE_EVENT = 'fvplus:pinned-folders-changed'; const applyDockerSettingsPinSyncPayload = (payload) => { @@ -7175,7 +7171,7 @@ const applyDockerSettingsPinSyncPayload = (payload) => { syncDockerPinnedFolderUi(); return; } - queueLoadlistRefresh({ suppressLoadingUi: true }); + queueLoadlistRefresh({ suppressLoadingUi: true, reloadSource: 'plugin-config-revision' }); }; const bindDockerSettingsPinSyncListener = () => { window.addEventListener('storage', (event) => { @@ -7341,6 +7337,7 @@ let dockerBootstrapPrefsPromise = null; let queuedLoadlistTimer = null; let queuedLoadlistOptions = null; let queuedLoadlistRequestedAt = 0; +let dockerNativeBusyPollInFlight = false; let lastLiveRefreshStateSignature = ''; let lastLiveRefreshStateEntityCount = 0; let lastDockerRuntimeSnapshotToken = ''; @@ -7369,18 +7366,6 @@ let dockerRuntimePerformanceProfile = resolveDockerRuntimePerformanceProfile(fol itemCount: 0 }); -const writeDockerSupportBundleStorageRecord = (storageKey, value) => { - const diagnosticsApi = getDockerRuntimeDiagnosticsApi(); - const writeOk = diagnosticsApi && typeof diagnosticsApi.writeSupportBundleStorageRecord === 'function' - ? diagnosticsApi.writeSupportBundleStorageRecord(storageKey, value) - : false; - if (storageKey === DOCKER_SUPPORT_BUNDLE_PAGE_STORAGE_KEY) { - updateDockerTraceHealth('pageSnapshot', writeOk, { - reason: String(value?.reason || '').trim() || 'runtime-sync' - }); - } - return writeOk; -}; const resolveDockerSupportBundleExpectedMemberActionToken = (entry = {}) => { const previewActionsApi = getDockerPreviewActionsApi(); if (previewActionsApi && typeof previewActionsApi.resolveDockerMemberUpdateState === 'function') { @@ -7410,15 +7395,6 @@ const resolveDockerSupportBundleExpectedFolderActionToken = (folderId) => { return 'unknown'; }; -const collectDockerSupportBundlePageSnapshot = (reason = 'runtime-sync') => { - const diagnosticsApi = getDockerRuntimeDiagnosticsApi(); - return diagnosticsApi && typeof diagnosticsApi.collectPageSnapshot === 'function' - ? diagnosticsApi.collectPageSnapshot(reason) - : null; -}; - -let dockerSupportBundlePageSnapshotWriteTimer = null; -let dockerSupportBundlePageSnapshotPendingReason = ''; const queueDockerSupportBundlePageSnapshot = (reason = 'runtime-sync', delayMs = 180) => { const diagnosticsApi = getDockerRuntimeDiagnosticsApi(); if (!(diagnosticsApi && typeof diagnosticsApi.queuePageSnapshot === 'function')) { @@ -7430,19 +7406,6 @@ const queueDockerSupportBundlePageSnapshot = (reason = 'runtime-sync', delayMs = ? Math.max(safeDelay, 1200) : safeDelay; diagnosticsApi.queuePageSnapshot(safeReason, offCriticalPathDelay); - dockerSupportBundlePageSnapshotPendingReason = safeReason; - if (dockerSupportBundlePageSnapshotWriteTimer) { - clearTimeout(dockerSupportBundlePageSnapshotWriteTimer); - } - dockerSupportBundlePageSnapshotWriteTimer = window.setTimeout(() => { - dockerSupportBundlePageSnapshotWriteTimer = null; - const snapshotReason = dockerSupportBundlePageSnapshotPendingReason || safeReason; - dockerSupportBundlePageSnapshotPendingReason = ''; - const snapshot = collectDockerSupportBundlePageSnapshot(snapshotReason); - if (snapshot) { - writeDockerSupportBundleStorageRecord(DOCKER_SUPPORT_BUNDLE_PAGE_STORAGE_KEY, snapshot); - } - }, offCriticalPathDelay); }; const bindDockerUpdateActionClickCapture = () => { getDockerRuntimeReconcileApi()?.bindUpdateActionClickCapture?.(); @@ -7463,6 +7426,7 @@ const resolveDockerStrictPerformanceProfile = (prefs, folders, containersInfo) = const shouldSuppressDockerRuntimeLoadingUi = () => dockerHostLoadOwnsLoadingUi || nextDockerRenderSuppressLoadingUi || activeDockerRenderSuppressLoadingUi; const queueLoadlistRefresh = (options = {}) => { + getDockerRuntimeDiagnosticsApi()?.markReloadSource?.(options?.reloadSource || 'plugin-action-followup'); const normalizedOptions = { suppressLoadingUi: options?.suppressLoadingUi === true }; @@ -7492,14 +7456,12 @@ const queueLoadlistRefresh = (options = {}) => { loadlist(); }, delayMs); }; - const buildDockerRuntimeInfoUrl = (mode = 'full', cacheBust = Date.now(), options = {}) => { const liveUpdateQuery = mode === 'state' && options?.liveUpdateStatus === true ? '&liveupdate=1' : ''; return `/plugins/folderview.plus/server/read_info.php?type=docker${mode === 'state' ? '&mode=state' : ''}${liveUpdateQuery}&nocache=1&_=${cacheBust || Date.now()}`; }; - const rememberDockerRuntimeSnapshot = (snapshot) => { if (snapshot?.snapshotToken) { lastDockerRuntimeSnapshotToken = String(snapshot.snapshotToken); @@ -7511,7 +7473,6 @@ const rememberDockerRuntimeSnapshot = (snapshot) => { }; } }; - const dockerRuntimeSnapshotConfigMatches = (snapshot) => { if (!lastDockerRuntimeSnapshotToken || !snapshot?.revisions) { return true; @@ -7531,7 +7492,8 @@ const getDockerApiIntegration = () => { return true; }, onStatus: (status) => dockerHostCompatibilityController?.updateProviderEvidence?.({ provider: { apiRead: status } }), - requestStructuralRefresh: () => queueLoadlistRefresh({ suppressLoadingUi: true }), + onIdentityMismatch: (details) => getDockerRuntimeDiagnosticsApi()?.recordApiMismatch?.(details), + requestStructuralRefresh: (details = {}) => queueLoadlistRefresh({ suppressLoadingUi: true, reloadSource: details?.reason === 'config-revision-changed' ? 'plugin-config-revision' : 'plugin-action-followup' }), readConfigSnapshot: async () => runtimeSnapshotApi?.parsePayload?.(await pluginRequestClient.getJson( runtimeSnapshotApi.buildUrl('docker', 'config', { forceRefresh: false }), { cache: false })), isConfigCurrent: dockerRuntimeSnapshotConfigMatches, @@ -7559,7 +7521,7 @@ const refreshDockerRuntimeStateFromPhp = async (options = {}) => { }); return; } - queueLoadlistRefresh({ suppressLoadingUi: true }); + queueLoadlistRefresh({ suppressLoadingUi: true, reloadSource: 'plugin-action-followup' }); }; const applyStatePayload = async () => { const useSnapshot = runtimeSnapshotApi && typeof runtimeSnapshotApi.buildUrl === 'function'; @@ -7654,7 +7616,6 @@ const refreshDockerRuntimeStateFromPhp = async (options = {}) => { }); } }; - const refreshDockerRuntimeStateInPlace = async (options = {}) => { if (options?.apiFirst !== false) { try { @@ -7665,7 +7626,6 @@ const refreshDockerRuntimeStateInPlace = async (options = {}) => { } return refreshDockerRuntimeStateFromPhp({ ...options, apiFirst: false }); }; - const readDockerHostOrderFromDom = () => { const order = []; document.querySelectorAll('#docker_list > tr.sortable').forEach((row) => { @@ -8007,7 +7967,6 @@ window.addEventListener('pagehide', () => { clearTimeout(queuedLoadlistTimer); clearTimeout(dockerPinnedFolderServerReconcileTimer); clearTimeout(dockerRuntimePrivacyServerReconcileTimer); - clearTimeout(dockerSupportBundlePageSnapshotWriteTimer); clearTimeout(folderViewPlusDockerStartOrderSyncTimer); dockerFolderRowActionsController.destroy(); dockerRuntimeColumnControllerApi.dispose(); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.api-coordinator.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.api-coordinator.js index 783a9c9e..0fced69f 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.api-coordinator.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.api-coordinator.js @@ -155,10 +155,8 @@ const requestLegacyFallback = typeof deps.requestLegacyFallback === 'function' ? deps.requestLegacyFallback : async () => false; - const requestStructuralRefresh = typeof deps.requestStructuralRefresh === 'function' - ? deps.requestStructuralRefresh - : () => {}; const onStatus = typeof deps.onStatus === 'function' ? deps.onStatus : () => {}; + const onIdentityMismatch = typeof deps.onIdentityMismatch === 'function' ? deps.onIdentityMismatch : () => {}; let disposed = false; let generation = 0; let inFlight = null; @@ -232,13 +230,6 @@ cooldownUntil = now() + TRANSIENT_COOLDOWNS_MS[index]; publishStatus(); }; - const scheduleStructuralRefresh = (details) => { - if (structuralRefreshPending || disposed) return; - structuralRefreshPending = true; - Promise.resolve().then(() => requestStructuralRefresh(details)).finally(() => { - structuralRefreshPending = false; - }); - }; const run = (work) => { if (inFlight) return inFlight; const currentGeneration = ++generation; @@ -257,11 +248,7 @@ if (disposed || options.generation !== generation) return { applied: false, stale: true }; const merged = mergeProviderContainers(getRuntimeMap(), containers, capabilitySnapshot()); if (merged.structuralChanged) { - scheduleStructuralRefresh({ - reason: 'api-identity-set-changed', - providerOnlyCount: merged.providerOnlyCount, - runtimeOnlyCount: merged.runtimeOnlyCount - }); + onIdentityMismatch({ providerOnlyCount: merged.providerOnlyCount, runtimeOnlyCount: merged.runtimeOnlyCount, hostReloadRequested: false }); return { applied: false, structuralChanged: true, ...merged }; } if (merged.changed.length > 0) applyRuntimeMap(merged.runtimeMap, merged); @@ -311,7 +298,7 @@ const currentMap = getRuntimeMap(); const key = findRuntimeKey(entry, currentMap); if (!key) { - scheduleStructuralRefresh({ reason: 'api-target-identity-missing' }); + onIdentityMismatch({ targeted: true, providerOnlyCount: 1, runtimeOnlyCount: 0, hostReloadRequested: false }); return { applied: false, structuralChanged: true }; } const mergedEntry = mergeContainerIntoRuntimeEntry(currentMap[key], entry, capabilitySnapshot()); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.diagnostics.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.diagnostics.js index 31156872..483c2186 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.diagnostics.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.diagnostics.js @@ -4,12 +4,12 @@ ? globalThis : (typeof window !== 'undefined' ? window : {}); if (typeof module === 'object' && module.exports) { - module.exports = factory(fallbackWindow); + module.exports = factory(fallbackWindow, require('./docker.runtime.refresh-diagnostics.js')); return; } - root.FolderViewPlusDockerRuntimeDiagnostics = factory(fallbackWindow); + root.FolderViewPlusDockerRuntimeDiagnostics = factory(fallbackWindow, fallbackWindow.FolderViewPlusFoundationModules?.dockerRefreshDiagnostics); root.FolderViewPlusDockerRuntimeDiagnosticsModuleLoaded = true; -}(typeof window !== 'undefined' ? window : {}, function dockerRuntimeDiagnosticsFactory(fallbackWindow) { +}(typeof window !== 'undefined' ? window : {}, function dockerRuntimeDiagnosticsFactory(fallbackWindow, refreshDiagnosticsModule) { 'use strict'; const DOCKER_SUPPORT_BUNDLE_PAGE_STORAGE_KEY = 'fv.support.bundle.docker.page.v1'; const DOCKER_BULK_UPDATE_TRACE_STORAGE_KEY = 'fv.support.bundle.docker.bulkUpdateTrace.v1'; @@ -29,19 +29,7 @@ ? require('./docker.runtime.layout-geometry.js') : null); let activeLayoutStabilityTracker = null; - const buildOrderFingerprint = (order) => { - const input = (Array.isArray(order) ? order : []).map((entry) => String(entry || '')).join('\u001f'); - let hashA = 0x811c9dc5; - let hashB = 0x9e3779b9; - for (let index = 0; index < input.length; index++) { - const code = input.charCodeAt(index); - hashA ^= code; - hashA = Math.imul(hashA, 0x01000193) >>> 0; - hashB ^= code + index + 1; - hashB = Math.imul(hashB, 0x85ebca6b) >>> 0; - } - return `${hashA.toString(16).padStart(8, '0')}${hashB.toString(16).padStart(8, '0')}`; - }; + const buildOrderFingerprint = refreshDiagnosticsModule?.buildOrderFingerprint; const createLayoutStabilityTracker = (deps = {}) => { activeLayoutStabilityTracker?.destroy?.(); const win = deps.window || fallbackWindow; @@ -313,6 +301,11 @@ const getLayoutStabilityDiagnostics = typeof deps.getLayoutStabilityDiagnostics === 'function' ? deps.getLayoutStabilityDiagnostics : (() => ({ available: false })); + const getFolderGroupingDiagnostics = typeof deps.getFolderGroupingDiagnostics === 'function' ? deps.getFolderGroupingDiagnostics : (() => ({ available: false })); + const refreshDiagnostics = refreshDiagnosticsModule?.createTracker?.({ + window: win, + storage: localStorageRef + }) || null; const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); const measureBytes = (value) => { try { @@ -458,7 +451,6 @@ } return candidate; }; - const collectDockerAssetIdentity = () => { const entries = []; const pluginVersion = String(win?.FolderViewPlusFatalRuntimeContext?.pluginVersion || '').trim(); @@ -532,8 +524,8 @@ : {}; const failureCount = Number.isFinite(Number(previous.failureCount)) ? Number(previous.failureCount) : 0; const nextPayload = { - updatedAt: new Date().toISOString(), ...existing, + updatedAt: new Date().toISOString(), [safeTraceName]: { lastWriteAt: new Date().toISOString(), lastWriteSucceeded: success === true, @@ -860,6 +852,7 @@ listViewMode: currentListViewMode, correlation: normalizeCorrelationContext(getCorrelationContext()), layoutStability: cloneValue(getLayoutStabilityDiagnostics()) || { available: false }, + folderGrouping: cloneValue(getFolderGroupingDiagnostics()) || { available: false }, dockerAssets: collectDockerAssetIdentity(), topLevelRows: { count: topLevelRowCount, @@ -890,10 +883,10 @@ summary }; }; - const writeSupportBundleStorageRecord = (storageKey, value) => { const writeOk = writeStorageRecord(storageKey, value); if (storageKey === DOCKER_SUPPORT_BUNDLE_PAGE_STORAGE_KEY) { + refreshDiagnostics?.recordPageSnapshot?.(value); updateTraceHealth('pageSnapshot', writeOk, { reason: String(value?.reason || '').trim() || 'runtime-sync' }); @@ -920,15 +913,20 @@ updateTraceHealth, appendBulkUpdateTrace: (eventType, details = {}) => appendTrace(DOCKER_BULK_UPDATE_TRACE_STORAGE_KEY, 'bulkUpdateTrace', DOCKER_BULK_UPDATE_TRACE_LIMIT, eventType, details), - appendRequestBundleTrace: (eventType, details = {}) => - appendTrace(DOCKER_REQUEST_BUNDLE_TRACE_STORAGE_KEY, 'requestBundleTrace', DOCKER_REQUEST_BUNDLE_TRACE_LIMIT, eventType, details), + appendRequestBundleTrace: (eventType, details = {}) => { + refreshDiagnostics?.record?.(eventType, details); + return appendTrace(DOCKER_REQUEST_BUNDLE_TRACE_STORAGE_KEY, 'requestBundleTrace', DOCKER_REQUEST_BUNDLE_TRACE_LIMIT, eventType, details); + }, + markReloadSource: (source) => refreshDiagnostics?.markReloadSource?.(source), + recordApiMismatch: (details = {}) => refreshDiagnostics?.recordApiMismatch?.(details), collectPageSnapshot, queuePageSnapshot, getStorageKeys: () => ({ dockerPage: DOCKER_SUPPORT_BUNDLE_PAGE_STORAGE_KEY, dockerBulkUpdateTrace: DOCKER_BULK_UPDATE_TRACE_STORAGE_KEY, dockerRequestBundleTrace: DOCKER_REQUEST_BUNDLE_TRACE_STORAGE_KEY, - dockerTraceHealth: DOCKER_TRACE_HEALTH_STORAGE_KEY + dockerTraceHealth: DOCKER_TRACE_HEALTH_STORAGE_KEY, + dockerRefreshDiagnostics: refreshDiagnosticsModule?.STORAGE_KEY || 'fv.support.bundle.docker.refreshDiagnostics.v1' }) }; }; @@ -938,6 +936,7 @@ DOCKER_BULK_UPDATE_TRACE_STORAGE_KEY, DOCKER_REQUEST_BUNDLE_TRACE_STORAGE_KEY, DOCKER_TRACE_HEALTH_STORAGE_KEY, + DOCKER_REFRESH_DIAGNOSTICS_STORAGE_KEY: refreshDiagnosticsModule?.STORAGE_KEY || 'fv.support.bundle.docker.refreshDiagnostics.v1', buildOrderFingerprint, createLayoutStabilityTracker, createApi diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.folder-grouping.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.folder-grouping.js new file mode 100644 index 00000000..28b5d010 --- /dev/null +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.folder-grouping.js @@ -0,0 +1,259 @@ +// @ts-check +(function dockerRuntimeFolderGroupingModule(root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + return; + } + root.FolderViewPlusFoundationModules = root.FolderViewPlusFoundationModules || {}; + root.FolderViewPlusFoundationModules.dockerFolderGrouping = factory(); +}(typeof window !== 'undefined' ? window : {}, function dockerRuntimeFolderGroupingFactory() { + 'use strict'; + + const ENTRY_LIMIT = 64; + const normalizeToken = (value) => String(value || '').trim().replace(/^\/+/, ''); + const asObject = (value) => ( + value && typeof value === 'object' && !Array.isArray(value) ? value : {} + ); + const asArray = (value) => (Array.isArray(value) ? value : []); + const addUnique = (target, value) => { + const normalized = normalizeToken(value); + if (normalized && !target.includes(normalized)) target.push(normalized); + }; + const identityVariants = (value) => { + const normalized = normalizeToken(value); + if (!normalized) return []; + const variants = [normalized]; + const unprefixed = normalized.split(':').pop() || ''; + addUnique(variants, unprefixed); + if (/^[a-f0-9]{16,}$/i.test(unprefixed)) { + addUnique(variants, unprefixed.slice(0, 12)); + } + return variants; + }; + const buildIdentityIndex = (containersInfo = {}) => { + const index = new Map(); + const canonicalNames = new Set(); + const register = (token, canonicalName) => { + identityVariants(token).forEach((variant) => { + const existing = index.get(variant); + index.set(variant, existing && existing !== canonicalName ? null : canonicalName); + }); + }; + Object.entries(asObject(containersInfo)).forEach(([rawName, rawEntry]) => { + const canonicalName = normalizeToken(rawName); + if (!canonicalName) return; + canonicalNames.add(canonicalName); + const entry = asObject(rawEntry); + const info = asObject(entry.info); + [rawName, entry.name, entry.id, entry.shortId, info.Name, info.Id] + .forEach((token) => register(token, canonicalName)); + asArray(entry.names).forEach((token) => register(token, canonicalName)); + asArray(info.Names).forEach((token) => register(token, canonicalName)); + }); + const resolve = (value) => { + const normalized = normalizeToken(value); + if (!normalized) return ''; + if (canonicalNames.has(normalized)) return normalized; + for (const variant of identityVariants(normalized)) { + const resolved = index.get(variant); + if (resolved) return resolved; + } + if (/^[a-f0-9]{8,}$/i.test(normalized)) { + const matches = [...canonicalNames].filter((name) => { + const entry = asObject(containersInfo[name]); + const info = asObject(entry.info); + const id = normalizeToken(entry.id || info.Id).split(':').pop() || ''; + return id.startsWith(normalized); + }); + if (matches.length === 1) return matches[0]; + } + return ''; + }; + return Object.freeze({ resolve }); + }; + const readRowCandidates = (row) => { + const candidates = []; + const dataset = asObject(row?.dataset); + ['containerName', 'name', 'container', 'containerId', 'id'].forEach((key) => { + addUnique(candidates, dataset[key]); + }); + ['[data-container-name]', '[data-container-id]', 'td.ct-name .appname a', 'td.ct-name .appname'] + .forEach((selector) => { + const node = row?.querySelector?.(selector); + addUnique(candidates, node?.dataset?.containerName); + addUnique(candidates, node?.dataset?.containerId); + addUnique(candidates, node?.textContent); + }); + const rawId = String(row?.id || '').trim(); + addUnique(candidates, rawId.startsWith('ct-') ? rawId.slice(3) : rawId); + return candidates; + }; + const candidateCount = (matchCacheEntry, key) => asArray(matchCacheEntry?.[key]).length; + const clone = (value) => JSON.parse(JSON.stringify(value)); + + const createSession = (options = {}) => { + const doc = options.document || null; + const listRoot = options.listRoot || doc?.getElementById?.('docker_list') || null; + const selector = String(options.rowSelector || '#docker_list > tr.sortable:not(.folder), #docker_list tr.folder-element'); + const identityIndex = buildIdentityIndex(options.containersInfo); + const rowRecords = []; + const rowByCanonicalName = new Map(); + const claimedNames = new Set(); + const folderEntries = new Map(); + let resolvedRowCount = 0; + let unresolvedRowCount = 0; + let conflictingCandidateRowCount = 0; + let duplicateCanonicalRowCount = 0; + + Array.from(doc?.querySelectorAll?.(selector) || []).forEach((row) => { + if (row?.classList?.contains?.('folder')) return; + const candidates = readRowCandidates(row); + const resolvedCandidates = candidates.map(identityIndex.resolve).filter(Boolean); + const distinctResolved = [...new Set(resolvedCandidates)]; + const canonicalName = distinctResolved[0] || ''; + if (distinctResolved.length > 1) conflictingCandidateRowCount++; + if (canonicalName) { + resolvedRowCount++; + if (rowByCanonicalName.has(canonicalName)) duplicateCanonicalRowCount++; + else rowByCanonicalName.set(canonicalName, row); + } else { + unresolvedRowCount++; + } + rowRecords.push({ row, canonicalName, fallbackName: candidates[0] || '' }); + }); + + const beginFolder = (folderId, matchCacheEntry = {}, combinedCandidateCount = 0) => { + const key = String(folderId || ''); + if (!key || folderEntries.has(key)) return; + folderEntries.set(key, { + folderOrdinal: folderEntries.size, + explicitCandidateCount: candidateCount(matchCacheEntry, 'explicit'), + regexCandidateCount: candidateCount(matchCacheEntry, 'regex'), + labelCandidateCount: candidateCount(matchCacheEntry, 'label'), + ruleCandidateCount: candidateCount(matchCacheEntry, 'rules'), + combinedCandidateCount: Math.max(0, Number(combinedCandidateCount) || 0), + claimedRowCount: 0, + missingRowCount: 0, + renderedMemberCount: 0, + shellInserted: false, + shellInsertionStrategy: 'none', + removedByHideEmpty: false + }); + }; + const isDirectListRow = (row) => !!listRoot && !!row && ( + row.parentElement === listRoot || row.parentNode === listRoot + ); + const resolveOrderRow = (identity) => { + const token = normalizeToken(identity); + if (!token || !listRoot) return null; + if (token.startsWith('folder-')) { + const folderId = token.slice(7); + return Array.from(listRoot.children || []).find((row) => ( + normalizeToken(row?.dataset?.fvFolderId) === folderId + )) || null; + } + const canonicalName = identityIndex.resolve(token) || token; + return rowByCanonicalName.get(canonicalName) || null; + }; + const insertFolderRow = (folderId, folderRow, position, liveOrder = []) => { + const entry = folderEntries.get(String(folderId || '')); + const order = asArray(liveOrder); + const resolvedPosition = Math.max(0, Math.min(order.length, Math.floor(Number(position) || 0))); + let strategy = 'failed'; + if (listRoot && folderRow && typeof listRoot.insertBefore === 'function') { + for (let index = resolvedPosition + 1; index < order.length; index++) { + const anchor = resolveOrderRow(order[index]); + if (!isDirectListRow(anchor) || anchor === folderRow) continue; + listRoot.insertBefore(folderRow, anchor); + strategy = 'before-next'; + break; + } + for (let index = resolvedPosition - 1; strategy === 'failed' && index >= 0; index--) { + const anchor = resolveOrderRow(order[index]); + if (!isDirectListRow(anchor) || anchor === folderRow) continue; + listRoot.insertBefore(folderRow, anchor.nextSibling || null); + strategy = 'after-previous'; + } + if (strategy === 'failed' && typeof listRoot.appendChild === 'function') { + listRoot.appendChild(folderRow); + strategy = 'append'; + } + } + const inserted = isDirectListRow(folderRow); + if (entry) { + entry.shellInserted = inserted; + entry.shellInsertionStrategy = inserted ? strategy : 'failed'; + } + return Object.freeze({ inserted, strategy: inserted ? strategy : 'failed' }); + }; + const claim = (folderId, requestedIdentity) => { + const canonicalName = identityIndex.resolve(requestedIdentity) || normalizeToken(requestedIdentity); + const entry = folderEntries.get(String(folderId || '')); + const row = rowByCanonicalName.get(canonicalName) || null; + const available = row && !claimedNames.has(canonicalName) && row.isConnected !== false; + if (available) { + claimedNames.add(canonicalName); + if (entry) entry.claimedRowCount++; + return row; + } + if (entry) entry.missingRowCount++; + return null; + }; + const finishFolder = (folderId, details = {}) => { + const entry = folderEntries.get(String(folderId || '')); + if (!entry) return; + entry.renderedMemberCount = Math.max(0, Number(details.renderedMemberCount) || 0); + entry.removedByHideEmpty = details.removedByHideEmpty === true; + }; + const snapshot = () => { + const entries = [...folderEntries.values()]; + const totals = entries.reduce((result, entry) => ({ + combinedCandidateCount: result.combinedCandidateCount + entry.combinedCandidateCount, + claimedRowCount: result.claimedRowCount + entry.claimedRowCount, + missingRowCount: result.missingRowCount + entry.missingRowCount, + renderedMemberCount: result.renderedMemberCount + entry.renderedMemberCount, + insertedShellCount: result.insertedShellCount + (entry.shellInserted ? 1 : 0), + failedShellCount: result.failedShellCount + (entry.shellInserted ? 0 : 1), + fallbackShellCount: result.fallbackShellCount + ( + entry.shellInserted && entry.shellInsertionStrategy !== 'before-next' ? 1 : 0 + ), + removedByHideEmptyCount: result.removedByHideEmptyCount + (entry.removedByHideEmpty ? 1 : 0) + }), { + combinedCandidateCount: 0, + claimedRowCount: 0, + missingRowCount: 0, + renderedMemberCount: 0, + insertedShellCount: 0, + failedShellCount: 0, + fallbackShellCount: 0, + removedByHideEmptyCount: 0 + }); + return clone({ + schemaVersion: 1, + hostRows: { + total: rowRecords.length, + resolved: resolvedRowCount, + unresolved: unresolvedRowCount, + conflictingCandidates: conflictingCandidateRowCount, + duplicateCanonical: duplicateCanonicalRowCount + }, + folders: { + total: entries.length, + truncated: entries.length > ENTRY_LIMIT, + ...totals, + entries: entries.slice(0, ENTRY_LIMIT) + } + }); + }; + return Object.freeze({ + readOrder: () => rowRecords.map((entry) => entry.canonicalName || entry.fallbackName).filter(Boolean), + beginFolder, + insertFolderRow, + claim, + finishFolder, + snapshot + }); + }; + + return Object.freeze({ normalizeToken, buildIdentityIndex, readRowCandidates, createSession }); +})); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.host-guards.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.host-guards.js index 6197a9b1..06a77f96 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.host-guards.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.host-guards.js @@ -76,6 +76,17 @@ return missing; }; + const isNativeBusyPollActive = () => { + const busy = doc && typeof doc.querySelector === 'function' + ? doc.querySelector('#busy') + : null; + if (!busy || busy.hidden === true || busy.getAttribute?.('aria-hidden') === 'true') return false; + const style = typeof win.getComputedStyle === 'function' + ? win.getComputedStyle(busy) + : busy.style; + return style?.display !== 'none' && style?.visibility !== 'hidden'; + }; + const ensureHostPageStructure = () => { const missing = collectHostPageStructureIssues(); if (missing.length <= 0) { @@ -211,6 +222,7 @@ adapter: hostAdapter, ensureHostPageStructure, collectHostPageStructureIssues, + isNativeBusyPollActive, captureHostHook, reportMissingHook, noteHookWrapped, diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.refresh-diagnostics.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.refresh-diagnostics.js new file mode 100644 index 00000000..b8e72458 --- /dev/null +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.refresh-diagnostics.js @@ -0,0 +1,262 @@ +// @ts-check +(function dockerRefreshDiagnosticsModule(root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + return; + } + root.FolderViewPlusFoundationModules = root.FolderViewPlusFoundationModules || {}; + root.FolderViewPlusFoundationModules.dockerRefreshDiagnostics = factory(); +}(typeof window !== 'undefined' ? window : {}, function dockerRefreshDiagnosticsFactory() { + 'use strict'; + + const STORAGE_KEY = 'fv.support.bundle.docker.refreshDiagnostics.v1'; + const SESSION_LIMIT = 5; + const STORAGE_MAX_BYTES = 24576; + const SOURCE_VALUES = Object.freeze([ + 'initial-bootstrap', + 'manual-host-refresh', + 'plugin-config-revision', + 'plugin-action-followup', + 'unraid-native-busy-poll', + 'host-dialog-callback', + 'unknown-host-caller' + ]); + const SOURCE_SET = new Set(SOURCE_VALUES); + const countTemplate = () => ({ loadlist: 0, listview: 0, renders: 0, requests: 0, busyPasses: 0, foldersRestored: 0 }); + const sourceTemplate = () => Object.fromEntries(SOURCE_VALUES.map((source) => [source, 0])); + const safeNumber = (value) => Math.max(0, Number.isFinite(Number(value)) ? Number(value) : 0); + const normalizeSource = (value) => SOURCE_SET.has(String(value || '').trim()) + ? String(value || '').trim() + : 'unknown-host-caller'; + const iso = (value) => new Date(value).toISOString(); + const buildOrderFingerprint = (order) => { + const input = (Array.isArray(order) ? order : []).map((entry) => String(entry || '')).join('\u001f'); + let hashA = 0x811c9dc5; + let hashB = 0x9e3779b9; + for (let index = 0; index < input.length; index++) { + const code = input.charCodeAt(index); + hashA ^= code; + hashA = Math.imul(hashA, 0x01000193) >>> 0; + hashB ^= code + index + 1; + hashB = Math.imul(hashB, 0x85ebca6b) >>> 0; + } + return `${hashA.toString(16).padStart(8, '0')}${hashB.toString(16).padStart(8, '0')}`; + }; + const summarizeCadence = (timestamps = []) => { + const gaps = timestamps.slice(1).map((value, index) => Math.max(0, value - timestamps[index])); + if (gaps.length === 0) return { sampleCount: 0, averageMs: null, minimumMs: null, maximumMs: null }; + return { + sampleCount: gaps.length, + averageMs: Math.round(gaps.reduce((sum, value) => sum + value, 0) / gaps.length), + minimumMs: Math.min(...gaps), + maximumMs: Math.max(...gaps) + }; + }; + const dominantSource = (events) => { + const counts = sourceTemplate(); + events.forEach((event) => { counts[normalizeSource(event.source)] += 1; }); + return SOURCE_VALUES.reduce((winner, source) => counts[source] > counts[winner] ? source : winner, 'unknown-host-caller'); + }; + const buildVerdict = (reloadEvents = [], nowMs = Date.now()) => { + const unattended = reloadEvents.filter((event) => !['manual-host-refresh', 'host-dialog-callback'].includes(event.source)); + const within90 = unattended.filter((event) => nowMs - event.atMs <= 90000); + const within60 = within90.filter((event) => nowMs - event.atMs <= 60000); + const status = within90.length >= 6 ? 'confirmed' : (within60.length >= 3 ? 'suspected' : 'healthy'); + const evidence = status === 'confirmed' ? within90 : within60; + return { + status, + source: evidence.length > 0 ? dominantSource(evidence) : null, + fullReloads: evidence.length, + renders: 0, + cadence: summarizeCadence(evidence.map((event) => event.atMs)), + reference: 'FVPLUS-DKR-REFRESH-001' + }; + }; + const createSession = (nowMs) => ({ + startedAt: iso(nowMs), + lastEventAt: iso(nowMs), + counts: countTemplate(), + reloadSources: sourceTemplate(), + reloadEvents: [], + lastRenderGeneration: null, + lastFolderCount: null, + nativeBusy: { cycleCount: 0, passCount: 0, firstSeenAt: null, lastSeenAt: null, durationMs: 0, cleared: false, foldersRestored: false, active: false } + }); + const sessionSummary = (session, nowMs, exitReason = null) => { + if (!session) return null; + const startedMs = Date.parse(session.startedAt); + const endedAt = exitReason ? iso(nowMs) : null; + const endMs = nowMs; + const verdict = buildVerdict(session.reloadEvents || [], nowMs); + verdict.renders = safeNumber(session.counts?.renders); + return { + startedAt: session.startedAt, + endedAt, + durationMs: Math.max(0, endMs - startedMs), + exitReason, + counts: { ...countTemplate(), ...(session.counts || {}) }, + reloadSources: { ...sourceTemplate(), ...(session.reloadSources || {}) }, + cadence: summarizeCadence((session.reloadEvents || []).map((event) => event.atMs)), + lastFolderCount: Number.isFinite(Number(session.lastFolderCount)) ? Number(session.lastFolderCount) : null, + nativeBusy: { ...createSession(nowMs).nativeBusy, ...(session.nativeBusy || {}) }, + verdict + }; + }; + const createTracker = (deps = {}) => { + const win = deps.window || null; + const storage = deps.storage || win?.localStorage || null; + const now = typeof deps.now === 'function' ? deps.now : Date.now; + const readStored = () => { + try { + const parsed = JSON.parse(String(storage?.getItem(STORAGE_KEY) || '{}')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (_error) { + return {}; + } + }; + const stored = readStored(); + let completedSessions = Array.isArray(stored.completedSessions) ? stored.completedSessions.slice(-SESSION_LIMIT) : []; + if (stored.currentSession && typeof stored.currentSession === 'object') { + completedSessions.push({ ...stored.currentSession, endedAt: iso(now()), exitReason: 'superseded-by-new-session' }); + completedSessions = completedSessions.slice(-SESSION_LIMIT); + } + let session = createSession(now()); + let pendingSource = null; + let pendingSourceUntil = 0; + let disposed = false; + const apiMismatch = { + observedCount: safeNumber(stored.apiMismatch?.observedCount), + providerOnlyCount: safeNumber(stored.apiMismatch?.providerOnlyCount), + runtimeOnlyCount: safeNumber(stored.apiMismatch?.runtimeOnlyCount), + firstSeenAt: stored.apiMismatch?.firstSeenAt || null, + lastSeenAt: stored.apiMismatch?.lastSeenAt || null, + policy: 'native-structure-authoritative', + hostReloadRequested: false + }; + const aggregate = () => { + const sessions = [...completedSessions, sessionSummary(session, now())].filter(Boolean); + const totals = countTemplate(); + const reloadSources = sourceTemplate(); + sessions.forEach((entry) => { + Object.keys(totals).forEach((key) => { totals[key] += safeNumber(entry.counts?.[key]); }); + SOURCE_VALUES.forEach((source) => { reloadSources[source] += safeNumber(entry.reloadSources?.[source]); }); + }); + const currentSession = sessionSummary(session, now()); + return { + schemaVersion: 1, + updatedAt: iso(now()), + policy: { structureAuthority: 'native-config-revision', completedSessionLimit: SESSION_LIMIT }, + verdict: currentSession?.verdict || completedSessions.at(-1)?.verdict || buildVerdict([], now()), + totals, + reloadSources, + apiMismatch, + nativeBusy: currentSession?.nativeBusy || completedSessions.at(-1)?.nativeBusy || createSession(now()).nativeBusy, + completedSessionCount: completedSessions.length, + completedSessions, + currentSession + }; + }; + const write = () => { + try { + const payload = aggregate(); + const serialized = JSON.stringify(payload); + storage?.setItem(STORAGE_KEY, serialized.length <= STORAGE_MAX_BYTES ? serialized : JSON.stringify({ + ...payload, + completedSessions: payload.completedSessions.slice(-2), + completedSessionCount: payload.completedSessions.length, + storageTrimmed: true + })); + return true; + } catch (_error) { + return false; + } + }; + const touch = () => { session.lastEventAt = iso(now()); }; + const recordBusy = () => { + const busy = session.nativeBusy; + const nowMs = now(); + if (!busy.active) { + busy.active = true; + busy.cycleCount += 1; + busy.firstSeenAt = iso(nowMs); + busy.cleared = false; + busy.foldersRestored = false; + } + busy.passCount += 1; + busy.lastSeenAt = iso(nowMs); + busy.durationMs = Math.max(0, nowMs - Date.parse(busy.firstSeenAt)); + session.counts.busyPasses += 1; + }; + const record = (eventType, details = {}) => { + if (disposed || !session) return false; + const type = String(eventType || '').trim(); + const safeDetails = details && typeof details === 'object' && !Array.isArray(details) ? details : {}; + const nowMs = now(); + touch(); + if (type === 'loadlist') { + const busy = safeDetails.nativeBusyActive === true; + const source = busy + ? 'unraid-native-busy-poll' + : (pendingSource && nowMs <= pendingSourceUntil + ? pendingSource + : (session.counts.loadlist === 0 ? 'initial-bootstrap' : normalizeSource(safeDetails.reloadSource))); + pendingSource = null; + session.counts.loadlist += 1; + session.reloadSources[source] += 1; + session.reloadEvents.push({ atMs: nowMs, source }); + session.reloadEvents = session.reloadEvents.slice(-20); + if (busy) recordBusy(); + else if (session.nativeBusy.active) { + session.nativeBusy.active = false; + session.nativeBusy.cleared = true; + session.nativeBusy.durationMs = Math.max(0, nowMs - Date.parse(session.nativeBusy.firstSeenAt)); + } + } else if (type === 'listview') session.counts.listview += 1; + else if (type === 'buildDockerFolderReq') session.counts.requests += 1; + else if (type === 'host-list-restored') session.counts.foldersRestored += 1; + return write(); + }; + const recordPageSnapshot = (snapshot = {}) => { + if (disposed || !session) return false; + const generation = safeNumber(snapshot?.correlation?.renderGeneration); + if (generation <= 0 || generation === session.lastRenderGeneration) return false; + session.lastRenderGeneration = generation; + session.counts.renders += 1; + session.lastFolderCount = safeNumber(snapshot?.folderRows?.count); + if (session.nativeBusy.cleared && !session.nativeBusy.foldersRestored) { + session.nativeBusy.foldersRestored = true; + session.counts.foldersRestored += 1; + } + touch(); + return write(); + }; + const recordApiMismatch = (details = {}) => { + const at = iso(now()); + apiMismatch.observedCount += 1; + apiMismatch.providerOnlyCount += safeNumber(details.providerOnlyCount); + apiMismatch.runtimeOnlyCount += safeNumber(details.runtimeOnlyCount ?? (details.targeted === true ? 1 : 0)); + apiMismatch.firstSeenAt = apiMismatch.firstSeenAt || at; + apiMismatch.lastSeenAt = at; + return write(); + }; + const markReloadSource = (source) => { + pendingSource = normalizeSource(source); + pendingSourceUntil = now() + 15000; + }; + const dispose = (exitReason = 'pagehide') => { + if (disposed) return; + disposed = true; + completedSessions.push(sessionSummary(session, now(), String(exitReason || 'pagehide'))); + completedSessions = completedSessions.slice(-SESSION_LIMIT); + session = null; + write(); + win?.removeEventListener?.('pagehide', onPageHide); + }; + const onPageHide = () => dispose('pagehide'); + win?.addEventListener?.('pagehide', onPageHide, { once: true }); + write(); + return Object.freeze({ record, recordPageSnapshot, recordApiMismatch, markReloadSource, snapshot: aggregate, dispose }); + }; + + return Object.freeze({ STORAGE_KEY, SESSION_LIMIT, SOURCE_VALUES, buildOrderFingerprint, buildVerdict, createTracker }); +})); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.activity-diagnostics.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.activity-diagnostics.js index e0846039..1de4a508 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.activity-diagnostics.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.activity-diagnostics.js @@ -645,7 +645,6 @@ const normalizeSupportBundleV2Payload = (bundle, privacy = 'sanitized') => { ) ? { ...payload.system.request } : {}; return payload; }; - const getSupportBundleTelemetryApi = () => { if (!supportBundleTelemetryApi && supportBundleTelemetryModule && typeof supportBundleTelemetryModule.createApi === 'function') { supportBundleTelemetryApi = supportBundleTelemetryModule.createApi({ @@ -672,6 +671,7 @@ const getSupportBundleTelemetryApi = () => { dockerBulkUpdateTrace: 'fv.support.bundle.docker.bulkUpdateTrace.v1', dockerRequestBundleTrace: 'fv.support.bundle.docker.requestBundleTrace.v1', dockerTraceHealth: 'fv.support.bundle.docker.traceHealth.v1', + dockerRefreshDiagnostics: 'fv.support.bundle.docker.refreshDiagnostics.v1', dashboardLayoutDocker: 'fv.support.bundle.dashboard.layout.docker.v1', dashboardLayoutVm: 'fv.support.bundle.dashboard.layout.vm.v1', dashboardVisualDocker: 'fv.support.bundle.dashboard.visual.docker.v1', diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.csp-events.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.csp-events.js index 96fc7464..973ced57 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.csp-events.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.csp-events.js @@ -34,7 +34,6 @@ const supportedEvents = ['click', 'change', 'input', 'keydown', 'submit', 'error']; const actionRecords = new Map(); const actionNamePattern = /^[A-Za-z_$][\w$]*$/; - const normalizeOwner = (value) => { const owner = String(value || '').trim(); if (!owner) { @@ -204,6 +203,7 @@ if (!fallback.startsWith('/') || fallback.startsWith('//')) { throw new Error('Unsafe image fallback path.'); } + win?.FolderViewPlusFoundationModules?.imageFallbacks?.record?.(element?.getAttribute?.('src')); if (element?.dataset?.fvplusFallbackApplied === 'true' || element?.getAttribute?.('src') === fallback) { return { returned: false, value: undefined }; } diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.fatal-banner.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.fatal-banner.js index 2c20c9e8..80ba31a4 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.fatal-banner.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.fatal-banner.js @@ -23,7 +23,7 @@ const DIAGNOSTIC_REQUEST_LIMIT = 16; const DIAGNOSTIC_STEP_LIMIT = 10; const DIAGNOSTIC_ACTION_LIMIT = 10; - const BROWSER_ERROR_LIMIT = 30; + const BROWSER_ERROR_LIMIT = 200; const MODULE_EVENT_LIMIT = 80; const RECOVERY_ATTEMPT_LIMIT = 8; const browserErrorSessionStartedAt = new Date().toISOString(); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-browser.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-browser.js index 1f5dde2a..692b30c9 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-browser.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-browser.js @@ -7,7 +7,8 @@ root.FolderViewPlusSupportBundleBrowserModuleLoaded = true; }(typeof globalThis !== 'undefined' ? globalThis : this, function(root) { const CONSOLE_ERROR_STORAGE_KEY = 'fv.support.bundle.consoleErrors.v1'; - const CONSOLE_ERROR_LIMIT = 30; + const CONSOLE_ERROR_LIMIT = 200; + const CONSOLE_ERROR_RECENT_MS = 30 * 24 * 60 * 60 * 1000; const DASHBOARD_VISUAL_STALE_AFTER_MS = 30 * 60 * 1000; const clientStorageIsAvailable = (kind) => { try { @@ -217,7 +218,7 @@ entries: Array.isArray(fallbackStorage) ? fallbackStorage : [] }; const sessionId = String(snapshot.sessionId || '').trim(); - const entries = (Array.isArray(snapshot.entries) ? snapshot.entries : []) + const retainedEntries = (Array.isArray(snapshot.entries) ? snapshot.entries : []) .slice(-CONSOLE_ERROR_LIMIT) .map((entry) => { const safeEntry = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; @@ -228,15 +229,29 @@ currentSession: safeEntry.currentSession === true || Boolean(sessionId && entrySessionId === sessionId) }; }); - const timestamps = entries + const timestamps = retainedEntries .map((entry) => String(entry.at || '').trim()) .filter(Boolean) .sort(); - const currentSessionCount = entries.filter((entry) => entry.currentSession === true).length; + const currentSessionCount = retainedEntries.filter((entry) => entry.currentSession === true).length; + const nowMs = Number.isFinite(Number(options.nowMs)) ? Number(options.nowMs) : Date.now(); + const recentCutoff = nowMs - CONSOLE_ERROR_RECENT_MS; + const entries = retainedEntries.filter((entry) => entry.currentSession === true || Date.parse(String(entry.at || '')) >= recentCutoff); + const olderEntries = retainedEntries.filter((entry) => { + const observedAt = Date.parse(String(entry.at || '')); + return entry.currentSession !== true && Number.isFinite(observedAt) && observedAt < recentCutoff; + }); + const olderByCategory = {}; + olderEntries.forEach((entry) => { + const category = String(entry.category || 'unknown').trim() || 'unknown'; + olderByCategory[category] = Math.max(0, Number(olderByCategory[category]) || 0) + 1; + }); + const unknownAgeCount = retainedEntries.filter((entry) => !Number.isFinite(Date.parse(String(entry.at || '')))).length; return { storageKey: String(snapshot.storageKey || CONSOLE_ERROR_STORAGE_KEY), maxEntries: Number.isFinite(Number(snapshot.maxEntries)) ? Number(snapshot.maxEntries) : CONSOLE_ERROR_LIMIT, count: entries.length, + retainedCount: retainedEntries.length, collectionPluginVersion: String( snapshot.collectionPluginVersion || options.pluginVersion || 'unknown' ).trim() || 'unknown', @@ -245,7 +260,12 @@ sessionId: sessionId || null, sessionStartedAt: String(snapshot.sessionStartedAt || '').trim() || null, currentSessionCount, - historicalCount: Math.max(0, entries.length - currentSessionCount), + last30DaysCount: entries.filter((entry) => entry.currentSession !== true).length, + historicalCount: Math.max(0, retainedEntries.length - currentSessionCount), + olderCount: olderEntries.length, + unknownAgeCount, + olderByCategory, + detailsScope: 'current-session-and-last-30-days', entries }; }; @@ -320,6 +340,16 @@ ...record }); }; + const collectDockerRefreshDiagnostics = (uiRedactor) => { + const record = readClientDiagnosticsStorageRecord(storageKeys.dockerRefreshDiagnostics || ''); + if (!record || typeof record !== 'object' || Array.isArray(record)) { + return { available: false }; + } + return sanitizeUiRecord(uiRedactor, 'uiTelemetry.dockerDiagnostics.refreshDiagnostics', 'refreshDiagnostics', { + available: true, + ...record + }); + }; const collectDashboardLayoutDiagnostics = (uiRedactor) => { const collectType = (type, storageKey) => { @@ -491,6 +521,7 @@ collectDockerBulkUpdateTrace, collectDockerRequestBundleTrace, collectDockerTraceHealth, + collectDockerRefreshDiagnostics, collectDashboardLayoutDiagnostics, collectDashboardVisualDiagnostics, collectRuntimePageDiagnostics, @@ -504,6 +535,7 @@ clientStorageIsAvailable, CONSOLE_ERROR_STORAGE_KEY, CONSOLE_ERROR_LIMIT, + CONSOLE_ERROR_RECENT_MS, DASHBOARD_VISUAL_STALE_AFTER_MS }); })); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-telemetry.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-telemetry.js index 3091112c..e1f3c8b9 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-telemetry.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-telemetry.js @@ -382,7 +382,7 @@ const collectLoadedAssetTelemetry = browserCollectors?.collectLoadedAssetTelemetry || (() => ({ count: 0, entries: [] })); const collectBrowserConsoleErrors = browserCollectors?.collectBrowserConsoleErrors || (() => ({ storageKey: 'fv.support.bundle.consoleErrors.v1', - maxEntries: 30, + maxEntries: 200, count: 0, entries: [] })); @@ -551,7 +551,8 @@ pageSnapshot: collectDockerPageDiagnostics(uiRedactor), bulkUpdateTrace: collectDockerBulkUpdateTrace(uiRedactor), requestBundleTrace: collectDockerRequestBundleTrace(uiRedactor), - traceHealth: collectDockerTraceHealth(uiRedactor) + traceHealth: collectDockerTraceHealth(uiRedactor), + refreshDiagnostics: browserCollectors?.collectDockerRefreshDiagnostics?.(uiRedactor) || { available: false } }; existingUiTelemetry.dashboardLayout = collectDashboardLayoutDiagnostics(uiRedactor); existingUiTelemetry.dashboardVisual = collectDashboardVisualDiagnostics(uiRedactor, { diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-foundation.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-foundation.js index 4c331be2..a09ddddf 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-foundation.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-foundation.js @@ -285,9 +285,7 @@ const sanitizeImageUrl = (value, fallback = '/plugins/dynamix.docker.manager/images/question.png') => { const raw = String(value || '').trim(); - if (!raw) { - return fallback; - } + if (!raw || globalThis.FolderViewPlusFoundationModules?.imageFallbacks?.has?.(raw)) return fallback; if (raw.startsWith('/') && !raw.startsWith('//')) { return raw; } diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.image-fallbacks.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.image-fallbacks.js new file mode 100644 index 00000000..4c319d3b --- /dev/null +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.image-fallbacks.js @@ -0,0 +1,39 @@ +(function(root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + return; + } + const modules = root.FolderViewPlusFoundationModules || {}; + modules.imageFallbacks = factory(); + root.FolderViewPlusFoundationModules = modules; +}(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this), function() { + 'use strict'; + const MAX_FAILED_SOURCES = 128; + const failedSources = new Set(); + const normalize = (value) => { + const raw = String(value || '').trim(); + if (!raw || raw.length > 2048 || /^data:/i.test(raw)) return ''; + if (raw.startsWith('/') && !raw.startsWith('//')) return raw; + try { + const parsed = new URL(raw); + return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password + ? parsed.href + : ''; + } catch (_error) { + return ''; + } + }; + const has = (value) => { + const key = normalize(value); + return key ? failedSources.has(key) : false; + }; + const record = (value) => { + const key = normalize(value); + if (!key || failedSources.has(key)) return false; + if (failedSources.size >= MAX_FAILED_SOURCES) failedSources.delete(failedSources.values().next().value); + failedSources.add(key); + return true; + }; + const snapshot = () => Object.freeze({ count: failedSources.size, limit: MAX_FAILED_SOURCES }); + return Object.freeze({ has, record, snapshot }); +})); diff --git a/tests/architecture-boundaries.test.mjs b/tests/architecture-boundaries.test.mjs index 075b5e55..3205283e 100644 --- a/tests/architecture-boundaries.test.mjs +++ b/tests/architecture-boundaries.test.mjs @@ -15,13 +15,13 @@ test('all FolderView Plus page and loader script graphs are contract-guarded', ( assert.deepEqual(result.failures, []); assert.equal(result.pageCount, 5); assert.equal(result.sourceCount, 8); - assert.equal(result.referenceCount, 285); + assert.equal(result.referenceCount, 290); }); test('entrypoints and contracted modules declare ownership boundaries', () => { assert.equal(architecture.schemaVersion, 2); assert.equal(architecture.entrypointContracts.length, 9); - assert.equal(architecture.moduleContracts.length, 47); + assert.equal(architecture.moduleContracts.length, 50); assert.equal(architecture.serverModuleContracts.length, 28); const allowedConsumers = new Set(architecture.consumerScopes); for (const contract of [...architecture.entrypointContracts, ...architecture.moduleContracts, ...architecture.serverModuleContracts]) { diff --git a/tests/browser/cases/docker.mjs b/tests/browser/cases/docker.mjs index 9c85854b..5facb9f8 100644 --- a/tests/browser/cases/docker.mjs +++ b/tests/browser/cases/docker.mjs @@ -27,6 +27,27 @@ test('Docker preview hydration and cached-width bootstrap preserve first-frame g assert.equal(result.settledWidth, result.firstVisibleWidth); }); +test('Docker late wizard folders claim canonical host rows before hide-empty filtering', async ({ page }) => { + await page.goto(`${baseUrl}/docker-folder-grouping`, { waitUntil: 'load' }); + const result = await page.evaluate(() => window.fixtureDockerFolderGrouping.result); + assert.equal(result.hidden.order.length, 18); + assert.equal(result.hidden.folderBoxes, 6); + assert.deepEqual(result.hidden.folderOrder, ['folder-3', 'folder-8', 'folder-21', 'folder-22', 'folder-23', 'folder-24']); + assert.equal(result.hidden.groupedMembers, 17); + assert.equal(result.hidden.standaloneMembers, 1); + assert.equal(result.hidden.snapshot.hostRows.resolved, 18); + assert.equal(result.hidden.snapshot.folders.total, 25); + assert.equal(result.hidden.snapshot.folders.claimedRowCount, 17); + assert.equal(result.hidden.snapshot.folders.missingRowCount, 0); + assert.equal(result.hidden.snapshot.folders.insertedShellCount, 25); + assert.equal(result.hidden.snapshot.folders.failedShellCount, 0); + assert.equal(result.hidden.snapshot.folders.removedByHideEmptyCount, 19); + assert.equal(result.shown.folderBoxes, 25); + assert.equal(result.shown.groupedMembers, 17); + assert.deepEqual(result.repeatedHidden.folderOrder, result.hidden.folderOrder); + assert.equal(result.repeatedHidden.snapshot.folders.failedShellCount, 0); +}); + test('Docker single-row preview cloning falls back without stopping later members', async ({ page }) => { await page.goto(`${baseUrl}/docker-layout-stability`, { waitUntil: 'load' }); const result = await page.evaluate(() => window.fixtureDockerPreviewCloneResilience.run()); diff --git a/tests/browser/cases/foundation.mjs b/tests/browser/cases/foundation.mjs index c00e6a91..be030b91 100644 --- a/tests/browser/cases/foundation.mjs +++ b/tests/browser/cases/foundation.mjs @@ -8,6 +8,7 @@ test('CSP and Trusted Types fixture renders malicious persisted values as inert injection: window.__fvInjection, rejected: window.__fvUnsafeAttributeRejected, trustedTypesAvailable: window.__fvTrustedTypesAvailable, + imageFallback: window.__fvImageFallbackEvidence, values: [...document.querySelectorAll('.csp-malicious-value')].map((node) => ({ kind: node.getAttribute('data-value-kind'), text: node.textContent, @@ -18,15 +19,13 @@ test('CSP and Trusted Types fixture renders malicious persisted values as inert assert.equal(evidence.rejected, true); assert.equal(evidence.values.length, 6); assert.equal(evidence.values.every((entry) => entry.childElements === 0), true); - assert.deepEqual(evidence.values.map((entry) => entry.kind), [ - 'folderName', - 'containerName', - 'templateMetadata', - 'importedConfiguration', - 'translation', - 'diagnostics' - ]); -}, { skipAccessibility: true }); + assert.deepEqual(evidence.values.map((entry) => entry.kind), ['folderName', 'containerName', 'templateMetadata', 'importedConfiguration', 'translation', 'diagnostics']); + assert.equal(evidence.imageFallback.replacementSource, '/plugin/images/folder-icon.png'); + assert.equal(evidence.imageFallback.missingRequests, 1, 'a failed icon URL is not requested again after a later render'); +}, { + skipAccessibility: true, + allowedConsoleErrors: [/Failed to load resource:.*404 \(Not Found\)/] +}); test('Shared UI primitives provide accessible modal, action, status, and progress behavior', async ({ page }) => { await page.goto(`${baseUrl}/ui-primitives`, { waitUntil: 'load' }); diff --git a/tests/browser/fixtures/csp-hardening.fixture.js b/tests/browser/fixtures/csp-hardening.fixture.js index 6790ad73..8d599538 100644 --- a/tests/browser/fixtures/csp-hardening.fixture.js +++ b/tests/browser/fixtures/csp-hardening.fixture.js @@ -28,5 +28,22 @@ } catch (error) { window.__fvUnsafeAttributeRejected = error instanceof TypeError; } + const missingIcon = '/missing-container-icon.png'; + const fallbackIcon = '/plugin/images/folder-icon.png'; + const loadImage = (source, onerror = '') => new Promise((resolve) => { + const image = document.createElement('img'); + image.addEventListener('load', resolve, { once: true }); + if (onerror) image.setAttribute('data-fv-onerror', onerror); + document.body.append(image); + image.src = source; + }); + await loadImage(missingIcon, `this.src='${fallbackIcon}'`); + const replacementSource = window.FolderViewPlusFoundationModules.utilityFoundation.sanitizeImageUrl(missingIcon, fallbackIcon); + await loadImage(replacementSource); + window.__fvImageFallbackEvidence = { + replacementSource, + missingRequests: performance.getEntriesByType('resource') + .filter((entry) => new URL(entry.name).pathname === missingIcon).length + }; window.__fvCspFixtureReady = true; })(); diff --git a/tests/browser/fixtures/csp-hardening.html b/tests/browser/fixtures/csp-hardening.html index 305e3aa1..d6115fa3 100644 --- a/tests/browser/fixtures/csp-hardening.html +++ b/tests/browser/fixtures/csp-hardening.html @@ -7,6 +7,9 @@
+ + + diff --git a/tests/browser/fixtures/docker-folder-grouping.html b/tests/browser/fixtures/docker-folder-grouping.html new file mode 100644 index 00000000..e8346789 --- /dev/null +++ b/tests/browser/fixtures/docker-folder-grouping.html @@ -0,0 +1,101 @@ + + + + + + FolderView Plus Docker folder grouping fixture + + + + + +
Container
+ + + + + diff --git a/tests/csp-readiness.test.mjs b/tests/csp-readiness.test.mjs index ce81696a..4d971a8f 100644 --- a/tests/csp-readiness.test.mjs +++ b/tests/csp-readiness.test.mjs @@ -8,6 +8,7 @@ const repoRoot = path.resolve(process.cwd()); const pluginRoot = path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus'); const bridgePath = path.join(pluginRoot, 'scripts/folderviewplus.csp-events.js'); const bridgeSource = fs.readFileSync(bridgePath, 'utf8'); +const imageFallbackSource = fs.readFileSync(path.join(pluginRoot, 'scripts/runtime.image-fallbacks.js'), 'utf8'); const sourceFiles = []; const walk = (directory) => { @@ -43,7 +44,8 @@ const createBridge = () => { } }; window.window = window; - const context = vm.createContext({ window, globalThis: window }); + const context = vm.createContext({ window, globalThis: window, URL }); + vm.runInContext(imageFallbackSource, context, { filename: 'runtime.image-fallbacks.js' }); vm.runInContext(bridgeSource, context, { filename: bridgePath }); return { api: window.FolderViewPlusCspEvents, listeners, locationCalls, window }; }; @@ -140,14 +142,16 @@ test('declarative action registry rejects invalid handlers and cross-owner colli }); test('declarative event bridge blocks arbitrary globals and unsafe image fallbacks', () => { - const { api } = createBridge(); + const { api, window } = createBridge(); const element = createElement(); const event = { preventDefault() {} }; assert.throws(() => api.execute("alert('no')", element, event), /not allowlisted/); assert.throws(() => api.execute("this.src='https://attacker.invalid/icon.png'", element, event), /Unsafe image fallback/); + element.setAttribute('src', 'https://icons.example.invalid/missing.png'); api.execute("this.src='/plugins/folderview.plus/folder-icon.png'", element, event); assert.equal(element.getAttribute('src'), '/plugins/folderview.plus/folder-icon.png'); assert.equal(element.dataset.fvplusFallbackApplied, 'true'); + assert.equal(window.FolderViewPlusFoundationModules.imageFallbacks.has('https://icons.example.invalid/missing.png'), true); }); test('event bridge installs delegated handlers including capture-phase image errors', () => { @@ -160,6 +164,31 @@ test('event bridge installs delegated handlers including capture-phase image err assert.deepEqual(locationCalls, ['/Plugins']); }); +test('image fallback cache loads before the CSP event bridge on every loader path', () => { + for (const pageName of ['FolderViewPlus.page', 'Folder.page']) { + const page = fs.readFileSync(path.join(pluginRoot, pageName), 'utf8'); + const cacheIndex = page.indexOf('runtime.image-fallbacks.js'); + const bridgeIndex = page.indexOf('folderviewplus.csp-events.js'); + assert.ok(cacheIndex >= 0 && cacheIndex < bridgeIndex, pageName); + } + const customLoader = fs.readFileSync(path.join(pluginRoot, 'scripts/custom.php'), 'utf8'); + const cacheIndex = customLoader.indexOf('runtime.image-fallbacks.js'); + const bridgeIndex = customLoader.indexOf('folderviewplus.csp-events.js'); + assert.ok(cacheIndex >= 0 && cacheIndex < bridgeIndex); +}); + +test('image fallback cache stays bounded and ignores inline image payloads', () => { + const { window } = createBridge(); + const cache = window.FolderViewPlusFoundationModules.imageFallbacks; + assert.equal(cache.record('data:image/png;base64,AAAA'), false); + for (let index = 0; index < 129; index += 1) { + assert.equal(cache.record(`https://icons.example.invalid/missing-${index}.png`), true); + } + assert.deepEqual({ ...cache.snapshot() }, { count: 128, limit: 128 }); + assert.equal(cache.has('https://icons.example.invalid/missing-0.png'), false); + assert.equal(cache.has('https://icons.example.invalid/missing-128.png'), true); +}); + test('all runtime handler names represented in declarative source remain allowlisted', () => { const { api } = createBridge(); const discovered = new Set(); diff --git a/tests/docker-double-reload-regression.test.mjs b/tests/docker-double-reload-regression.test.mjs index 13ad9666..4147ccac 100644 --- a/tests/docker-double-reload-regression.test.mjs +++ b/tests/docker-double-reload-regression.test.mjs @@ -17,3 +17,9 @@ test('docker queued bootstrap renders replay in place instead of forcing a secon assert.match(dockerJs, /const queueCreateFoldersRender = \(\) => \{[\s\S]*?if \(createFoldersQueued\) \{\s*createFoldersQueued = false;[\s\S]*?nextDockerRenderSuppressLoadingUi = true;\s*queueCreateFoldersRender\(\);\s*\}[\s\S]*?\};/); assert.doesNotMatch(dockerJs, /const queueCreateFoldersRender = \(\) => \{[\s\S]*?if \(createFoldersQueued\) \{\s*createFoldersQueued = false;\s*queueLoadlistRefresh\(\);\s*\}[\s\S]*?\};/); }); + +test('Docker native busy polling keeps host rows authoritative until the busy cycle completes', () => { + assert.match(dockerJs, /dockerNativeBusyPollInFlight = getDockerHostGuardsApi\(\)\?\.isNativeBusyPollActive\?\.\(\) === true;/); + assert.match(dockerJs, /if \(dockerNativeBusyPollInFlight\) \{[\s\S]*?folderReq = null;[\s\S]*?invokeOriginal\(\);[\s\S]*?return;/); + assert.match(dockerJs, /wrapHostHook\?\.\('listview',[\s\S]*?if \(dockerNativeBusyPollInFlight\) \{[\s\S]*?invokeOriginal\(\);[\s\S]*?return;/); +}); diff --git a/tests/docker-folder-grouping.test.mjs b/tests/docker-folder-grouping.test.mjs new file mode 100644 index 00000000..019bfa19 --- /dev/null +++ b/tests/docker-folder-grouping.test.mjs @@ -0,0 +1,225 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const repoRoot = path.resolve(process.cwd()); +const pluginRoot = path.join(repoRoot, 'src', 'folderview.plus', 'usr', 'local', 'emhttp', 'plugins', 'folderview.plus'); +const grouping = createRequire(import.meta.url)(path.join(pluginRoot, 'scripts', 'docker.runtime.folder-grouping.js')); +const read = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); + +const createRow = ({ name = '', id = '', containerId = '', folderId = '' }) => { + const classes = new Set(folderId ? ['folder', 'sortable'] : ['sortable']); + const row = { + id, + dataset: folderId ? { fvFolderId: folderId } : (containerId ? { containerId } : {}), + isConnected: true, + parentElement: null, + parentNode: null, + classList: { + contains: (value) => classes.has(value), + add: (...values) => values.forEach((value) => classes.add(value)), + remove: (...values) => values.forEach((value) => classes.delete(value)) + }, + querySelector: (selector) => ( + selector.includes('.appname') ? { textContent: name, dataset: {} } : null + ), + remove: () => row.parentNode?.removeChild?.(row) + }; + Object.defineProperty(row, 'nextSibling', { + get: () => { + const siblings = row.parentNode?.children || []; + return siblings[siblings.indexOf(row) + 1] || null; + } + }); + return row; +}; +const createParent = (initialChildren = []) => { + const parent = { + children: [], + removeChild: (row) => { + const index = parent.children.indexOf(row); + if (index >= 0) parent.children.splice(index, 1); + row.parentElement = null; + row.parentNode = null; + return row; + }, + insertBefore: (row, anchor) => { + row.parentNode?.removeChild?.(row); + const index = anchor ? parent.children.indexOf(anchor) : parent.children.length; + parent.children.splice(index < 0 ? parent.children.length : index, 0, row); + row.parentElement = parent; + row.parentNode = parent; + return row; + }, + appendChild: (row) => parent.insertBefore(row, null) + }; + initialChildren.forEach(parent.appendChild); + return parent; +}; +const createRuntime = (count = 18) => Object.fromEntries( + Array.from({ length: count }, (_, index) => { + const name = `app-${String(index).padStart(2, '0')}`; + const id = `sha256:${(index + 1).toString(16).padStart(2, '0').repeat(32)}`; + return [name, { + id, + shortId: id.slice(7, 19), + info: { Id: id, Name: `/${name}` } + }]; + }) +); + +test('Docker grouping resolves short-id host rows to canonical runtime names', () => { + const containersInfo = createRuntime(); + const rows = Object.entries(containersInfo).map(([name, entry]) => createRow({ + name, + id: `ct-${entry.shortId}` + })); + const session = grouping.createSession({ + document: { querySelectorAll: () => rows }, + containersInfo + }); + + assert.deepEqual(session.readOrder(), Object.keys(containersInfo)); + assert.deepEqual(session.snapshot().hostRows, { + total: 18, + resolved: 18, + unresolved: 0, + conflictingCandidates: 0, + duplicateCanonical: 0 + }); +}); + +test('late wizard folder shells survive shrinking sortable rows with hide-empty enabled', () => { + const containersInfo = createRuntime(); + const names = Object.keys(containersInfo); + const rows = names.map((name) => createRow({ + name, + id: `ct-${containersInfo[name].shortId}` + })); + const listRoot = createParent(rows); + const session = grouping.createSession({ + document: { + querySelectorAll: () => rows, + getElementById: () => listRoot + }, + containersInfo + }); + const assignments = new Map([ + [3, { explicit: names.slice(0, 1) }], + [8, { explicit: names.slice(1, 10) }], + [21, { explicit: names.slice(10, 12) }], + [22, { rules: names.slice(12, 14) }], + [23, { label: names.slice(14, 16) }], + [24, { regex: names.slice(16, 17) }] + ]); + + const order = Array.from({ length: 25 }, (_, index) => `folder-folder-${index}`).concat(names); + for (let folderIndex = 0; folderIndex < 25; folderIndex++) { + const sources = assignments.get(folderIndex) || {}; + const combined = [...new Set(Object.values(sources).flat())]; + const folderId = `folder-${folderIndex}`; + session.beginFolder(folderId, sources, combined.length); + const folderRow = createRow({ folderId }); + const storage = createParent(); + const insertion = session.insertFolderRow(folderId, folderRow, folderIndex, order); + assert.equal(insertion.inserted, true); + const claimed = combined.map((name) => session.claim(folderId, name)).filter(Boolean); + claimed.forEach((row) => { + row.classList.remove('sortable'); + row.classList.add('folder-element'); + storage.appendChild(row); + }); + session.finishFolder(folderId, { + renderedMemberCount: claimed.length, + removedByHideEmpty: claimed.length === 0 + }); + if (claimed.length === 0) folderRow.remove(); + } + + const snapshot = session.snapshot(); + assert.deepEqual( + listRoot.children.filter((row) => row.classList.contains('folder')).map((row) => row.dataset.fvFolderId), + ['folder-3', 'folder-8', 'folder-21', 'folder-22', 'folder-23', 'folder-24'] + ); + assert.equal(snapshot.folders.total, 25); + assert.equal(snapshot.folders.combinedCandidateCount, 17); + assert.equal(snapshot.folders.claimedRowCount, 17); + assert.equal(snapshot.folders.renderedMemberCount, 17); + assert.equal(snapshot.folders.missingRowCount, 0); + assert.equal(snapshot.folders.insertedShellCount, 25); + assert.equal(snapshot.folders.failedShellCount, 0); + assert.equal(snapshot.folders.fallbackShellCount, 0); + assert.equal(snapshot.folders.removedByHideEmptyCount, 19); + assert.equal(snapshot.folders.entries.filter((entry) => !entry.removedByHideEmpty).length, 6); + assert.equal(snapshot.folders.entries[22].ruleCandidateCount, 2); + assert.equal(snapshot.folders.entries[23].labelCandidateCount, 2); + assert.equal(snapshot.folders.entries[24].regexCandidateCount, 1); + assert.equal(JSON.stringify(snapshot).includes('app-'), false, 'diagnostics must not expose container names'); + assert.equal(JSON.stringify(snapshot).includes('sha256:'), false, 'diagnostics must not expose container IDs'); +}); + +test('Docker grouping preserves first-folder-wins ownership', () => { + const containersInfo = createRuntime(1); + const name = Object.keys(containersInfo)[0]; + const row = createRow({ name, id: `ct-${containersInfo[name].shortId}` }); + const session = grouping.createSession({ + document: { querySelectorAll: () => [row] }, + containersInfo + }); + + session.beginFolder('first', { explicit: [name] }, 1); + session.beginFolder('second', { rules: [name] }, 1); + assert.equal(session.claim('first', name), row); + assert.equal(session.claim('second', name), null); + session.finishFolder('first', { renderedMemberCount: 1 }); + session.finishFolder('second', { renderedMemberCount: 0, removedByHideEmpty: true }); + + const snapshot = session.snapshot(); + assert.equal(snapshot.folders.claimedRowCount, 1); + assert.equal(snapshot.folders.missingRowCount, 1); + assert.equal(snapshot.folders.removedByHideEmptyCount, 1); +}); + +test('folder shell insertion falls back to a connected previous row and reports missing roots', () => { + const containersInfo = createRuntime(1); + const name = Object.keys(containersInfo)[0]; + const hostRow = createRow({ name, id: `ct-${containersInfo[name].shortId}` }); + const listRoot = createParent([hostRow]); + const session = grouping.createSession({ + document: { querySelectorAll: () => [hostRow], getElementById: () => listRoot }, + containersInfo + }); + const folderRow = createRow({ folderId: 'late' }); + session.beginFolder('late', {}, 0); + assert.deepEqual(session.insertFolderRow('late', folderRow, 1, [name, 'folder-late']), { + inserted: true, + strategy: 'after-previous' + }); + assert.equal(listRoot.children.at(-1), folderRow); + + const disconnected = grouping.createSession({ document: { querySelectorAll: () => [] }, containersInfo: {} }); + disconnected.beginFolder('missing-root', {}, 0); + assert.deepEqual(disconnected.insertFolderRow('missing-root', createRow({ folderId: 'missing-root' }), 0, []), { + inserted: false, + strategy: 'failed' + }); + assert.equal(disconnected.snapshot().folders.failedShellCount, 1); +}); + +test('Docker runtime loads and consumes the extracted folder grouping module', () => { + const page = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page'); + const docker = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js'); + const diagnostics = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.diagnostics.js'); + const groupingIndex = page.indexOf('/plugins/folderview.plus/scripts/docker.runtime.folder-grouping.js'); + const runtimeIndex = page.indexOf('/plugins/folderview.plus/scripts/docker.js'); + + assert.ok(groupingIndex >= 0 && groupingIndex < runtimeIndex); + assert.match(docker, /dockerFolderGroupingModule\.createSession\(\{ document, containersInfo \}\)/); + assert.match(docker, /let order = dockerFolderGroupingSession\.readOrder\(\)/); + assert.match(docker, /dockerFolderGroupingSession\.insertFolderRow\(/); + assert.match(docker, /dockerFolderGroupingSession\.claim\(id, container_name_in_folder\)/); + assert.match(docker, /dockerFolderGroupingSession\?\.finishFolder/); + assert.match(diagnostics, /folderGrouping: cloneValue\(getFolderGroupingDiagnostics\(\)\)/); +}); diff --git a/tests/docker-refresh-diagnostics.test.mjs b/tests/docker-refresh-diagnostics.test.mjs new file mode 100644 index 00000000..fb3bba98 --- /dev/null +++ b/tests/docker-refresh-diagnostics.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const diagnostics = require(path.join( + repoRoot, + 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.refresh-diagnostics.js' +)); + +const createHarness = () => { + let clock = Date.parse('2026-08-19T12:00:00.000Z'); + const records = new Map(); + const listeners = new Map(); + const storage = { + getItem: (key) => records.get(key) || null, + setItem: (key, value) => records.set(key, String(value)) + }; + const window = { + localStorage: storage, + addEventListener: (type, handler) => listeners.set(type, handler), + removeEventListener: (type, handler) => { + if (listeners.get(type) === handler) listeners.delete(type); + } + }; + return { + storage, + window, + now: () => clock, + advance: (ms) => { clock += ms; }, + pagehide: () => listeners.get('pagehide')?.(), + persisted: () => JSON.parse(records.get(diagnostics.STORAGE_KEY) || '{}') + }; +}; + +test('refresh diagnostics classifies recurring full reloads without persisting runtime identities', () => { + const harness = createHarness(); + const tracker = diagnostics.createTracker({ window: harness.window, storage: harness.storage, now: harness.now }); + + tracker.record('loadlist'); + tracker.record('listview'); + tracker.record('buildDockerFolderReq'); + tracker.recordPageSnapshot({ + reason: 'runtime-sync', + correlation: { renderGeneration: 1 }, + folderRows: { count: 12 }, + privateContainerName: 'vaultwarden' + }); + tracker.recordPageSnapshot({ + reason: 'render-complete', + correlation: { renderGeneration: 1 }, + folderRows: { count: 99 } + }); + for (let index = 0; index < 5; index += 1) { + harness.advance(10000); + tracker.record('loadlist'); + } + tracker.recordApiMismatch({ providerOnlyCount: 2, runtimeOnlyCount: 1, privateName: 'radarr' }); + + const snapshot = tracker.snapshot(); + assert.equal(snapshot.verdict.status, 'confirmed'); + assert.equal(snapshot.verdict.reference, 'FVPLUS-DKR-REFRESH-001'); + assert.equal(snapshot.currentSession.counts.loadlist, 6); + assert.equal(snapshot.currentSession.counts.renders, 1); + assert.equal(snapshot.currentSession.lastFolderCount, 12); + assert.equal(snapshot.currentSession.counts.requests, 1); + assert.equal(snapshot.currentSession.reloadSources['initial-bootstrap'], 1); + assert.equal(snapshot.currentSession.reloadSources['unknown-host-caller'], 5); + assert.equal(snapshot.apiMismatch.observedCount, 1); + assert.equal(snapshot.apiMismatch.providerOnlyCount, 2); + assert.equal(snapshot.apiMismatch.runtimeOnlyCount, 1); + assert.equal(snapshot.apiMismatch.policy, 'native-structure-authoritative'); + assert.equal(snapshot.apiMismatch.hostReloadRequested, false); + assert.doesNotMatch(JSON.stringify(snapshot), /vaultwarden|radarr/); + + harness.pagehide(); + const persisted = harness.persisted(); + assert.equal(persisted.currentSession, null); + assert.equal(persisted.completedSessionCount, 1); + assert.equal(persisted.completedSessions[0].exitReason, 'pagehide'); +}); + +test('refresh diagnostics records explicit reload sources and native busy recovery', () => { + const harness = createHarness(); + const tracker = diagnostics.createTracker({ window: harness.window, storage: harness.storage, now: harness.now }); + + tracker.record('loadlist'); + harness.advance(5000); + tracker.markReloadSource('plugin-config-revision'); + tracker.record('loadlist'); + harness.advance(5000); + tracker.record('loadlist', { nativeBusyActive: true }); + tracker.record('listview', { nativeBusyActive: true }); + harness.advance(5000); + tracker.record('loadlist'); + tracker.recordPageSnapshot({ reason: 'render-complete', correlation: { renderGeneration: 2 }, folderRows: { count: 7 } }); + + const snapshot = tracker.snapshot(); + assert.equal(snapshot.reloadSources['plugin-config-revision'], 1); + assert.equal(snapshot.reloadSources['unraid-native-busy-poll'], 1); + assert.equal(snapshot.nativeBusy.cycleCount, 1); + assert.equal(snapshot.nativeBusy.passCount, 1); + assert.equal(snapshot.nativeBusy.cleared, true); + assert.equal(snapshot.nativeBusy.foldersRestored, true); + assert.equal(snapshot.currentSession.counts.foldersRestored, 1); + assert.equal(snapshot.currentSession.lastFolderCount, 7); +}); + +test('refresh-loop thresholds exclude reloads attributed to direct user actions', () => { + const start = Date.parse('2026-08-19T12:00:00.000Z'); + const manualEvents = Array.from({ length: 8 }, (_, index) => ({ atMs: start + (index * 5000), source: 'manual-host-refresh' })); + const suspectedEvents = Array.from({ length: 3 }, (_, index) => ({ atMs: start + (index * 10000), source: 'unknown-host-caller' })); + + assert.equal(diagnostics.buildVerdict(manualEvents, start + 40000).status, 'healthy'); + assert.equal(diagnostics.buildVerdict(suspectedEvents, start + 30000).status, 'suspected'); +}); diff --git a/tests/docker-runtime-action-bar.test.mjs b/tests/docker-runtime-action-bar.test.mjs index 0b97910c..2ababeb7 100644 --- a/tests/docker-runtime-action-bar.test.mjs +++ b/tests/docker-runtime-action-bar.test.mjs @@ -76,7 +76,7 @@ test('view and empty-folder preference changes use the immediate conflict-safe s assert.match(dockerJs, /dockerPrefsCoordinator\.save\('docker', patch, \{[\s\S]*currentPrefs,[\s\S]*immediate: true/); assert.match(actionBarJs, /savePrefs\(\{ pageViewMode: normalizedMode \}, nextPrefs\)/); assert.match(actionBarJs, /savePrefs\(\{ hideEmptyFolders \}, nextPrefs\)/); - assert.match(dockerJs, /refreshRuntimeView: \(\) => \{[\s\S]*queueLoadlistRefresh\(\{ suppressLoadingUi: true \}\)/); + assert.match(dockerJs, /refreshRuntimeView: \(\) => \{[\s\S]*queueLoadlistRefresh\(\{ suppressLoadingUi: true, reloadSource: 'manual-host-refresh' \}\)/); assert.match(actionBarJs, /await refreshRuntimeView\(\)/); assert.doesNotMatch(actionBarJs, /queueRuntimeRender/); assert.match(dockerJs, /const requestBundle = ensureDockerFolderReqForHostRender\(\{[\s\S]*forceRefresh: options\?\.forceRefresh === true/); diff --git a/tests/docker-runtime-api-coordinator.test.mjs b/tests/docker-runtime-api-coordinator.test.mjs index 8cbd2c72..0d8a9f04 100644 --- a/tests/docker-runtime-api-coordinator.test.mjs +++ b/tests/docker-runtime-api-coordinator.test.mjs @@ -103,9 +103,10 @@ test('API merge treats identity-set changes as structural and does not invent ho assert.equal(Object.hasOwn(result.runtimeMap, 'new-app'), false); }); -test('coordinator applies one full API refresh and schedules one structural host refresh', async () => { +test('coordinator keeps the native host authoritative for API identity-set differences', async () => { let runtimeMap = { app: runtimeEntry('app') }; let structuralRefreshes = 0; + const mismatches = []; const provider = { supports: () => true, getCapabilities: () => capabilities, @@ -118,13 +119,18 @@ test('coordinator applies one full API refresh and schedules one structural host }, getRuntimeMap: () => runtimeMap, applyRuntimeMap: (next) => { runtimeMap = next; }, + onIdentityMismatch: (details) => mismatches.push(details), requestStructuralRefresh: () => { structuralRefreshes += 1; } }); const result = await coordinator.refreshAll({ fallback: false }); + const repeated = await coordinator.refreshAll({ fallback: false }); await Promise.resolve(); assert.equal(result.structuralChanged, true); - assert.equal(structuralRefreshes, 1); + assert.equal(repeated.structuralChanged, true); + assert.equal(structuralRefreshes, 0); + assert.equal(mismatches.length, 2); + assert.deepEqual(mismatches[0], { providerOnlyCount: 1, runtimeOnlyCount: 0, hostReloadRequested: false }); assert.equal(Object.hasOwn(runtimeMap, 'new-app'), false); coordinator.dispose(); }); @@ -151,6 +157,28 @@ test('coordinator uses targeted reconciliation and rejects stale work after disp assert.equal(coordinator.status().state, 'disposed'); }); +test('targeted API identity misses are diagnostic-only and never request a host reload', async () => { + let structuralRefreshes = 0; + const mismatches = []; + const provider = { + supports: () => true, + getCapabilities: () => capabilities, + listContainers: async () => [apiContainer('app')], + reconcileContainer: async () => apiContainer('missing-app') + }; + const coordinator = coordinatorModule.createCoordinator({ + providerRegistry: { getDefault: () => provider }, + getRuntimeMap: () => ({ app: runtimeEntry('app') }), + onIdentityMismatch: (details) => mismatches.push(details), + requestStructuralRefresh: () => { structuralRefreshes += 1; } + }); + + const result = await coordinator.reconcileContainer('missing-app', { fallback: false }); + assert.equal(result.structuralChanged, true); + assert.equal(structuralRefreshes, 0); + assert.deepEqual(mismatches, [{ targeted: true, providerOnlyCount: 1, runtimeOnlyCount: 0, hostReloadRequested: false }]); +}); + test('permission failures disable API retries for the page lifecycle', async () => { let calls = 0; const provider = { diff --git a/tests/docker-runtime-host-guards.test.mjs b/tests/docker-runtime-host-guards.test.mjs new file mode 100644 index 00000000..83014d80 --- /dev/null +++ b/tests/docker-runtime-host-guards.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const hostGuardsModule = require(path.resolve( + 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.host-guards.js' +)); + +const createHarness = (busy) => { + const document = { + querySelector: (selector) => selector === '#busy' ? busy : null + }; + const window = { + document, + getComputedStyle: (node) => node.style + }; + return hostGuardsModule.createApi({ window, document, adapter: {} }); +}; + +test('Docker host guard recognizes only a visible Unraid busy indicator', () => { + const busy = { + hidden: false, + style: { display: 'block', visibility: 'visible' }, + getAttribute: () => null + }; + const api = createHarness(busy); + + assert.equal(api.isNativeBusyPollActive(), true); + busy.style.display = 'none'; + assert.equal(api.isNativeBusyPollActive(), false); + busy.style.display = 'block'; + busy.style.visibility = 'hidden'; + assert.equal(api.isNativeBusyPollActive(), false); + busy.style.visibility = 'visible'; + busy.hidden = true; + assert.equal(api.isNativeBusyPollActive(), false); +}); + +test('Docker host guard treats a missing busy indicator as inactive', () => { + assert.equal(createHarness(null).isNativeBusyPollActive(), false); +}); diff --git a/tests/docker-runtime-shared-architecture.test.mjs b/tests/docker-runtime-shared-architecture.test.mjs index 43f4fd1e..1edc6fdd 100644 --- a/tests/docker-runtime-shared-architecture.test.mjs +++ b/tests/docker-runtime-shared-architecture.test.mjs @@ -57,6 +57,7 @@ test('docker runtime page loads shared runtime module before docker modules/runt const runtimeHierarchyIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js'); const runtimeActionsIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.actions.js'); const hostGuardsIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.host-guards.js'); + const refreshDiagnosticsIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.refresh-diagnostics.js'); const diagnosticsIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.diagnostics.js'); const layoutGeometryIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.layout-geometry.js'); const reconcileIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.reconcile.js'); @@ -80,6 +81,7 @@ test('docker runtime page loads shared runtime module before docker modules/runt assert.ok(runtimeHierarchyIndex >= 0, 'docker hierarchy script include is missing'); assert.ok(runtimeActionsIndex >= 0, 'docker actions script include is missing'); assert.ok(hostGuardsIndex >= 0, 'docker host guards script include is missing'); + assert.ok(refreshDiagnosticsIndex >= 0, 'docker refresh diagnostics script include is missing'); assert.ok(diagnosticsIndex >= 0, 'docker diagnostics script include is missing'); assert.ok(reconcileIndex >= 0, 'docker reconcile script include is missing'); assert.ok(commandViewIndex >= 0, 'docker command-view script include is missing'); @@ -108,6 +110,7 @@ test('docker runtime page loads shared runtime module before docker modules/runt assert.ok(runtimeHierarchyIndex < runtimeActionsIndex, 'docker hierarchy helpers must load before docker.runtime.actions.js'); assert.ok(runtimeActionsIndex < hostGuardsIndex, 'docker action helpers must load before docker.runtime.host-guards.js'); assert.ok(hostGuardsIndex < diagnosticsIndex, 'docker host guards must load before docker.runtime.diagnostics.js'); + assert.ok(refreshDiagnosticsIndex < diagnosticsIndex, 'Docker refresh diagnostics must load before Docker page diagnostics'); assert.ok(layoutGeometryIndex >= 0 && layoutGeometryIndex < diagnosticsIndex, 'layout geometry must load before docker diagnostics'); assert.ok(diagnosticsIndex < reconcileIndex, 'docker diagnostics helpers must load before docker.runtime.reconcile.js'); assert.ok(reconcileIndex < commandViewIndex, 'docker reconcile helpers must load before docker.runtime.command-view.js'); @@ -169,7 +172,7 @@ test('docker extracted helper modules export createApi entry points with safe gl assert.match(dockerRuntimeHostGuardsJs, /const createApi = \(deps = \{\}\) =>/); assert.match(dockerRuntimeDiagnosticsJs, /^\/\/ @ts-check/m); assert.match(dockerRuntimeDiagnosticsJs, /const fallbackWindow = typeof globalThis !== 'undefined'/); - assert.match(dockerRuntimeDiagnosticsJs, /root\.FolderViewPlusDockerRuntimeDiagnostics = factory\(fallbackWindow\);/); + assert.match(dockerRuntimeDiagnosticsJs, /root\.FolderViewPlusDockerRuntimeDiagnostics = factory\(fallbackWindow, fallbackWindow\.FolderViewPlusFoundationModules\?\.dockerRefreshDiagnostics\);/); assert.match(dockerRuntimeDiagnosticsJs, /root\.FolderViewPlusDockerRuntimeDiagnosticsModuleLoaded = true;/); assert.match(dockerRuntimeDiagnosticsJs, /const createApi = \(deps = \{\}\) =>/); assert.match(dockerRuntimeReconcileJs, /^\/\/ @ts-check/m); diff --git a/tests/docker-support-bundle-diagnostics.test.mjs b/tests/docker-support-bundle-diagnostics.test.mjs index 71f83b00..5c281386 100644 --- a/tests/docker-support-bundle-diagnostics.test.mjs +++ b/tests/docker-support-bundle-diagnostics.test.mjs @@ -88,6 +88,26 @@ const containerRow = (name, folderId = '') => ({ updateCell: updateElement('up-to-date') }); +test('Docker trace health advances its top-level timestamp after an existing record', () => { + const records = new Map(); + records.set(diagnosticsModule.DOCKER_TRACE_HEALTH_STORAGE_KEY, JSON.stringify({ + updatedAt: '2026-04-14T12:55:19.518Z', + requestBundleTrace: { failureCount: 0 } + })); + const localStorage = { + getItem: (key) => records.get(key) || null, + setItem: (key, value) => records.set(key, String(value)) + }; + const api = diagnosticsModule.createApi({ window: { localStorage }, localStorage }); + + assert.equal(api.updateTraceHealth('pageSnapshot', true, { reason: 'runtime-sync' }), true); + const persisted = JSON.parse(records.get(diagnosticsModule.DOCKER_TRACE_HEALTH_STORAGE_KEY)); + assert.notEqual(persisted.updatedAt, '2026-04-14T12:55:19.518Z'); + assert.equal(persisted.pageSnapshot.lastWriteSucceeded, true); + assert.equal(persisted.pageSnapshot.details.reason, 'runtime-sync'); + assert.equal(persisted.requestBundleTrace.failureCount, 0); +}); + test('Docker support snapshot records standalone containers after folders and page asset versions', () => { const rows = [folderRow('apps', 'Apps'), folderRow('services', 'Services'), containerRow('new-container')]; const scripts = [ @@ -104,6 +124,11 @@ test('Docker support snapshot records standalone containers after folders and pa document: { querySelectorAll: () => scripts }, $: createJQuery(rows), readDockerListViewMode: () => 'basic', + getFolderGroupingDiagnostics: () => ({ + schemaVersion: 1, + hostRows: { total: 3, resolved: 3, unresolved: 0 }, + folders: { total: 2, claimedRowCount: 2, missingRowCount: 0 } + }), getCorrelationContext: () => ({ stateSignature: 'new-container:r:1:dockerman:true:', stateEntityCount: 1, @@ -134,6 +159,9 @@ test('Docker support snapshot records standalone containers after folders and pa assert.equal(snapshot.correlation.orderReconciliation.appendedContainerCount, 1); assert.equal(snapshot.correlation.orderReconciliation.appendPosition, 'after-folders'); assert.equal(snapshot.correlation.orderReconciliation.orderingInvariantSatisfied, true); + assert.equal(snapshot.folderGrouping.hostRows.resolved, 3); + assert.equal(snapshot.folderGrouping.folders.claimedRowCount, 2); + assert.equal(snapshot.folderGrouping.folders.missingRowCount, 0); assert.equal(snapshot.dockerAssets.pluginVersion, '2026.07.13.04'); assert.deepEqual(snapshot.dockerAssets.entries.map((entry) => entry.versionQuery), [ '2026.07.13.04', diff --git a/tests/docker-update-status-regression.test.mjs b/tests/docker-update-status-regression.test.mjs index 194530c0..a1396af8 100644 --- a/tests/docker-update-status-regression.test.mjs +++ b/tests/docker-update-status-regression.test.mjs @@ -34,6 +34,10 @@ const dockerRuntimeReconcileJs = fs.readFileSync( path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.reconcile.js'), 'utf8' ); +const dockerRuntimeDiagnosticsJs = fs.readFileSync( + path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.diagnostics.js'), + 'utf8' +); const dockerRuntimeHierarchyJs = fs.readFileSync( path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js'), 'utf8' @@ -177,7 +181,6 @@ test('docker runtime observes native update-column mutations and reuses them for assert.match(dockerRuntimeInfoJs, /if \(syncDockerHostRowUpdateStatesFromDom\(pendingNames\)\) \{\s*syncDockerVisibleFoldersFromRuntimeCache\(pendingNames\);\s*\}/); assert.match(dockerRuntimeInfoJs, /const ensureDockerHostRowUpdateObserver = \(\) => \{[\s\S]*dockerHostUpdateCellObserver = new MutationObserver/); assert.match(dockerJs, /const DOCKER_HOST_UPDATE_SYNC_SUSPENDED_UNTIL_KEY = '__fvplusDockerHostUpdateSyncSuspendedUntil';/); - assert.match(dockerJs, /const DOCKER_SUPPORT_BUNDLE_PAGE_STORAGE_KEY = dockerRuntimeDiagnosticsModule\?\.DOCKER_SUPPORT_BUNDLE_PAGE_STORAGE_KEY \|\| 'fv\.support\.bundle\.docker\.page\.v1';/); assert.match(dockerJs, /const dockerHostGuardsModule = window\.FolderViewPlusDockerHostGuards \|\| null;/); assert.match(dockerJs, /const dockerRuntimeDiagnosticsModule = window\.FolderViewPlusDockerRuntimeDiagnostics \|\| null;/); assert.match(dockerJs, /const dockerRuntimeReconcileModule = window\.FolderViewPlusDockerRuntimeReconcile \|\| null;/); @@ -185,10 +188,9 @@ test('docker runtime observes native update-column mutations and reuses them for assert.match(dockerJs, /const isDockerHostUpdateCommand = \(command\) => DOCKER_HOST_UPDATE_COMMAND_REGEX\.test\(String\(command \|\| ''\)\.trim\(\)\);/); assert.match(dockerJs, /const isDockerHostUpdateSyncSuspended = \(\) => readDockerHostUpdateSyncSuspendedUntil\(\) > Date\.now\(\);/); assert.match(dockerJs, /const suspendDockerHostUpdateSync = \(durationMs = 0\) => \{/); - assert.match(dockerJs, /const updateDockerTraceHealth = \(traceName,\s*success,\s*details = \{\}\) => \{/); assert.match(dockerJs, /const appendDockerBulkUpdateTrace = \(eventType,\s*details = \{\}\) => \{/); assert.match(dockerJs, /const appendDockerRequestBundleTrace = \(eventType,\s*details = \{\}\) => \{/); - assert.match(dockerJs, /diagnosticsApi\.updateTraceHealth\(traceName,\s*success,\s*details\)/); + assert.doesNotMatch(dockerJs, /DOCKER_SUPPORT_BUNDLE_PAGE_STORAGE_KEY|updateDockerTraceHealth/); assert.match(dockerJs, /diagnosticsApi\.appendBulkUpdateTrace\(eventType,\s*details\)/); assert.match(dockerJs, /diagnosticsApi\.appendRequestBundleTrace\(eventType,\s*details\)/); assert.match(dockerJs, /const getDockerRuntimeReconcileApi = \(\) => \{/); @@ -207,7 +209,8 @@ test('docker runtime observes native update-column mutations and reuses them for assert.match(dockerJs, /markDockerFatalBannerStep\('Docker request bundle primed'\);\s*bindDockerHostOpenDockerPatch\(\);\s*bindDockerLifecycleEventControlPatch\(\);\s*bindDockerContainerContextStatePatch\(\);\s*bindDockerUpdateActionClickCapture\(\);\s*bindDockerPostUpdateRenderReconcile\(\);\s*startDockerListViewModeObserver\(\);/); assert.match(dockerJs, /if \(!loadedFolder\) \{[\s\S]*queueDockerRuntimeRenderForPageViewMode\(\);/); assert.match(dockerJs, /wrapHostHook\?\.\('loadlist',[\s\S]*bindDockerHostOpenDockerPatch\(\);[\s\S]*folderReq = ensureDockerFolderReqForHostRender\(\);/); - assert.match(dockerJs, /const collectDockerSupportBundlePageSnapshot = \(reason = 'runtime-sync'\) => \{[\s\S]*diagnosticsApi\.collectPageSnapshot\(reason\)/); + assert.match(dockerJs, /const queueDockerSupportBundlePageSnapshot = \(reason = 'runtime-sync', delayMs = 180\) => \{[\s\S]*diagnosticsApi\.queuePageSnapshot\(safeReason, offCriticalPathDelay\);/); + assert.doesNotMatch(dockerJs, /collectDockerSupportBundlePageSnapshot|writeDockerSupportBundleStorageRecord/); assert.match(dockerJs, /const buildDockerDiagnosticsCorrelationContext = \(\) => \(\{/); assert.match(dockerJs, /hookStates:\s*getDockerHostGuardsApi\(\)\?\.getHookStates\?\.\(\) \|\| \{\}/); }); @@ -295,8 +298,8 @@ test('docker update dialog callbacks replace host loadlist redraws with a bounde }); test('docker support bundle snapshot reads only visible update-column text in basic view', () => { - assert.match(dockerJs, /const collectDockerSupportBundlePageSnapshot = \(reason = 'runtime-sync'\) => \{[\s\S]*diagnosticsApi\.collectPageSnapshot\(reason\)/); - assert.doesNotMatch(dockerJs, /const updateCellText = normalizeDockerSupportBundleText\(\$row\.find\('td\.updatecolumn'\)\.first\(\)\.text\(\)\);/); + assert.match(dockerRuntimeDiagnosticsJs, /const updateCellText = readVisibleUpdateCellText\(\$row\.find\('td\.updatecolumn'\)\.first\(\)\);/); + assert.doesNotMatch(dockerRuntimeDiagnosticsJs, /const updateCellText = normalizeText\(\$row\.find\('td\.updatecolumn'\)\.first\(\)\.text\(\)\);/); }); test('docker runtime can stay in host-list mode without rendering FolderView rows', () => { diff --git a/tests/folderviewplus-utils.test.mjs b/tests/folderviewplus-utils.test.mjs index d53dd106..e1f9de60 100644 --- a/tests/folderviewplus-utils.test.mjs +++ b/tests/folderviewplus-utils.test.mjs @@ -3,6 +3,8 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); +const imageFallbacks = require('../src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.image-fallbacks.js'); +globalThis.FolderViewPlusFoundationModules = { imageFallbacks }; const utils = require('../src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils.js'); test('normalizePrefs preserves transient configuration revision metadata for stale-save protection', () => { @@ -450,11 +452,19 @@ test('utils exports shared dashboard metadata and runtime-safe escaping helpers' assert.equal(utils.normalizeDashboardOverflowMode('bad-value'), 'default'); assert.equal(utils.escapeHtml(`a<"b"&'c'`), 'a<"b"&'c''); assert.equal(utils.sanitizeImageSrc('javascript:alert(1)'), '/plugins/dynamix.docker.manager/images/question.png'); + assert.equal(utils.sanitizeImageSrc(''), '/plugins/dynamix.docker.manager/images/question.png'); assert.equal(utils.sanitizeImageSrc('data:image/svg+xml,'), '/plugins/dynamix.docker.manager/images/question.png'); assert.equal(utils.sanitizeImageUrl('//attacker.invalid/icon.png'), '/plugins/dynamix.docker.manager/images/question.png'); assert.equal(utils.sanitizeImageUrl('https://user:pass@example.com/icon.png'), '/plugins/dynamix.docker.manager/images/question.png'); assert.equal(utils.sanitizeImageUrl('https://example.com/icon.png'), 'https://example.com/icon.png'); assert.equal(utils.sanitizeImageSrc('/plugins/folderview.plus/images/folder-icon.png'), '/plugins/folderview.plus/images/folder-icon.png'); + assert.equal(imageFallbacks.record('https://missing-icons.example.invalid/container.png'), true); + assert.equal(imageFallbacks.record('https://missing-icons.example.invalid/container.png'), false); + assert.equal(imageFallbacks.has('https://missing-icons.example.invalid/container.png'), true); + assert.equal( + utils.sanitizeImageUrl('https://missing-icons.example.invalid/container.png'), + '/plugins/dynamix.docker.manager/images/question.png' + ); assert.deepEqual(utils.resolvePreviewActionPrefs({ preview_webui: true, preview_console: false, preview_logs: true }), { preview_webui: true, preview_console: false, diff --git a/tests/performance-optimizations.test.mjs b/tests/performance-optimizations.test.mjs index b02dc87e..7cc17f3b 100644 --- a/tests/performance-optimizations.test.mjs +++ b/tests/performance-optimizations.test.mjs @@ -497,15 +497,13 @@ test('docker post-render polish retries only when rows are still settling', () = assert.doesNotMatch(dockerModulesJs, /setTimeout\(queueForceAllFolderRowsVerticalCenter,\s*1000\)/); }); -test('docker first paint avoids repeated full-row polish and defers support snapshots', () => { +test('docker first paint avoids repeated full-row polish and delegates support snapshot timing once', () => { assert.match(dockerJs, /const scheduleDockerPostRenderPolish = \(folderIds = \[\]\) => \{/); assert.doesNotMatch(dockerJs, /safeFolderIds\.forEach\(\(folderId\) => forceFolderRowVerticalCenter\(folderId\)\);/); assert.match(dockerJs, /queueForceAllFolderRowsVerticalCenter\(\);/); - assert.match(dockerJs, /let dockerSupportBundlePageSnapshotWriteTimer = null;/); assert.match(dockerJs, /const offCriticalPathDelay = \/\^\(render-complete\|runtime-sync\)\$\/\.test\(safeReason\)[\s\S]*Math\.max\(safeDelay,\s*1200\)/); assert.match(dockerJs, /diagnosticsApi\.queuePageSnapshot\(safeReason,\s*offCriticalPathDelay\);/); - assert.match(dockerJs, /dockerSupportBundlePageSnapshotWriteTimer = window\.setTimeout\(\(\) => \{/); - assert.match(dockerJs, /writeDockerSupportBundleStorageRecord\(DOCKER_SUPPORT_BUNDLE_PAGE_STORAGE_KEY,\s*snapshot\);/); + assert.doesNotMatch(dockerJs, /dockerSupportBundlePageSnapshotWriteTimer|dockerSupportBundlePageSnapshotPendingReason/); }); test('vm runtime tiny-width overflow guard can still recover clipped folder names', () => { diff --git a/tests/phase8-test-runner-decomposition.test.mjs b/tests/phase8-test-runner-decomposition.test.mjs index 06c9f655..c02f44d1 100644 --- a/tests/phase8-test-runner-decomposition.test.mjs +++ b/tests/phase8-test-runner-decomposition.test.mjs @@ -20,8 +20,8 @@ test('Phase 8 runner contract preserves deterministic fixture intent inventory', cwd: rootDir, encoding: 'utf8' }); - assert.match(output, /41 ordered fixture cases/); - assert.equal(contract.intent.fixture.assertionCount, 516); + assert.match(output, /42 ordered fixture cases/); + assert.equal(contract.intent.fixture.assertionCount, 534); }); test('Phase 8 change classification covers every extracted runner family', async () => { diff --git a/tests/runtime-action-refresh-optimization.test.mjs b/tests/runtime-action-refresh-optimization.test.mjs index 0894e2b9..9c211b40 100644 --- a/tests/runtime-action-refresh-optimization.test.mjs +++ b/tests/runtime-action-refresh-optimization.test.mjs @@ -26,7 +26,7 @@ test('docker runtime actions refresh visible state in place instead of forcing a assert.match(dockerJs, /const configurationChanged = snapshot && !dockerRuntimeSnapshotConfigMatches\(snapshot\);/); assert.match(dockerJs, /if \(configurationChanged && !preserveGroupedDom\) \{\s*fallbackReason = 'configuration-changed';\s*return false;\s*\}/); assert.match(dockerJs, /if \(configurationChanged\) \{[\s\S]*deferredConfigurationRebuild:/); - assert.match(dockerJs, /queueLoadlistRefresh\(\{ suppressLoadingUi: true \}\);/); + assert.match(dockerJs, /queueLoadlistRefresh\(\{ suppressLoadingUi: true, reloadSource: 'plugin-action-followup' \}\);/); assert.match(dockerJs, /await refreshDockerRuntimeStateInPlace\(\{ followupDelayMs: 650 \}\);/); assert.match(dockerRuntimeActionsJs, /const refreshDockerRuntimeState = typeof deps\.refreshDockerRuntimeState === 'function'/); assert.match(dockerRuntimeActionsJs, /const armDockerPostUpdateRuntimeReconcileWindow = typeof deps\.armDockerPostUpdateRuntimeReconcileWindow === 'function'/); diff --git a/tests/security-hardening.test.mjs b/tests/security-hardening.test.mjs index 5a932355..117667dd 100644 --- a/tests/security-hardening.test.mjs +++ b/tests/security-hardening.test.mjs @@ -259,7 +259,7 @@ test('docker advanced popup uses delegated actions instead of inline handlers', assert.match(dockerJs, /class="fv-runtime-action" data-action="console"/); assert.match(dockerJs, /\$content\.on\('click', '\.fv-runtime-action'/); assert.match(dockerJs, /const actionMap = new Set\(\['start', 'resume', 'stop', 'pause', 'restart'\]\);/); - assert.match(dockerJs, /const refreshTarget = getDockerRuntimeReconcileApi\(\)\?\.getLifecycleRefreshCallbackName\?\.\(\) \|\| 'loadlist';\s*eventControl\(\{ action, container: containerId \}, refreshTarget\);/); + assert.match(dockerJs, /const refreshTarget = getDockerRuntimeReconcileApi\(\)\?\.getLifecycleRefreshCallbackName\?\.\(\) \|\| 'loadlist';\s*if \(refreshTarget === 'loadlist'\) getDockerRuntimeDiagnosticsApi\(\)\?\.markReloadSource\?\.\('plugin-action-followup'\);\s*eventControl\(\{ action, container: containerId \}, refreshTarget\);/); assert.match(dockerJs, /openTerminal\('docker', actionContainerName, String\(\$link\.attr\('data-shell-value'\)/); assert.match(dockerJs, /class="fv-runtime-toggle-info-list" data-show="\.info-ports-more"/); assert.match(dockerJs, /\$content\.on\('click', '\.fv-runtime-toggle-info-list'/); diff --git a/tests/support-bundle-browser-telemetry.test.mjs b/tests/support-bundle-browser-telemetry.test.mjs index 22888964..aa274699 100644 --- a/tests/support-bundle-browser-telemetry.test.mjs +++ b/tests/support-bundle-browser-telemetry.test.mjs @@ -251,19 +251,22 @@ test('browser error telemetry separates current-session failures from historical }; const browserModule = loadBrowserModule(root); const snapshot = browserModule.createCollectors().collectBrowserConsoleErrors({ - pluginVersion: '2026.07.13.06' + pluginVersion: '2026.07.13.06', + nowMs: Date.parse('2026-07-13T18:02:00.000Z') }); - assert.equal(snapshot.count, 2); + assert.equal(snapshot.count, 1); + assert.equal(snapshot.retainedCount, 2); assert.equal(snapshot.collectionPluginVersion, '2026.07.13.06'); assert.equal(snapshot.firstSeenAt, '2026-05-21T19:09:29.870Z'); assert.equal(snapshot.lastSeenAt, '2026-07-13T18:01:00.000Z'); assert.equal(snapshot.currentSessionCount, 1); assert.equal(snapshot.historicalCount, 1); - assert.equal(snapshot.entries[0].currentSession, false); - assert.equal(snapshot.entries[0].observedPluginVersion, '2026.05.21.01'); - assert.equal(snapshot.entries[1].currentSession, true); - assert.equal(snapshot.entries[1].observedPluginVersion, '2026.07.13.06'); + assert.equal(snapshot.last30DaysCount, 0); + assert.equal(snapshot.olderCount, 1); + assert.equal(snapshot.olderByCategory.unknown, 1); + assert.equal(snapshot.entries[0].currentSession, true); + assert.equal(snapshot.entries[0].observedPluginVersion, '2026.07.13.06'); }); test('browser error telemetry does not misattribute legacy records to the collection plugin version', () => { @@ -272,7 +275,10 @@ test('browser error telemetry does not misattribute legacy records to the collec readClientDiagnosticsStorageRecord() { return [{ at: '2026-05-21T19:09:29.870Z', message: 'Legacy failure' }]; } - }).collectBrowserConsoleErrors({ pluginVersion: '2026.07.13.06' }); + }).collectBrowserConsoleErrors({ + pluginVersion: '2026.07.13.06', + nowMs: Date.parse('2026-05-21T19:10:00.000Z') + }); assert.equal(snapshot.collectionPluginVersion, '2026.07.13.06'); assert.equal(snapshot.entries[0].observedPluginVersion, 'unknown'); @@ -614,6 +620,15 @@ test('support bundle browser telemetry includes persisted docker page snapshot a } }; } + if (storageKey === 'docker-refresh-key') { + return { + schemaVersion: 1, + verdict: { status: 'healthy', source: null, fullReloads: 1, renders: 1, reference: 'FVPLUS-DKR-REFRESH-001' }, + totals: { loadlist: 1, listview: 1, renders: 1, requests: 1, busyPasses: 0, foldersRestored: 0 }, + apiMismatch: { observedCount: 2, providerOnlyCount: 1, runtimeOnlyCount: 1, policy: 'native-structure-authoritative', hostReloadRequested: false }, + completedSessionCount: 2 + }; + } if (storageKey === 'dashboard-layout-docker-key') { return { schemaVersion: 1, @@ -684,6 +699,7 @@ test('support bundle browser telemetry includes persisted docker page snapshot a dockerBulkUpdateTrace: 'docker-trace-key', dockerRequestBundleTrace: 'docker-request-key', dockerTraceHealth: 'docker-trace-health-key', + dockerRefreshDiagnostics: 'docker-refresh-key', dashboardLayoutDocker: 'dashboard-layout-docker-key', dashboardLayoutVm: 'dashboard-layout-vm-key', dashboardLifecycle: 'dashboard-lifecycle-key', @@ -699,6 +715,7 @@ test('support bundle browser telemetry includes persisted docker page snapshot a const bulkUpdateTrace = collectors.collectDockerBulkUpdateTrace(); const requestBundleTrace = collectors.collectDockerRequestBundleTrace(); const traceHealth = collectors.collectDockerTraceHealth(); + const refreshDiagnostics = collectors.collectDockerRefreshDiagnostics(); const dashboardLayout = collectors.collectDashboardLayoutDiagnostics(); const dashboardLifecycle = collectors.collectDashboardLifecycleDiagnostics(); const vmLifecycle = collectors.collectVmLifecycleDiagnostics(); @@ -714,6 +731,9 @@ test('support bundle browser telemetry includes persisted docker page snapshot a assert.equal(traceHealth.available, true); assert.equal(traceHealth.bulkUpdateTrace.lastWriteSucceeded, true); assert.equal(traceHealth.requestBundleTrace.lastWriteSucceeded, true); + assert.equal(refreshDiagnostics.available, true); + assert.equal(refreshDiagnostics.verdict.status, 'healthy'); + assert.equal(refreshDiagnostics.apiMismatch.hostReloadRequested, false); assert.equal(dashboardLayout.docker.available, true); assert.equal(dashboardLayout.docker.folderColumns, 2); assert.equal(dashboardLayout.vm.available, false); diff --git a/tests/support-bundle-comparator.test.mjs b/tests/support-bundle-comparator.test.mjs index 0a31f5be..5f2448b1 100644 --- a/tests/support-bundle-comparator.test.mjs +++ b/tests/support-bundle-comparator.test.mjs @@ -41,3 +41,40 @@ test('comparator refuses full or unmanifested bundles', () => { /redaction manifest/ ); }); + +test('comparator includes refresh-loop, busy, API mismatch, mutation, and transfer regressions', () => { + const telemetry = (loadlist, mutationRecords, transferBytes, status) => ({ + dockerDiagnostics: { + refreshDiagnostics: { + verdict: { status, fullReloads: loadlist, renders: loadlist }, + totals: { loadlist, listview: loadlist, requests: loadlist }, + apiMismatch: { observedCount: loadlist, providerOnlyCount: 0, runtimeOnlyCount: loadlist }, + nativeBusy: { passCount: loadlist, cycleCount: 1 }, + completedSessions: Array.from({ length: loadlist }, () => ({ privateName: 'ui-private' })) + } + }, + runtimePerformance: { + surfaces: { + docker: { + workload: { + mutations: { records: mutationRecords }, + resources: { transferBytes } + } + } + } + } + }); + const report = compareSanitizedSupportBundles( + bundle({ uiTelemetry: telemetry(1, 100, 2048, 'healthy') }), + bundle({ uiTelemetry: telemetry(7, 8000, 1048576, 'confirmed') }) + ); + const paths = new Set(report.differences.map((row) => row.path)); + assert.equal(paths.has('uiTelemetry.dockerDiagnostics.refreshDiagnostics.verdict.status'), true); + assert.equal(paths.has('uiTelemetry.dockerDiagnostics.refreshDiagnostics.totals.loadlist'), true); + assert.equal(paths.has('uiTelemetry.dockerDiagnostics.refreshDiagnostics.apiMismatch.observedCount'), true); + assert.equal(paths.has('uiTelemetry.dockerDiagnostics.refreshDiagnostics.nativeBusy.passCount'), true); + assert.equal(paths.has('uiTelemetry.dockerDiagnostics.refreshDiagnostics.completedSessions[]#count'), true); + assert.equal(paths.has('uiTelemetry.runtimePerformance.surfaces.docker.workload.mutations.records'), true); + assert.equal(paths.has('uiTelemetry.runtimePerformance.surfaces.docker.workload.resources.transferBytes'), true); + assert.doesNotMatch(JSON.stringify(report), /ui-private/); +});