fix(statistics): preserve private account history safely

This commit is contained in:
Sucukdeluxe
2026-08-21 08:37:04 +02:00
parent 4de4f4a0be
commit dc3d9dfc10
6 changed files with 97 additions and 14 deletions
+1 -1
View File
@@ -1092,7 +1092,7 @@ export class AppController {
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
session: this.manager.getSession(), session: this.manager.getSession(),
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()), history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
statistics: this.manager.getStats().statistics!, statistics: this.manager.getStatisticsLedgerForBackup(),
remoteDiagnostics remoteDiagnostics
}); });
const encrypted = encryptBackup(JSON.stringify(payloadObj), passphrase); const encrypted = encryptBackup(JSON.stringify(payloadObj), passphrase);
+10 -1
View File
@@ -85,6 +85,7 @@ import {
addStatisticsOutcomeInPlace, addStatisticsOutcomeInPlace,
createStatisticsLedger, createStatisticsLedger,
loadStatisticsLedger, loadStatisticsLedger,
normalizeStatisticsLedger,
projectStatisticsLedger, projectStatisticsLedger,
saveStatisticsLedger, saveStatisticsLedger,
seedStatisticsDayProviderBytes seedStatisticsDayProviderBytes
@@ -2652,6 +2653,10 @@ export class DownloadManager extends EventEmitter {
return stats; return stats;
} }
public getStatisticsLedgerForBackup(now = nowMs()): StatisticsLedger {
return normalizeStatisticsLedger(this.statisticsLedger, now);
}
public getLiveTotalRuntimeMs(now = nowMs()): number { public getLiveTotalRuntimeMs(now = nowMs()): number {
return Math.max(0, this.runtimePersistedTotalMs + Math.max(0, now - this.runtimePersistedAt)); return Math.max(0, this.runtimePersistedTotalMs + Math.max(0, now - this.runtimePersistedAt));
} }
@@ -8234,12 +8239,16 @@ export class DownloadManager extends EventEmitter {
} }
const recordedAt = nowMs(); const recordedAt = nowMs();
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
const accountStatus = providerAccountId ? this.settings.debridAccountStatuses[providerAccountId] : undefined;
const statisticsAccountLabel = accountStatus?.username?.trim()
|| accountStatus?.email?.trim()
|| providerAccountLabel;
addStatisticsBytesInPlace(this.statisticsLedger, effectiveProvider, byteDelta, recordedAt); addStatisticsBytesInPlace(this.statisticsLedger, effectiveProvider, byteDelta, recordedAt);
this.rollingAccountStatistics.record( this.rollingAccountStatistics.record(
effectiveProvider, effectiveProvider,
byteDelta, byteDelta,
providerAccountId, providerAccountId,
providerAccountLabel, statisticsAccountLabel,
recordedAt recordedAt
); );
this.statisticsDirty = true; this.statisticsDirty = true;
+15 -1
View File
@@ -165,8 +165,22 @@ export function buildRedactedSettingsPayload(settings: AppSettings): Record<stri
} }
export function buildStatsPayload(snapshot: UiSnapshot): Record<string, unknown> { export function buildStatsPayload(snapshot: UiSnapshot): Record<string, unknown> {
const rolling24Hours = snapshot.stats.rolling24Hours;
return { return {
session: snapshot.stats, session: {
...snapshot.stats,
...(rolling24Hours ? {
rolling24Hours: {
from: rolling24Hours.from,
to: rolling24Hours.to,
downloadedBytes: rolling24Hours.downloadedBytes,
accounts: rolling24Hours.accounts.map((account) => ({
provider: account.provider,
bytes: account.bytes
}))
}
} : {})
},
totals: { totals: {
totalPackages: Object.keys(snapshot.session.packages).length, totalPackages: Object.keys(snapshot.session.packages).length,
totalItems: Object.keys(snapshot.session.items).length, totalItems: Object.keys(snapshot.session.items).length,
+9
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload"; import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
import { readFileSync } from "node:fs";
import type { AppSettings, SessionState, HistoryEntry, StatisticsLedger } from "../src/shared/types"; import type { AppSettings, SessionState, HistoryEntry, StatisticsLedger } from "../src/shared/types";
function settings(overrides: Partial<AppSettings> = {}): AppSettings { function settings(overrides: Partial<AppSettings> = {}): AppSettings {
@@ -17,6 +18,14 @@ const statistics: StatisticsLedger = { version: 2, startedAt: 1, days: [], minut
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics }; const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics };
describe("buildBackupPayload — default is settings-only", () => { describe("buildBackupPayload — default is settings-only", () => {
it("exports the full statistics ledger instead of the renderer projection", () => {
const source = readFileSync(new URL("../src/main/app-controller.ts", import.meta.url), "utf8");
const exportBlock = source.slice(source.indexOf("public exportBackup"), source.indexOf("public async exportOnlineBackup"));
expect(exportBlock).toContain("getStatisticsLedgerForBackup()");
expect(exportBlock).not.toContain("getStats().statistics");
});
it("omits session AND history when backupIncludeDownloads is false (default)", () => { it("omits session AND history when backupIncludeDownloads is false (default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings }); const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect(p.kind).toBe("settings-only"); expect(p.kind).toBe("settings-only");
+30 -3
View File
@@ -13362,7 +13362,33 @@ describe("download manager", () => {
realDebridApiTokens: serializeRealDebridApiAccounts([ realDebridApiTokens: serializeRealDebridApiAccounts([
{ id: "rda_one", token: "token-one" }, { id: "rda_one", token: "token-one" },
{ id: "rda_two", token: "token-two" } { id: "rda_two", token: "token-two" }
]) ]),
debridAccountStatuses: {
rda_one: {
accountId: "rda_one",
provider: "realdebrid" as const,
label: "Real-Debrid API 1",
maskedLogin: "Geschützter API-Token",
valid: true,
isPremium: true,
premiumUntilMs: null,
username: "xSucukDE",
message: "Premium aktiv",
checkedAt: Date.now()
},
rda_two: {
accountId: "rda_two",
provider: "realdebrid" as const,
label: "Real-Debrid API 2",
maskedLogin: "Geschützter API-Token",
valid: true,
isPremium: true,
premiumUntilMs: null,
username: "Backup",
message: "Premium aktiv",
checkedAt: Date.now()
}
}
}; };
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
const internal = manager as unknown as { const internal = manager as unknown as {
@@ -13381,11 +13407,12 @@ describe("download manager", () => {
expect(stats.rolling24Hours).toMatchObject({ downloadedBytes: 125 }); expect(stats.rolling24Hours).toMatchObject({ downloadedBytes: 125 });
expect(stats.rolling24Hours?.accounts.map((account) => [account.id, account.label, account.bytes])).toEqual([ expect(stats.rolling24Hours?.accounts.map((account) => [account.id, account.label, account.bytes])).toEqual([
["rda_two", "Secondary", 75], ["rda_two", "Backup", 75],
["rda_one", "Primary", 50] ["rda_one", "xSucukDE", 50]
]); ]);
expect(stats.statistics?.minutes).toEqual([]); expect(stats.statistics?.minutes).toEqual([]);
expect(internal.statisticsLedger.minutes).toHaveLength(1); expect(internal.statisticsLedger.minutes).toHaveLength(1);
expect(manager.getStatisticsLedgerForBackup().minutes).toHaveLength(1);
}); });
it("keeps rolling traffic on session reset and clears it on total reset", () => { it("keeps rolling traffic on session reset and clears it on total reset", () => {
+25 -1
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants"; import { defaultSettings } from "../src/main/constants";
import { buildAccountSummary } from "../src/main/support-data"; import { buildAccountSummary, buildStatsPayload } from "../src/main/support-data";
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
import { createVisualFixture } from "./visual/fixtures";
describe("Real-Debrid support summary", () => { describe("Real-Debrid support summary", () => {
it("reports pool counts without exposing account IDs or credentials", () => { it("reports pool counts without exposing account IDs or credentials", () => {
@@ -29,4 +30,27 @@ describe("Real-Debrid support summary", () => {
expect(serialized).not.toContain("rdw_private"); expect(serialized).not.toContain("rdw_private");
expect(serialized).not.toContain("secret-"); expect(serialized).not.toContain("secret-");
}); });
it("removes rolling account IDs and labels from support statistics", () => {
const snapshot = structuredClone(createVisualFixture("empty").snapshot);
snapshot.stats.rolling24Hours = {
from: 1,
to: 2,
downloadedBytes: 4_096,
accounts: [{
id: "rdw_private_account",
provider: "realdebrid",
label: "private-user@example.test",
bytes: 4_096
}]
};
const payload = buildStatsPayload(snapshot);
const serialized = JSON.stringify(payload);
expect(serialized).not.toContain("rdw_private_account");
expect(serialized).not.toContain("private-user@example.test");
expect(serialized).toContain("realdebrid");
expect(serialized).toContain("4096");
});
}); });