Add bandwidth statistics tab with live chart
- Add new Statistics tab between Downloads and Settings - Implement real-time bandwidth chart using Canvas (60s history) - Add session overview with 8 stats cards (speed, downloaded, files, packages, etc.) - Add provider statistics with progress bars - Add getSessionStats IPC endpoint - Support dark/light theme in chart rendering
This commit is contained in:
parent
ec45983810
commit
f850555e4f
@ -5,6 +5,7 @@ import {
|
|||||||
AppSettings,
|
AppSettings,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
ParsedPackageInput,
|
ParsedPackageInput,
|
||||||
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
@ -225,6 +226,10 @@ export class AppController {
|
|||||||
return this.manager.importQueue(json);
|
return this.manager.importQueue(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSessionStats(): SessionStats {
|
||||||
|
return this.manager.getSessionStats();
|
||||||
|
}
|
||||||
|
|
||||||
public shutdown(): void {
|
public shutdown(): void {
|
||||||
abortActiveUpdateDownload();
|
abortActiveUpdateDownload();
|
||||||
this.manager.prepareForShutdown();
|
this.manager.prepareForShutdown();
|
||||||
|
|||||||
@ -4737,4 +4737,79 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.persistNow();
|
this.persistNow();
|
||||||
this.emitState();
|
this.emitState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSessionStats(): import("../shared/types").SessionStats {
|
||||||
|
const now = nowMs();
|
||||||
|
this.pruneSpeedEvents(now);
|
||||||
|
|
||||||
|
const bandwidthSamples: import("../shared/types").BandwidthSample[] = [];
|
||||||
|
for (let i = this.speedEventsHead; i < this.speedEvents.length; i += 1) {
|
||||||
|
const event = this.speedEvents[i];
|
||||||
|
if (event) {
|
||||||
|
bandwidthSamples.push({
|
||||||
|
timestamp: event.at,
|
||||||
|
speedBps: event.bytes * 3
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paused = this.session.running && this.session.paused;
|
||||||
|
const currentSpeedBps = paused ? 0 : this.speedBytesLastWindow / 3;
|
||||||
|
|
||||||
|
let totalBytes = 0;
|
||||||
|
let maxSpeed = 0;
|
||||||
|
for (let i = this.speedEventsHead; i < this.speedEvents.length; i += 1) {
|
||||||
|
const event = this.speedEvents[i];
|
||||||
|
if (event) {
|
||||||
|
totalBytes += event.bytes;
|
||||||
|
const speed = event.bytes * 3;
|
||||||
|
if (speed > maxSpeed) {
|
||||||
|
maxSpeed = speed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionDurationSeconds = this.session.runStartedAt > 0
|
||||||
|
? Math.max(0, Math.floor((now - this.session.runStartedAt) / 1000))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const averageSpeedBps = sessionDurationSeconds > 0
|
||||||
|
? Math.floor(this.session.totalDownloadedBytes / sessionDurationSeconds)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let totalDownloads = 0;
|
||||||
|
let completedDownloads = 0;
|
||||||
|
let failedDownloads = 0;
|
||||||
|
let activeDownloads = 0;
|
||||||
|
let queuedDownloads = 0;
|
||||||
|
|
||||||
|
for (const item of Object.values(this.session.items)) {
|
||||||
|
totalDownloads += 1;
|
||||||
|
if (item.status === "completed") {
|
||||||
|
completedDownloads += 1;
|
||||||
|
} else if (item.status === "failed") {
|
||||||
|
failedDownloads += 1;
|
||||||
|
} else if (item.status === "downloading" || item.status === "validating") {
|
||||||
|
activeDownloads += 1;
|
||||||
|
} else if (item.status === "queued" || item.status === "reconnect_wait") {
|
||||||
|
queuedDownloads += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bandwidth: {
|
||||||
|
samples: bandwidthSamples.slice(-120),
|
||||||
|
currentSpeedBps: Math.floor(currentSpeedBps),
|
||||||
|
averageSpeedBps,
|
||||||
|
maxSpeedBps: Math.floor(maxSpeed),
|
||||||
|
totalBytesSession: this.session.totalDownloadedBytes,
|
||||||
|
sessionDurationSeconds
|
||||||
|
},
|
||||||
|
totalDownloads,
|
||||||
|
completedDownloads,
|
||||||
|
failedDownloads,
|
||||||
|
activeDownloads,
|
||||||
|
queuedDownloads
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -343,6 +343,7 @@ function registerIpcHandlers(): void {
|
|||||||
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||||
return result.canceled ? [] : result.filePaths;
|
return result.canceled ? [] : result.filePaths;
|
||||||
});
|
});
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
||||||
|
|
||||||
controller.onState = (snapshot) => {
|
controller.onState = (snapshot) => {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
@ -40,6 +41,7 @@ const api: ElectronApi = {
|
|||||||
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
||||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||||
|
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||||
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||||
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import type {
|
|||||||
UpdateInstallProgress
|
UpdateInstallProgress
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
||||||
type Tab = "collector" | "downloads" | "settings";
|
type Tab = "collector" | "downloads" | "statistics" | "settings";
|
||||||
|
|
||||||
interface CollectorTab {
|
interface CollectorTab {
|
||||||
id: string;
|
id: string;
|
||||||
@ -91,6 +91,186 @@ function humanSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BandwidthChartProps {
|
||||||
|
items: Record<string, DownloadItem>;
|
||||||
|
running: boolean;
|
||||||
|
paused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BandwidthChart = memo(function BandwidthChart({ items, running, paused }: BandwidthChartProps): ReactElement {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const speedHistoryRef = useRef<{ time: number; speed: number }[]>([]);
|
||||||
|
const lastUpdateRef = useRef<number>(0);
|
||||||
|
const [, forceUpdate] = useState(0);
|
||||||
|
const animationFrameRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const drawChart = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!canvas || !container) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) return;
|
||||||
|
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
canvas.style.width = `${rect.width}px`;
|
||||||
|
canvas.style.height = `${rect.height}px`;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const width = rect.width;
|
||||||
|
const height = rect.height;
|
||||||
|
const padding = { top: 20, right: 20, bottom: 30, left: 60 };
|
||||||
|
const chartWidth = width - padding.left - padding.right;
|
||||||
|
const chartHeight = height - padding.top - padding.bottom;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
|
||||||
|
const gridColor = isDark ? "rgba(35, 57, 84, 0.5)" : "rgba(199, 213, 234, 0.5)";
|
||||||
|
const textColor = isDark ? "#90a4bf" : "#4e6482";
|
||||||
|
const accentColor = isDark ? "#38bdf8" : "#1168d9";
|
||||||
|
const fillColor = isDark ? "rgba(56, 189, 248, 0.15)" : "rgba(17, 104, 217, 0.15)";
|
||||||
|
|
||||||
|
ctx.strokeStyle = gridColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i <= 5; i += 1) {
|
||||||
|
const y = padding.top + (chartHeight / 5) * i;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left, y);
|
||||||
|
ctx.lineTo(width - padding.right, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = speedHistoryRef.current;
|
||||||
|
const now = Date.now();
|
||||||
|
const maxTime = now;
|
||||||
|
const minTime = now - 60000;
|
||||||
|
|
||||||
|
let maxSpeed = 0;
|
||||||
|
for (const point of history) {
|
||||||
|
if (point.speed > maxSpeed) maxSpeed = point.speed;
|
||||||
|
}
|
||||||
|
maxSpeed = Math.max(maxSpeed, 1024 * 1024);
|
||||||
|
const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed)));
|
||||||
|
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.font = "11px 'Manrope', sans-serif";
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
|
||||||
|
for (let i = 0; i <= 5; i += 1) {
|
||||||
|
const y = padding.top + (chartHeight / 5) * i;
|
||||||
|
const speedVal = niceMax * (1 - i / 5);
|
||||||
|
ctx.fillText(formatSpeedMbps(speedVal), padding.left - 8, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
ctx.fillText("0s", padding.left, height - padding.bottom + 8);
|
||||||
|
ctx.fillText("30s", padding.left + chartWidth / 2, height - padding.bottom + 8);
|
||||||
|
ctx.fillText("60s", width - padding.right, height - padding.bottom + 8);
|
||||||
|
|
||||||
|
if (history.length < 2) {
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.font = "13px 'Manrope', sans-serif";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(running ? (paused ? "Pausiert" : "Sammle Daten...") : "Download starten fur Statistiken", width / 2, height / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const points: { x: number; y: number }[] = [];
|
||||||
|
for (const point of history) {
|
||||||
|
const x = padding.left + ((point.time - minTime) / 60000) * chartWidth;
|
||||||
|
const y = padding.top + chartHeight - (point.speed / niceMax) * chartHeight;
|
||||||
|
points.push({ x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(points[0].x, points[0].y);
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
ctx.lineTo(points[i].x, points[i].y);
|
||||||
|
}
|
||||||
|
ctx.lineTo(points[points.length - 1].x, padding.top + chartHeight);
|
||||||
|
ctx.lineTo(points[0].x, padding.top + chartHeight);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = fillColor;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(points[0].x, points[0].y);
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
ctx.lineTo(points[i].x, points[i].y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = accentColor;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const lastPoint = points[points.length - 1];
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(lastPoint.x, lastPoint.y, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = accentColor;
|
||||||
|
ctx.fill();
|
||||||
|
}, [running, paused]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastUpdateRef.current >= 250) {
|
||||||
|
forceUpdate((n) => n + 1);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const totalSpeed = Object.values(items)
|
||||||
|
.filter((item) => item.status === "downloading")
|
||||||
|
.reduce((sum, item) => sum + (item.speedBps || 0), 0);
|
||||||
|
|
||||||
|
const history = speedHistoryRef.current;
|
||||||
|
history.push({ time: now, speed: paused ? 0 : totalSpeed });
|
||||||
|
|
||||||
|
const cutoff = now - 60000;
|
||||||
|
while (history.length > 0 && history[0].time < cutoff) {
|
||||||
|
history.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdateRef.current = now;
|
||||||
|
}, [items, paused]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(drawChart);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [drawChart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
drawChart();
|
||||||
|
}, [drawChart]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="bandwidth-chart-container">
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
let nextCollectorId = 1;
|
let nextCollectorId = 1;
|
||||||
|
|
||||||
function createScheduleId(): string {
|
function createScheduleId(): string {
|
||||||
@ -1249,6 +1429,7 @@ export function App(): ReactElement {
|
|||||||
<nav className="tabs">
|
<nav className="tabs">
|
||||||
<button className={tab === "collector" ? "tab active" : "tab"} onClick={() => setTab("collector")}>Linksammler</button>
|
<button className={tab === "collector" ? "tab active" : "tab"} onClick={() => setTab("collector")}>Linksammler</button>
|
||||||
<button className={tab === "downloads" ? "tab active" : "tab"} onClick={() => setTab("downloads")}>Downloads</button>
|
<button className={tab === "downloads" ? "tab active" : "tab"} onClick={() => setTab("downloads")}>Downloads</button>
|
||||||
|
<button className={tab === "statistics" ? "tab active" : "tab"} onClick={() => setTab("statistics")}>Statistiken</button>
|
||||||
<button className={tab === "settings" ? "tab active" : "tab"} onClick={() => setTab("settings")}>Einstellungen</button>
|
<button className={tab === "settings" ? "tab active" : "tab"} onClick={() => setTab("settings")}>Einstellungen</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@ -1382,6 +1563,88 @@ export function App(): ReactElement {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "statistics" && (
|
||||||
|
<section className="statistics-view">
|
||||||
|
<article className="card stats-overview">
|
||||||
|
<h3>Session-Ubersicht</h3>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Aktuelle Geschwindigkeit</span>
|
||||||
|
<span className="stat-value">{snapshot.speedText.replace("Geschwindigkeit: ", "")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Gesamt heruntergeladen</span>
|
||||||
|
<span className="stat-value">{humanSize(snapshot.stats.totalDownloaded)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Fertige Dateien</span>
|
||||||
|
<span className="stat-value">{snapshot.stats.totalFiles}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Pakete</span>
|
||||||
|
<span className="stat-value">{snapshot.stats.totalPackages}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Aktive Downloads</span>
|
||||||
|
<span className="stat-value">{Object.values(snapshot.session.items).filter((item) => item.status === "downloading").length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">In Warteschlange</span>
|
||||||
|
<span className="stat-value">{Object.values(snapshot.session.items).filter((item) => item.status === "queued" || item.status === "reconnect_wait").length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Fehlerhaft</span>
|
||||||
|
<span className="stat-value danger">{Object.values(snapshot.session.items).filter((item) => item.status === "failed").length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">{snapshot.etaText.split(": ")[0]}</span>
|
||||||
|
<span className="stat-value">{snapshot.etaText.split(": ")[1] || "--"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="card stats-chart-card">
|
||||||
|
<h3>Bandbreitenverlauf</h3>
|
||||||
|
<BandwidthChart items={snapshot.session.items} running={snapshot.session.running} paused={snapshot.session.paused} />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="card stats-provider-card">
|
||||||
|
<h3>Provider-Statistik</h3>
|
||||||
|
<div className="provider-stats">
|
||||||
|
{Object.entries(
|
||||||
|
Object.values(snapshot.session.items).reduce((acc, item) => {
|
||||||
|
const provider = item.provider || "unknown";
|
||||||
|
if (!acc[provider]) {
|
||||||
|
acc[provider] = { total: 0, completed: 0, failed: 0, bytes: 0 };
|
||||||
|
}
|
||||||
|
acc[provider].total += 1;
|
||||||
|
if (item.status === "completed") acc[provider].completed += 1;
|
||||||
|
if (item.status === "failed") acc[provider].failed += 1;
|
||||||
|
acc[provider].bytes += item.downloadedBytes;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, { total: number; completed: number; failed: number; bytes: number }>)
|
||||||
|
).map(([provider, stats]) => (
|
||||||
|
<div key={provider} className="provider-stat-item">
|
||||||
|
<span className="provider-name">{provider === "unknown" ? "Unbekannt" : providerLabels[provider as DebridProvider] || provider}</span>
|
||||||
|
<div className="provider-bars">
|
||||||
|
<div className="provider-bar">
|
||||||
|
<div className="bar-fill completed" style={{ width: `${stats.total > 0 ? (stats.completed / stats.total) * 100 : 0}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="provider-detail">
|
||||||
|
{stats.completed}/{stats.total} fertig | {humanSize(stats.bytes)}
|
||||||
|
{stats.failed > 0 && <span className="danger"> | {stats.failed} Fehler</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.keys(snapshot.session.items).length === 0 && (
|
||||||
|
<div className="empty-provider">Noch keine Downloads vorhanden.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === "settings" && (
|
{tab === "settings" && (
|
||||||
<section className="settings-shell">
|
<section className="settings-shell">
|
||||||
<article className="card settings-toolbar">
|
<article className="card settings-toolbar">
|
||||||
|
|||||||
@ -626,6 +626,156 @@ td {
|
|||||||
grid-template-columns: 1.1fr 1fr;
|
grid-template-columns: 1.1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statistics-view {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.5fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-overview {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--field);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-chart-card {
|
||||||
|
min-height: 280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandwidth-chart-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
position: relative;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--field);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandwidth-chart-container canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-provider-card {
|
||||||
|
min-height: 280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-stats {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--field);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--progress-track);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill.completed {
|
||||||
|
background: linear-gradient(90deg, #3bc9ff, #22d3ee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-detail {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-provider {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.statistics-view {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-overview {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-row {
|
.schedule-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 56px auto 56px auto 92px auto auto auto;
|
grid-template-columns: 56px auto 56px auto 92px auto auto auto;
|
||||||
|
|||||||
@ -25,5 +25,6 @@ export const IPC_CHANNELS = {
|
|||||||
PICK_CONTAINERS: "dialog:pick-containers",
|
PICK_CONTAINERS: "dialog:pick-containers",
|
||||||
STATE_UPDATE: "state:update",
|
STATE_UPDATE: "state:update",
|
||||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||||
TOGGLE_CLIPBOARD: "clipboard:toggle"
|
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||||
|
GET_SESSION_STATS: "stats:get-session-stats"
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
@ -35,6 +36,7 @@ export interface ElectronApi {
|
|||||||
toggleClipboard: () => Promise<boolean>;
|
toggleClipboard: () => Promise<boolean>;
|
||||||
pickFolder: () => Promise<string | null>;
|
pickFolder: () => Promise<string | null>;
|
||||||
pickContainers: () => Promise<string[]>;
|
pickContainers: () => Promise<string[]>;
|
||||||
|
getSessionStats: () => Promise<SessionStats>;
|
||||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||||
|
|||||||
@ -226,3 +226,26 @@ export interface ParsedHashEntry {
|
|||||||
algorithm: "crc32" | "md5" | "sha1";
|
algorithm: "crc32" | "md5" | "sha1";
|
||||||
digest: string;
|
digest: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BandwidthSample {
|
||||||
|
timestamp: number;
|
||||||
|
speedBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BandwidthStats {
|
||||||
|
samples: BandwidthSample[];
|
||||||
|
currentSpeedBps: number;
|
||||||
|
averageSpeedBps: number;
|
||||||
|
maxSpeedBps: number;
|
||||||
|
totalBytesSession: number;
|
||||||
|
sessionDurationSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStats {
|
||||||
|
bandwidth: BandwidthStats;
|
||||||
|
totalDownloads: number;
|
||||||
|
completedDownloads: number;
|
||||||
|
failedDownloads: number;
|
||||||
|
activeDownloads: number;
|
||||||
|
queuedDownloads: number;
|
||||||
|
}
|
||||||
|
|||||||
@ -954,6 +954,7 @@ describe("download manager", () => {
|
|||||||
token: "rd-token",
|
token: "rd-token",
|
||||||
outputDir: path.join(root, "downloads"),
|
outputDir: path.join(root, "downloads"),
|
||||||
extractDir: path.join(root, "extract"),
|
extractDir: path.join(root, "extract"),
|
||||||
|
retryLimit: 1,
|
||||||
autoExtract: false
|
autoExtract: false
|
||||||
},
|
},
|
||||||
emptySession(),
|
emptySession(),
|
||||||
@ -1084,6 +1085,7 @@ describe("download manager", () => {
|
|||||||
token: "rd-token",
|
token: "rd-token",
|
||||||
outputDir: path.join(root, "downloads"),
|
outputDir: path.join(root, "downloads"),
|
||||||
extractDir: path.join(root, "extract"),
|
extractDir: path.join(root, "extract"),
|
||||||
|
retryLimit: 1,
|
||||||
autoExtract: false
|
autoExtract: false
|
||||||
},
|
},
|
||||||
session,
|
session,
|
||||||
@ -1216,6 +1218,7 @@ describe("download manager", () => {
|
|||||||
token: "rd-token",
|
token: "rd-token",
|
||||||
outputDir: path.join(root, "downloads"),
|
outputDir: path.join(root, "downloads"),
|
||||||
extractDir: path.join(root, "extract"),
|
extractDir: path.join(root, "extract"),
|
||||||
|
retryLimit: 1,
|
||||||
autoExtract: false
|
autoExtract: false
|
||||||
},
|
},
|
||||||
session,
|
session,
|
||||||
@ -1359,6 +1362,7 @@ describe("download manager", () => {
|
|||||||
token: "rd-token",
|
token: "rd-token",
|
||||||
outputDir: path.join(root, "downloads"),
|
outputDir: path.join(root, "downloads"),
|
||||||
extractDir: path.join(root, "extract"),
|
extractDir: path.join(root, "extract"),
|
||||||
|
retryLimit: 1,
|
||||||
autoExtract: false
|
autoExtract: false
|
||||||
},
|
},
|
||||||
session,
|
session,
|
||||||
@ -1494,7 +1498,7 @@ describe("download manager", () => {
|
|||||||
server.close();
|
server.close();
|
||||||
await once(server, "close");
|
await once(server, "close");
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 70000);
|
||||||
|
|
||||||
it("retries non-retriable HTTP statuses and eventually succeeds", async () => {
|
it("retries non-retriable HTTP statuses and eventually succeeds", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
@ -4083,6 +4087,7 @@ describe("download manager", () => {
|
|||||||
const sourceFileName = `${nestedFolder}/tvr-gotham-s03e11-720p.mkv`;
|
const sourceFileName = `${nestedFolder}/tvr-gotham-s03e11-720p.mkv`;
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
zip.addFile(sourceFileName, Buffer.from("video"));
|
zip.addFile(sourceFileName, Buffer.from("video"));
|
||||||
|
zip.addFile(`${nestedFolder}/tvr-gotham-s03-720p.nfo`, Buffer.from("info"));
|
||||||
zip.addFile(`${nestedFolder}/Thumbs.db`, Buffer.from("thumbs"));
|
zip.addFile(`${nestedFolder}/Thumbs.db`, Buffer.from("thumbs"));
|
||||||
zip.addFile("desktop.ini", Buffer.from("system"));
|
zip.addFile("desktop.ini", Buffer.from("system"));
|
||||||
const archivePath = path.join(outputDir, "episode.zip");
|
const archivePath = path.join(outputDir, "episode.zip");
|
||||||
|
|||||||
@ -362,6 +362,73 @@ describe("update", () => {
|
|||||||
expect(requestedUrls.some((url) => url.includes("latest.yml"))).toBe(true);
|
expect(requestedUrls.some((url) => url.includes("latest.yml"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects installer when latest.yml SHA512 digest does not match", async () => {
|
||||||
|
const executablePayload = fs.readFileSync(process.execPath);
|
||||||
|
const wrongDigestBase64 = Buffer.alloc(64, 0x13).toString("base64");
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
|
||||||
|
if (url.endsWith("/releases/tags/v9.9.9")) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
tag_name: "v9.9.9",
|
||||||
|
draft: false,
|
||||||
|
prerelease: false,
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
name: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||||
|
browser_download_url: "https://example.invalid/setup-no-digest.exe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "latest.yml",
|
||||||
|
browser_download_url: "https://example.invalid/latest.yml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("latest.yml")) {
|
||||||
|
return new Response(
|
||||||
|
`version: 9.9.9\npath: Real-Debrid-Downloader Setup 9.9.9.exe\nsha512: ${wrongDigestBase64}\n`,
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/yaml" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("setup-no-digest.exe")) {
|
||||||
|
return new Response(executablePayload, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Content-Length": String(executablePayload.length)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("missing", { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const prechecked: UpdateCheckResult = {
|
||||||
|
updateAvailable: true,
|
||||||
|
currentVersion: APP_VERSION,
|
||||||
|
latestVersion: "9.9.9",
|
||||||
|
latestTag: "v9.9.9",
|
||||||
|
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||||
|
setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
|
||||||
|
setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||||
|
setupAssetDigest: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||||
|
expect(result.started).toBe(false);
|
||||||
|
expect(result.message).toMatch(/sha512|integrit|mismatch/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("emits install progress events while downloading and launching update", async () => {
|
it("emits install progress events while downloading and launching update", async () => {
|
||||||
const executablePayload = fs.readFileSync(process.execPath);
|
const executablePayload = fs.readFileSync(process.execPath);
|
||||||
const digest = sha256Hex(executablePayload);
|
const digest = sha256Hex(executablePayload);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user