From f14d16689832d6d650165afe1ae01326acdc059d Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 31 Aug 2026 18:21:58 +0200 Subject: [PATCH] fix: align live speed and split package reset --- PROJECT_MEMORY.md | 13 +- src/main/download-manager.ts | 161 ++++++++++-------- src/renderer/App.tsx | 25 ++- src/renderer/i18n.ts | 2 +- .../views/downloads/downloads-model.ts | 24 ++- tests/app-shell.test.tsx | 11 ++ tests/download-manager.test.ts | 97 +++++++++++ tests/downloads-view.test.tsx | 17 ++ tests/i18n.test.ts | 5 +- 9 files changed, 270 insertions(+), 85 deletions(-) diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index 10986bf..d43d9cb 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -155,6 +155,14 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält - Item-Logs melden den Grund `readback_mismatch` beziehungsweise `zero_run_mismatch` mit Range- und Chunkgrenzen. Die bisher irreführende Meldung „Integritätsprüfung bestanden“ bei fehlender `.sfv`-, `.md5`- oder `.sha1`-Prüfsumme wurde in „Integritätsprüfung nicht ausgeführt“ mit Begründung geändert. - Entpack-, Passwort- und Nachbearbeitungslogik wurden nicht verändert. `v2.0.79` wurde nach ausdrücklicher Freigabe auf GitHub und Forgejo veröffentlicht; Installation und produktiver Neustart sind nicht Bestandteil des Releases. +## Noch unveröffentlicht nach v2.0.79 + +- Datei-, Paket-, Kopfzeilen- und Seitenleisten-Geschwindigkeit verwenden nun dieselbe rollierende Ein-Sekunden-Trafficbasis. Dafür werden die bereits für den globalen und paketbezogenen Durchsatz erfassten Bytes zusätzlich pro Item aggregiert; die bisherige kurzzeitige 0,5-Sekunden-Proxyspitze kann nicht mehr als widersprüchlicher Dateiwert stehen bleiben. +- Die Geschwindigkeitsprojektion verändert keine internen Session-Items. Nur tatsächlich abweichende Snapshot-Items werden kopiert, damit Restmengen-Benachrichtigungen und große Queues ihren bisherigen Laufzeitzustand beziehungsweise ihre Snapshot-Kosten behalten. +- Das Kontextmenü eines Oberpakets trennt jetzt „Nur fehlerhafte Dateien zurücksetzen“ von „Gesamtes Paket zurücksetzen“. Der selektive Weg erfasst fehlgeschlagene Downloads und als Entpackfehler markierte Items, verwendet den vorhandenen Item-Reset und lässt erfolgreiche beziehungsweise bereits entpackte Dateien unverändert. +- Der Statistikpunkt „Fehler zurücksetzen“ verwendet dieselbe Fehlerklassifizierung und erfasst dadurch neben Downloadfehlern auch sichtbare Entpackfehler. +- Entpack-, Passwort- und Nachbearbeitungslogik wurden nicht verändert. Dieser Stand wurde noch nicht als neue Version veröffentlicht und nicht auf einem produktiven System installiert oder gestartet. + ## Start-, Build- und Testbefehle ```powershell @@ -186,6 +194,8 @@ npm exec -- tsc --noEmit ## Verifizierungen vom 31. August 2026 +- Noch unveröffentlichter Anzeige-/Reset-Fix nach `v2.0.79`: 265 von 265 Download-Manager-Tests sowie 372 von 372 Downloadansichts-/Übersetzungstests erfolgreich. Der vollständige Clientlauf erreichte 2.690 erfolgreiche und 4 übersprungene Tests; zusätzlich zu den zwei bekannten Windows-Symlink-Fixtures deckte er eine Snapshot-Seitenwirkung auf. Nach deren Korrektur bestanden die 10 betroffenen Geschwindigkeits-, Restmengen-, Reset-, Shell- und Übersetzungsregressionen vollständig. Der neue Dateisystemtest bestätigt zusätzlich, dass der selektive Paket-Reset erfolgreiche Dateien erhält und nur die beiden ausgewählten Fehlerdateien entfernt und neu einreiht. +- Nach dem Anzeige-/Reset-Fix erfolgreich: TypeScript, Main-Build, Renderer-Build, Node-Self-Check und 16 von 16 Backup-API-Tests. Die bekannte Vite-Warnung zum rund 577 KiB großen Renderer-Chunk bleibt bestehen. - Veröffentlichung `v2.0.79`: Der annotierte Tag zeigt auf beiden Git-Remotes exakt auf `04e5ab71d2e77e68256c77b6d0fff48fa86188e8`. GitHub und Forgejo veröffentlichen denselben stabilen Release mit denselben sechs Assets. Alle zwölf erneut heruntergeladenen Dateien stimmen jeweils in Größe und SHA-256 exakt mit den lokalen Originalen überein; GitHub führt `v2.0.79` als Latest Release. - Release-Build `v2.0.79`: Installer und Portable-Datei wurden aus dem versionierten Release-Vorbereitungsstand neu erzeugt. Die Release-Prüfung bestätigte Paket- und Bundle-Version, Update-Metadaten, Artefaktnamen, Dateigröße und SHA-512 des Installers, Icon, Lizenzdateien sowie den entpackten Inhalt beider EXE-Archive. Von den 24 Public-Release-Metadatentests waren 22 erfolgreich; nur die zwei unter Windows ohne Symlink-Berechtigung nicht ausführbaren Fixtures endeten vor ihrer Produktassertion mit `EPERM`. - SHA-256 des `v2.0.79`-Setups: `eccfe029f32aaf198c0597fdbb7874540ead39737616879baf1a17bebdf13450`; SHA-256 der Portable-Datei: `2f32b825e03a36ae842f2aa2b9a73c420657d812046c23bf4cbacf6a63ae2333`. @@ -264,10 +274,11 @@ npm exec -- tsc --noEmit - Der faire rollierende Proxy-Scheduler und der bytebasierte Paketfortschritt sind als `v2.0.77` veröffentlicht. Eine Serverinstallation oder ein produktiver Neustart wurde nicht vorgenommen. - Der 32/40-Proxybereich und der synchrone Live-Takt von „Sitzung“ und „Verbleibend“ sind als `v2.0.78` veröffentlicht. Eine Serverinstallation oder ein produktiver Neustart wurde nicht vorgenommen. - Der Proxy-Readback- und Nullbereich-Hotfix ist als `v2.0.79` auf GitHub und Forgejo veröffentlicht, aber noch nicht auf einem produktiven System installiert oder gestartet. +- Die einheitliche Geschwindigkeitsanzeige und die getrennten Paket-Reset-Optionen sind noch unveröffentlicht. Für eine neue Version, Installation oder einen produktiven Neustart ist eine aktuelle ausdrückliche Freigabe erforderlich. ## Nächste sinnvolle Schritte -1. Nach Veröffentlichung und einer getrennt freigegebenen Serverinstallation denselben problematischen Download erneut durchführen und Item-Log, SHA-256 sowie `UnRAR t` prüfen. +1. Nach ausdrücklicher Release-Freigabe die unveröffentlichte Geschwindigkeits-/Reset-Korrektur versionieren, bauen, veröffentlichen und erst nach getrennt freigegebener Serverinstallation am echten Queue-Fall prüfen. 2. Nach einer getrennt freigegebenen Serverinstallation den Ablauf Real-Debrid-Web-Download, Hauptfenster schließen und unmittelbar neu starten am echten Zielsystem verifizieren; vor jedem Eingriff Prozessbaum und Logtail sichern. 3. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen. 4. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe. diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index d6de150..016a0a0 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1874,7 +1874,7 @@ export class DownloadManager extends EventEmitter { private persistTimer: NodeJS.Timeout | null = null; - private speedEvents: Array<{ at: number; bytes: number; pid: string }> = []; + private speedEvents: Array<{ at: number; bytes: number; pid: string; itemId: string }> = []; private summary: DownloadSummary | null = null; @@ -1883,7 +1883,9 @@ export class DownloadManager extends EventEmitter { private stateEmitTimer: NodeJS.Timeout | null = null; private lastStateEmitAt = 0; - private speedBytesLastWindow = 0; + private speedBytesLastWindow = 0; + + private speedBytesPerItem = new Map(); private sessionDownloadedBytes = 0; private sessionCompletedFiles = 0; @@ -2808,7 +2810,15 @@ export class DownloadManager extends EventEmitter { const reconnectMs = Math.max(0, this.session.reconnectUntil - now); - const snapshotSession = cloneSession(this.session); + const snapshotSession = cloneSession(this.session); + for (const [itemId, item] of Object.entries(snapshotSession.items)) { + const speedBps = this.session.running && !paused && item.status === "downloading" + ? Math.floor((this.speedBytesPerItem.get(item.id) ?? 0) / SPEED_WINDOW_SECONDS) + : 0; + if (item.speedBps !== speedBps) { + snapshotSession.items[itemId] = { ...item, speedBps }; + } + } let rendererState: ReturnType; if (this.settingsSnapshotCache && now - this.settingsSnapshotCacheAt < 400) { rendererState = this.settingsSnapshotCache; @@ -2964,10 +2974,11 @@ export class DownloadManager extends EventEmitter { changed = true; } if (this.speedEvents.length > 0 || this.speedBytesLastWindow !== 0 || this.speedBytesPerPackage.size > 0) { - this.speedEvents = []; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); - this.speedEventsHead = 0; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedBytesPerItem.clear(); + this.speedEventsHead = 0; changed = true; } if (changed) { @@ -3070,10 +3081,11 @@ export class DownloadManager extends EventEmitter { this.lastGlobalProgressBytes = 0; this.lastGlobalProgressAt = now; this.speedEvents = []; - this.speedEventsHead = 0; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); - this.summary = null; + this.speedEventsHead = 0; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedBytesPerItem.clear(); + this.summary = null; this.invalidateStatsCache(); this.persistSoon(); this.emitState(true); @@ -3390,10 +3402,11 @@ export class DownloadManager extends EventEmitter { this.claimedTargetPathByItem.clear(); this.itemContributedBytes.clear(); this.speedEvents = []; - this.speedEventsHead = 0; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); - this.packagePostProcessTasks.clear(); + this.speedEventsHead = 0; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedBytesPerItem.clear(); + this.packagePostProcessTasks.clear(); this.packagePostProcessAbortControllers.clear(); this.packageDeferredPostProcessAbortControllers.clear(); this.packageDeferredPostProcessTasks.clear(); @@ -6339,11 +6352,12 @@ export class DownloadManager extends EventEmitter { this.session.summaryText = ""; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; - this.speedEvents = []; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); - this.speedEventsHead = 0; - this.lastGlobalProgressBytes = 0; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedBytesPerItem.clear(); + this.speedEventsHead = 0; + this.lastGlobalProgressBytes = 0; this.lastGlobalProgressAt = nowMs(); this.lastReconnectMarkAt = 0; this.consecutiveReconnects = 0; @@ -6452,11 +6466,12 @@ export class DownloadManager extends EventEmitter { this.session.summaryText = ""; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; - this.speedEvents = []; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); - this.speedEventsHead = 0; - this.lastGlobalProgressBytes = 0; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedBytesPerItem.clear(); + this.speedEventsHead = 0; + this.lastGlobalProgressBytes = 0; this.lastGlobalProgressAt = nowMs(); this.lastReconnectMarkAt = 0; this.consecutiveReconnects = 0; @@ -6569,11 +6584,12 @@ export class DownloadManager extends EventEmitter { this.session.summaryText = ""; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; - this.speedEvents = []; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); - this.speedEventsHead = 0; - this.lastGlobalProgressBytes = 0; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedBytesPerItem.clear(); + this.speedEventsHead = 0; + this.lastGlobalProgressBytes = 0; this.lastGlobalProgressAt = nowMs(); this.summary = null; this.nonResumableActive = 0; @@ -6610,9 +6626,10 @@ export class DownloadManager extends EventEmitter { this.session.reconnectReason = ""; this.lastReconnectMarkAt = 0; this.consecutiveReconnects = 0; - this.speedEvents = []; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedBytesPerItem.clear(); this.speedEventsHead = 0; this.lastGlobalProgressBytes = 0; this.lastGlobalProgressAt = nowMs(); @@ -6652,11 +6669,12 @@ export class DownloadManager extends EventEmitter { this.retryStateByItem.clear(); this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressAt = nowMs(); - this.speedEvents = []; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); - this.speedEventsHead = 0; - if (!keepExtraction) { + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedBytesPerItem.clear(); + this.speedEventsHead = 0; + if (!keepExtraction) { this.abortPostProcessing("stop"); for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); } this.packagePostProcessWaiters = []; @@ -6774,11 +6792,12 @@ export class DownloadManager extends EventEmitter { } } - this.speedEvents = []; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); - this.speedEventsHead = 0; - this.runItemIds.clear(); + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedBytesPerItem.clear(); + this.speedEventsHead = 0; + this.runItemIds.clear(); this.runPackageIds.clear(); this.runOutcomes.clear(); this.runCompletedPackages.clear(); @@ -6815,10 +6834,11 @@ export class DownloadManager extends EventEmitter { this.session.paused = !this.session.paused; if (!wasPaused && this.session.paused) { - this.speedEvents = []; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); - this.speedEventsHead = 0; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedBytesPerItem.clear(); + this.speedEventsHead = 0; } if (wasPaused && !this.session.paused) { @@ -7198,17 +7218,20 @@ export class DownloadManager extends EventEmitter { }, emitDelay); } - private speedEventsHead = 0; - private speedBytesPerPackage = new Map(); + private speedEventsHead = 0; + private speedBytesPerPackage = new Map(); private pruneSpeedEvents(now: number): void { const cutoff = now - SPEED_WINDOW_SECONDS * 1000; while (this.speedEventsHead < this.speedEvents.length && this.speedEvents[this.speedEventsHead].at < cutoff) { const ev = this.speedEvents[this.speedEventsHead]; this.speedBytesLastWindow = Math.max(0, this.speedBytesLastWindow - ev.bytes); - const pkgBytes = (this.speedBytesPerPackage.get(ev.pid) ?? 0) - ev.bytes; - if (pkgBytes <= 0) this.speedBytesPerPackage.delete(ev.pid); - else this.speedBytesPerPackage.set(ev.pid, pkgBytes); + const pkgBytes = (this.speedBytesPerPackage.get(ev.pid) ?? 0) - ev.bytes; + if (pkgBytes <= 0) this.speedBytesPerPackage.delete(ev.pid); + else this.speedBytesPerPackage.set(ev.pid, pkgBytes); + const itemBytes = (this.speedBytesPerItem.get(ev.itemId) ?? 0) - ev.bytes; + if (itemBytes <= 0) this.speedBytesPerItem.delete(ev.itemId); + else this.speedBytesPerItem.set(ev.itemId, itemBytes); this.speedEventsHead += 1; } if (this.speedEventsHead > 200) { @@ -7219,7 +7242,7 @@ export class DownloadManager extends EventEmitter { private lastSpeedPruneAt = 0; - private recordSpeed(bytes: number, packageId: string = ""): void { + private recordSpeed(bytes: number, packageId: string = "", itemId: string = ""): void { const now = nowMs(); if (!Number.isFinite(bytes) || bytes <= 0) { return; @@ -7231,13 +7254,14 @@ export class DownloadManager extends EventEmitter { } const bucket = now - (now % 120); const last = this.speedEvents[this.speedEvents.length - 1]; - if (last && last.at === bucket && last.pid === packageId) { - last.bytes += bytes; - } else { - this.speedEvents.push({ at: bucket, bytes, pid: packageId }); - } - this.speedBytesLastWindow += bytes; - this.speedBytesPerPackage.set(packageId, (this.speedBytesPerPackage.get(packageId) ?? 0) + bytes); + if (last && last.at === bucket && last.pid === packageId && last.itemId === itemId) { + last.bytes += bytes; + } else { + this.speedEvents.push({ at: bucket, bytes, pid: packageId, itemId }); + } + this.speedBytesLastWindow += bytes; + this.speedBytesPerPackage.set(packageId, (this.speedBytesPerPackage.get(packageId) ?? 0) + bytes); + this.speedBytesPerItem.set(itemId, (this.speedBytesPerItem.get(itemId) ?? 0) + bytes); if (now - this.lastSpeedPruneAt >= 1500) { this.pruneSpeedEvents(now); this.lastSpeedPruneAt = now; @@ -11024,8 +11048,6 @@ export class DownloadManager extends EventEmitter { origin_http_error: "Downloadserver hat die Segmentanfrage abgelehnt", segment_failed: "Proxy-Segmente konnten nicht vollständig geladen werden" }; - let proxyWindowBytes = 0; - let proxyWindowStartedAt = nowMs(); let lastProxyUiEmitAt = 0; item.fullStatus = `Proxy-Download startet (Gesamtlimit ${this.settings.proxyConnectionsPerDownload})`; item.updatedAt = nowMs(); @@ -11058,14 +11080,13 @@ export class DownloadManager extends EventEmitter { } }, onTrafficBytes: (bytes) => { - proxyWindowBytes += bytes; this.recordProviderDownloadedBytes( item.provider, bytes, item.providerAccountId, item.providerAccountLabel ); - this.recordSpeed(bytes, item.packageId); + this.recordSpeed(bytes, item.packageId, active.itemId); }, onProgress: (deltaBytes, downloadedBytes, totalBytes) => { this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes + deltaBytes); @@ -11076,19 +11097,16 @@ export class DownloadManager extends EventEmitter { Math.max(0, (this.itemContributedBytes.get(active.itemId) || 0) + deltaBytes) ); const nowTick = nowMs(); - const elapsed = Math.max((nowTick - proxyWindowStartedAt) / 1000, 0.2); item.status = this.session.paused ? "paused" : "downloading"; item.downloadedBytes = downloadedBytes; item.totalBytes = totalBytes; item.progressPercent = Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100))); - item.speedBps = this.session.paused ? 0 : Math.max(0, Math.floor(proxyWindowBytes / elapsed)); + item.speedBps = this.session.paused + ? 0 + : Math.floor((this.speedBytesPerItem.get(active.itemId) ?? 0) / SPEED_WINDOW_SECONDS); item.fullStatus = this.session.paused ? "Pausiert" : `Proxy-Download läuft (Gesamtlimit ${this.settings.proxyConnectionsPerDownload})`; - if (elapsed >= 0.5) { - proxyWindowStartedAt = nowTick; - proxyWindowBytes = 0; - } if (nowTick - lastProxyUiEmitAt >= DOWNLOAD_LIVE_UPDATE_INTERVAL_MS || deltaBytes < 0) { item.updatedAt = nowTick; this.emitState(); @@ -11751,7 +11769,7 @@ export class DownloadManager extends EventEmitter { item.providerAccountLabel ); this.itemContributedBytes.set(active.itemId, (this.itemContributedBytes.get(active.itemId) || 0) + buffer.length); - this.recordSpeed(buffer.length, item.packageId); + this.recordSpeed(buffer.length, item.packageId, active.itemId); throughputWindowBytes += buffer.length; if (completionPlan.canFinishEarly && completionPlan.expectedTotal && written >= completionPlan.expectedTotal) { @@ -14630,6 +14648,7 @@ export class DownloadManager extends EventEmitter { this.speedEventsHead = 0; this.speedBytesLastWindow = 0; this.speedBytesPerPackage.clear(); + this.speedBytesPerItem.clear(); this.resetGlobalSpeedLimitState(); this.nonResumableActive = 0; this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 25c9432..3dbc618 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -111,7 +111,7 @@ import { StatisticsSidebarStatus, type StatisticsViewActions } from "./views/statistics/StatisticsView"; -import { buildDownloadsViewModel, formatDownloadEta, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueStatusMetrics, getDownloadSpeedBps, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; +import { buildDownloadsViewModel, formatDownloadEta, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueStatusMetrics, getDownloadSpeedBps, getPackageErrorItemIds, isDownloadItemError, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable"; import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog"; import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, DOWNLOAD_COLUMN_MOVE_DURATION_MS, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag"; @@ -5684,7 +5684,7 @@ export function App(): ReactElement { }, onResetErrors: () => { const failedIds = Object.values(snapshot.session.items) - .filter((item) => item.status === "failed") + .filter(isDownloadItemError) .map((item) => item.id); if (failedIds.length === 0) { return; @@ -7119,12 +7119,21 @@ export function App(): ReactElement { else { executeDeleteSelection(ids); } }}>Ausgewählte Dateien entfernen ({selectedItemIds.length}) )} - {hasPackages && !contextMenu.itemId && ( - - )} + {hasPackages && !contextMenu.itemId && (() => { + const errorItemIds = getPackageErrorItemIds(selectedPackageIds, snapshot.session.packages, snapshot.session.items); + return ( + <> + + + + ); + })()} {contextMenu.itemId && (