feat(statistics): show rolling account usage
This commit is contained in:
@@ -22,8 +22,9 @@ export interface StatisticsViewProps {
|
||||
}
|
||||
|
||||
const rangeItems: Array<{ id: StatisticsRange; label: string }> = [
|
||||
{ id: "session", label: "Sitzung" },
|
||||
{ id: "today", label: "Heute" },
|
||||
{ id: "session", label: "Sitzung" },
|
||||
{ id: "today", label: "Heute" },
|
||||
{ id: "last24", label: "Letzte 24 Stunden" },
|
||||
{ id: "week", label: "Sieben Tage" },
|
||||
{ id: "month", label: "30 Tage" },
|
||||
{ id: "all", label: "Gesamt" }
|
||||
@@ -69,6 +70,9 @@ function providerScopeLabel(scope: StatisticsProviderScope | null): string {
|
||||
if (scope === "today") {
|
||||
return "Heute";
|
||||
}
|
||||
if (scope === "last24") {
|
||||
return "Letzte 24 Stunden";
|
||||
}
|
||||
if (scope === "week") {
|
||||
return "Sieben Tage";
|
||||
}
|
||||
@@ -88,6 +92,9 @@ function emptyProviderMessage(model: StatisticsViewModel): string {
|
||||
if (model.providerScope === "today") {
|
||||
return "Heute wurden noch keine Providerbytes erfasst.";
|
||||
}
|
||||
if (model.providerScope === "last24") {
|
||||
return "In den vergangenen 24 Stunden wurde noch kein Account-Traffic erfasst.";
|
||||
}
|
||||
if (model.providerScope === "week" || model.providerScope === "month") {
|
||||
return "In diesem Zeitraum wurden noch keine Providerwerte erfasst.";
|
||||
}
|
||||
@@ -144,7 +151,7 @@ export function StatisticsSidebarStatus({ model }: Pick<StatisticsViewProps, "mo
|
||||
metrics.files.available ? `Dateien: ${formatMetric(metrics.files, "count")}` : null,
|
||||
metrics.successRate.available ? `Erfolg: ${formatMetric(metrics.successRate, "percent")}` : null,
|
||||
metrics.errors.available ? `Fehler: ${formatMetric(metrics.errors, "count")}` : null,
|
||||
model.providerScope ? `Provider: ${model.providers.length}` : null
|
||||
model.providerScope ? `${model.usageKind === "accounts" ? "Accounts" : "Provider"}: ${model.providers.length}` : null
|
||||
].filter((value): value is string => value !== null);
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
@@ -156,8 +163,10 @@ export function StatisticsSidebarStatus({ model }: Pick<StatisticsViewProps, "mo
|
||||
);
|
||||
}
|
||||
|
||||
export function StatisticsContent({ model, actions, chart }: StatisticsViewProps): ReactElement {
|
||||
return (
|
||||
export function StatisticsContent({ model, actions, chart }: StatisticsViewProps): ReactElement {
|
||||
const usageHeading = model.usageKind === "accounts" ? "Accounts" : "Provider";
|
||||
const usageColumnHeading = model.usageKind === "accounts" ? "Account" : "Provider";
|
||||
return (
|
||||
<section aria-label="Statistik-Dashboard" className="statistics-content">
|
||||
<header className="statistics-heading">
|
||||
<div>
|
||||
@@ -193,14 +202,14 @@ export function StatisticsContent({ model, actions, chart }: StatisticsViewProps
|
||||
<div className="statistics-chart-canvas">{chart}</div>
|
||||
</section>
|
||||
|
||||
<section className="statistics-providers">
|
||||
<div className="statistics-section-heading">
|
||||
<h3>Provider</h3>
|
||||
<span>{providerScopeLabel(model.providerScope)}</span>
|
||||
</div>
|
||||
<div aria-label="Provider-Nutzung" className="statistics-provider-table" role="table">
|
||||
<div className="statistics-provider-header" role="row">
|
||||
<span role="columnheader">Provider</span>
|
||||
<section className="statistics-providers">
|
||||
<div className="statistics-section-heading">
|
||||
<h3>{usageHeading}</h3>
|
||||
<span>{providerScopeLabel(model.providerScope)}</span>
|
||||
</div>
|
||||
<div aria-label={`${usageColumnHeading}-Nutzung`} className="statistics-provider-table" role="table">
|
||||
<div className="statistics-provider-header" role="row">
|
||||
<span role="columnheader">{usageColumnHeading}</span>
|
||||
<span role="columnheader">Daten</span>
|
||||
<span role="columnheader">Ergebnisse</span>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { aggregateStatisticsRange, type StatisticsAggregate } from "../../../shared/statistics-aggregation";
|
||||
import type { DebridProvider, DownloadItem, DownloadSummary, StatisticsProviderBucket, UiSnapshot } from "../../../shared/types";
|
||||
|
||||
export type StatisticsRange = "session" | "today" | "week" | "month" | "all";
|
||||
export type StatisticsRange = "session" | "today" | "last24" | "week" | "month" | "all";
|
||||
export type StatisticsCoverage = "partial" | "unavailable";
|
||||
export type StatisticsSessionState = "empty" | "idle" | "active" | "paused";
|
||||
export type StatisticsProviderScope = "current-queue" | "today" | "week" | "month" | "all";
|
||||
export type StatisticsProviderScope = "current-queue" | "today" | "last24" | "week" | "month" | "all";
|
||||
export type StatisticsUsageKind = "providers" | "accounts";
|
||||
export type StatisticsMetricTone = "danger";
|
||||
|
||||
export interface StatisticsMetric {
|
||||
@@ -15,7 +16,7 @@ export interface StatisticsMetric {
|
||||
}
|
||||
|
||||
export interface StatisticsProviderRow {
|
||||
id: DebridProvider;
|
||||
id: string;
|
||||
label: string;
|
||||
bytes: number;
|
||||
completed: number | null;
|
||||
@@ -37,6 +38,7 @@ export interface StatisticsViewModel {
|
||||
sessionState: StatisticsSessionState;
|
||||
metrics: StatisticsMetrics;
|
||||
providerScope: StatisticsProviderScope | null;
|
||||
usageKind: StatisticsUsageKind;
|
||||
providers: StatisticsProviderRow[];
|
||||
errorResetAvailable: boolean;
|
||||
}
|
||||
@@ -179,6 +181,16 @@ function deriveUsageProviders(
|
||||
return sortProviderRows(rows);
|
||||
}
|
||||
|
||||
function deriveRollingAccounts(snapshot: UiSnapshot): StatisticsProviderRow[] {
|
||||
return (snapshot.stats.rolling24Hours?.accounts ?? []).map((account) => ({
|
||||
id: account.id,
|
||||
label: `${providerLabels[account.provider]} · ${account.label}`,
|
||||
bytes: normalizeNonNegative(account.bytes),
|
||||
completed: null,
|
||||
failed: null
|
||||
})).sort((left, right) => right.bytes - left.bytes || left.label.localeCompare(right.label, "de-DE", { sensitivity: "base" }) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function aggregateMetrics(aggregate: StatisticsAggregate, sourceLabel: string): StatisticsMetrics {
|
||||
return {
|
||||
downloadedBytes: availableMetric(aggregate.downloadedBytes, sourceLabel),
|
||||
@@ -207,6 +219,30 @@ export function buildStatisticsViewModel(
|
||||
): StatisticsViewModel {
|
||||
const sessionState = deriveSessionState(snapshot);
|
||||
|
||||
if (range === "last24") {
|
||||
const rolling = snapshot.stats.rolling24Hours;
|
||||
const hasFullCoverage = nowMs - Math.max(0, snapshot.stats.statistics?.startedAt ?? nowMs) >= 24 * 60 * 60 * 1_000;
|
||||
return {
|
||||
range,
|
||||
coverage: "partial",
|
||||
message: hasFullCoverage
|
||||
? "Account-Traffic der vergangenen 24 Stunden."
|
||||
: "Letzte 24 Stunden: Werte seit Beginn der Aufzeichnung.",
|
||||
sessionState,
|
||||
metrics: {
|
||||
downloadedBytes: availableMetric(rolling?.downloadedBytes ?? 0, "Letzte 24 Stunden"),
|
||||
files: unavailableMetric("Dateien werden nicht minutengenau nach Account erfasst"),
|
||||
successRate: unavailableMetric("Ergebnisse werden nicht minutengenau nach Account erfasst"),
|
||||
averageSpeedBps: unavailableMetric("Aktive Downloadzeit wird nur tagesweise erfasst"),
|
||||
errors: unavailableMetric("Fehler werden nicht minutengenau nach Account erfasst")
|
||||
},
|
||||
providerScope: "last24",
|
||||
usageKind: "accounts",
|
||||
providers: deriveRollingAccounts(snapshot),
|
||||
errorResetAvailable: false
|
||||
};
|
||||
}
|
||||
|
||||
if (range === "today" || range === "week" || range === "month") {
|
||||
const days = range === "today" ? 1 : range === "week" ? 7 : 30;
|
||||
const aggregate = aggregateStatisticsRange(snapshot.stats.statistics, days, nowMs);
|
||||
@@ -220,6 +256,7 @@ export function buildStatisticsViewModel(
|
||||
sessionState,
|
||||
metrics: aggregateMetrics(aggregate, label),
|
||||
providerScope: range,
|
||||
usageKind: "providers",
|
||||
providers: deriveUsageProviders(
|
||||
Object.fromEntries(Object.entries(aggregate.providers).map(([provider, bucket]) => [provider, bucket?.bytes ?? 0])),
|
||||
aggregate.providers
|
||||
@@ -245,6 +282,7 @@ export function buildStatisticsViewModel(
|
||||
errors: recordedMetrics.errors
|
||||
},
|
||||
providerScope: "all",
|
||||
usageKind: "providers",
|
||||
providers,
|
||||
errorResetAvailable: false
|
||||
};
|
||||
@@ -278,6 +316,7 @@ export function buildStatisticsViewModel(
|
||||
errors: availableMetric(failed, resultSource, failed > 0 ? "danger" : undefined)
|
||||
},
|
||||
providerScope: "current-queue",
|
||||
usageKind: "providers",
|
||||
providers: deriveQueueProviders(items),
|
||||
errorResetAvailable: queueResults.failed > 0
|
||||
};
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
type StatisticsMetric
|
||||
} from "../src/renderer/views/statistics/statistics-model";
|
||||
import {
|
||||
StatisticsContent,
|
||||
StatisticsSidebar,
|
||||
StatisticsContent,
|
||||
StatisticsSidebar,
|
||||
StatisticsSidebarStatus,
|
||||
StatisticsView,
|
||||
type StatisticsViewActions
|
||||
} from "../src/renderer/views/statistics/StatisticsView";
|
||||
@@ -210,7 +211,63 @@ describe("statistics model", () => {
|
||||
["realdebrid", 500]
|
||||
]);
|
||||
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[0, 1], [1, 0]]);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows rolling account traffic with unavailable day-scoped metrics", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.stats.statistics = {
|
||||
version: 2,
|
||||
startedAt: now - (2 * 60 * 60 * 1_000),
|
||||
days: [],
|
||||
minutes: []
|
||||
};
|
||||
snapshot.stats.rolling24Hours = {
|
||||
from: now - (24 * 60 * 60 * 1_000),
|
||||
to: now,
|
||||
downloadedBytes: 125,
|
||||
accounts: [
|
||||
{ id: "rdw_two", provider: "realdebrid", label: "Secondary", bytes: 75 },
|
||||
{ id: "rdw_one", provider: "realdebrid", label: "Primary", bytes: 50 }
|
||||
]
|
||||
};
|
||||
|
||||
const model = buildStatisticsViewModel(snapshot, "last24", now);
|
||||
|
||||
expect(model.metrics.downloadedBytes).toMatchObject({ value: 125, available: true });
|
||||
expectUnavailable(model.metrics.files);
|
||||
expectUnavailable(model.metrics.successRate);
|
||||
expectUnavailable(model.metrics.averageSpeedBps);
|
||||
expectUnavailable(model.metrics.errors);
|
||||
expect(model.usageKind).toBe("accounts");
|
||||
expect(model.providerScope).toBe("last24");
|
||||
expect(model.providers.map((row) => [row.id, row.label, row.bytes])).toEqual([
|
||||
["rdw_two", "Real-Debrid · Secondary", 75],
|
||||
["rdw_one", "Real-Debrid · Primary", 50]
|
||||
]);
|
||||
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[null, null], [null, null]]);
|
||||
expect(model.message).toContain("seit Beginn der Aufzeichnung");
|
||||
});
|
||||
|
||||
it("shows a complete rolling description after 24 hours of statistics coverage", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.stats.statistics = {
|
||||
version: 2,
|
||||
startedAt: now - (25 * 60 * 60 * 1_000),
|
||||
days: [],
|
||||
minutes: []
|
||||
};
|
||||
snapshot.stats.rolling24Hours = {
|
||||
from: now - (24 * 60 * 60 * 1_000),
|
||||
to: now,
|
||||
downloadedBytes: 0,
|
||||
accounts: []
|
||||
};
|
||||
|
||||
const model = buildStatisticsViewModel(snapshot, "last24", now);
|
||||
|
||||
expect(model.message).toContain("vergangenen 24 Stunden");
|
||||
expect(model.message).not.toContain("seit Beginn der Aufzeichnung");
|
||||
});
|
||||
|
||||
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
|
||||
const snapshot = createSnapshot();
|
||||
@@ -340,7 +397,7 @@ describe("statistics view", () => {
|
||||
const html = renderToStaticMarkup(<StatisticsSidebar actions={createActions()} model={model} />);
|
||||
|
||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(5);
|
||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(6);
|
||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -360,8 +417,8 @@ describe("statistics view", () => {
|
||||
for (const marker of ["statistics-sidebar", "statistics-kpis", "statistics-chart"]) {
|
||||
expect(html.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
|
||||
}
|
||||
for (const label of ["Sitzung", "Heute", "Sieben Tage", "30 Tage", "Gesamt"]) {
|
||||
expect(html).toContain(`>${label}<`);
|
||||
for (const label of ["Sitzung", "Heute", "Letzte 24 Stunden", "Sieben Tage", "30 Tage", "Gesamt"]) {
|
||||
expect(html).toContain(`>${label}<`);
|
||||
}
|
||||
expect(html).toContain("Bestehender Bandbreitenverlauf");
|
||||
expect(html).not.toContain("downloads-toolbar");
|
||||
@@ -410,7 +467,7 @@ describe("statistics view", () => {
|
||||
expect(findButton(failedContent, "Fehler zurücksetzen").props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the empty provider state inside the ARIA table as a row and spanning cell", () => {
|
||||
it("keeps the empty provider state inside the ARIA table as a row and spanning cell", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatisticsContent
|
||||
actions={createActions()}
|
||||
@@ -420,8 +477,29 @@ describe("statistics view", () => {
|
||||
);
|
||||
|
||||
expect(html).toContain('class="statistics-provider-empty" role="row"');
|
||||
expect(html).toContain('aria-colspan="3" role="cell"');
|
||||
});
|
||||
expect(html).toContain('aria-colspan="3" role="cell"');
|
||||
});
|
||||
|
||||
it("renders the rolling range as an account table with its own empty state", () => {
|
||||
const snapshot = createSnapshot();
|
||||
snapshot.stats.rolling24Hours = {
|
||||
from: now - (24 * 60 * 60 * 1_000),
|
||||
to: now,
|
||||
downloadedBytes: 0,
|
||||
accounts: []
|
||||
};
|
||||
const model = buildStatisticsViewModel(snapshot, "last24", now);
|
||||
const html = renderToStaticMarkup(<>
|
||||
<StatisticsSidebarStatus model={model} />
|
||||
<StatisticsContent actions={createActions()} chart={<div />} model={model} />
|
||||
</>);
|
||||
|
||||
expect(html).toContain("<h3>Accounts</h3>");
|
||||
expect(html).toContain('aria-label="Account-Nutzung"');
|
||||
expect(html).toContain('<span role="columnheader">Account</span>');
|
||||
expect(html).toContain("In den vergangenen 24 Stunden wurde noch kein Account-Traffic erfasst.");
|
||||
expect(html).toContain("Accounts: 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bandwidth chart palette", () => {
|
||||
|
||||
@@ -87,6 +87,8 @@ describe("visual fixtures", () => {
|
||||
const update = createVisualFixture("update");
|
||||
expect(Object.keys(empty.snapshot.session.packages)).toHaveLength(0);
|
||||
expect(Object.keys(dense.snapshot.session.packages).length).toBeGreaterThan(1);
|
||||
expect(dense.snapshot.stats.rolling24Hours?.accounts).toHaveLength(2);
|
||||
expect(dense.snapshot.stats.statistics?.minutes).toEqual([]);
|
||||
expect(update.update.latestTag).toBe("v9.9.9");
|
||||
expect(createVisualFixture("dense")).toEqual(dense);
|
||||
});
|
||||
|
||||
@@ -496,8 +496,27 @@ function createDenseSnapshot(): UiSnapshot {
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
rolling24Hours: {
|
||||
from: 1786226400000,
|
||||
to: 1786312800000,
|
||||
downloadedBytes: 541165879488,
|
||||
accounts: [
|
||||
{
|
||||
id: "rdw_visual_primary",
|
||||
provider: "realdebrid",
|
||||
label: "xSucukDE",
|
||||
bytes: 328565653504
|
||||
},
|
||||
{
|
||||
id: "dl_visual_secondary",
|
||||
provider: "debridlink",
|
||||
label: "Debrid-Link Key 2",
|
||||
bytes: 212600225984
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
};
|
||||
snapshot.speedText = "12,0 MB/s";
|
||||
snapshot.etaText = "00:17:24";
|
||||
snapshot.canStart = true;
|
||||
|
||||
Reference in New Issue
Block a user