fix: stabilize narrow-window telemetry UI
Collect bandwidth history for the full running session, keep responsive service and status labels readable, repair nested menu overflow, and correct semantic state colors. Update active queue counters immediately for terminal items, add localized integer formatting, eliminate progress label overlap, and cover the regressions with focused tests.
This commit is contained in:
+23
-34
@@ -90,7 +90,7 @@ import {
|
||||
StatisticsSidebarStatus,
|
||||
type StatisticsViewActions
|
||||
} from "./views/statistics/StatisticsView";
|
||||
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, getDownloadSpeedBps, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
||||
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
||||
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
|
||||
import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag";
|
||||
import {
|
||||
@@ -1316,17 +1316,26 @@ export function readBandwidthChartPalette(
|
||||
};
|
||||
}
|
||||
|
||||
export function appendBandwidthSample(
|
||||
history: { time: number; speed: number }[],
|
||||
speed: number,
|
||||
now = Date.now()
|
||||
): { time: number; speed: number }[] {
|
||||
const next = [...history, { time: now, speed: Number.isFinite(speed) ? Math.max(0, speed) : 0 }];
|
||||
const cutoff = now - 60000;
|
||||
const firstVisible = next.findIndex((point) => point.time >= cutoff);
|
||||
return firstVisible > 0 ? next.slice(firstVisible) : next;
|
||||
}
|
||||
|
||||
interface BandwidthChartProps {
|
||||
items: Record<string, DownloadItem>;
|
||||
running: boolean;
|
||||
running: boolean;
|
||||
paused: boolean;
|
||||
speedHistoryRef: React.MutableRefObject<{ time: number; speed: number }[]>;
|
||||
}
|
||||
|
||||
const BandwidthChart = memo(function BandwidthChart({ items, running, paused, speedHistoryRef }: BandwidthChartProps): ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastUpdateRef = useRef<number>(0);
|
||||
const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHistoryRef }: BandwidthChartProps): ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
|
||||
@@ -1463,30 +1472,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
||||
return () => clearInterval(interval);
|
||||
}, [drawChart, running, paused]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running || paused) return;
|
||||
|
||||
const now = Date.now();
|
||||
const activeItems = Object.values(items).filter((item) => item.status === "downloading");
|
||||
if (activeItems.length === 0) return;
|
||||
|
||||
const totalSpeed = activeItems.reduce((sum, item) => sum + (item.speedBps || 0), 0);
|
||||
|
||||
const history = speedHistoryRef.current;
|
||||
history.push({ time: now, speed: totalSpeed });
|
||||
|
||||
const cutoff = now - 60000;
|
||||
let trimIndex = 0;
|
||||
while (trimIndex < history.length && history[trimIndex].time < cutoff) {
|
||||
trimIndex += 1;
|
||||
}
|
||||
if (trimIndex > 0) {
|
||||
speedHistoryRef.current = history.slice(trimIndex);
|
||||
}
|
||||
|
||||
lastUpdateRef.current = now;
|
||||
}, [items, paused, running]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
||||
@@ -1504,7 +1489,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
||||
|
||||
useEffect(() => {
|
||||
drawChart();
|
||||
}, [drawChart, items, paused]);
|
||||
}, [drawChart, paused]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="bandwidth-chart-container">
|
||||
@@ -4946,6 +4931,10 @@ export function App(): ReactElement {
|
||||
|
||||
const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]);
|
||||
const liveDownloadSpeedBps = useMemo(() => getDownloadSpeedBps(snapshot.packageSpeedBps), [snapshot.packageSpeedBps]);
|
||||
useEffect(() => {
|
||||
if (!snapshot.session.running || snapshot.session.paused) return;
|
||||
speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps);
|
||||
}, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]);
|
||||
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
||||
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
||||
...downloadsViewCore,
|
||||
@@ -4971,7 +4960,7 @@ export function App(): ReactElement {
|
||||
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
||||
status: {
|
||||
packages: snapshot.stats.totalPackages,
|
||||
links: Object.keys(snapshot.session.items).length,
|
||||
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
|
||||
session: humanSize(snapshot.stats.totalDownloaded),
|
||||
sessionBytes: snapshot.stats.totalDownloaded,
|
||||
total: humanSize(downloadQueueTotalBytes),
|
||||
@@ -5963,7 +5952,7 @@ export function App(): ReactElement {
|
||||
{tab === "statistics" && (
|
||||
<StatisticsContent
|
||||
actions={statisticsActions}
|
||||
chart={<BandwidthChart items={snapshot.session.items} running={snapshot.session.running} paused={snapshot.session.paused} speedHistoryRef={speedHistoryRef} />}
|
||||
chart={<BandwidthChart running={snapshot.session.running} paused={snapshot.session.paused} speedHistoryRef={speedHistoryRef} />}
|
||||
model={statisticsViewModel}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -30,7 +30,11 @@ export function compactProviderLabels(labels: string[]): string {
|
||||
}
|
||||
|
||||
export function normalizeDownloadServiceLabel(label: string): string {
|
||||
return label.replace(/\s+(Web|API)\s+\(\1 Account\)$/i, " $1");
|
||||
return [...new Set(label.split(",").map((entry) => entry.trim().replace(/\s+(Web|API)\s+\(\1 Account\)$/i, " $1")).filter(Boolean))].join(", ");
|
||||
}
|
||||
|
||||
export function compactDownloadServiceLabel(label: string): string {
|
||||
return [...new Set(normalizeDownloadServiceLabel(label).split(",").map((entry) => entry.trim().replace(/\s+(Web|API)$/i, "")).filter(Boolean))].join(", ");
|
||||
}
|
||||
|
||||
export function formatDateTime(timestamp: number): string {
|
||||
|
||||
@@ -601,13 +601,18 @@
|
||||
.md-application-menu-tree :where(.menu-dropdown, .menu-submenu-dropdown) {
|
||||
z-index: var(--md-layer-menu);
|
||||
max-height: calc(100vh - 16px);
|
||||
overflow-y: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.md-application-menu-tree > .menu-bar-item > .menu-dropdown {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.md-application-menu-tree .menu-submenu-dropdown {
|
||||
right: 100%;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.md-overlay-host {
|
||||
position: static;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties,
|
||||
import type { DownloadItem } from "../../../shared/types";
|
||||
import {
|
||||
compactProviderLabels,
|
||||
compactDownloadServiceLabel,
|
||||
extractHoster,
|
||||
formatAudioStripSummary,
|
||||
formatDateTime,
|
||||
@@ -147,6 +148,17 @@ function DownloadStatusCell({ status, title }: { status: string; title?: string
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadServiceCell({ label }: { label: string }): ReactElement {
|
||||
const full = normalizeDownloadServiceLabel(label);
|
||||
const compact = compactDownloadServiceLabel(label);
|
||||
return (
|
||||
<span aria-label={full} className="downloads-cell downloads-service-cell" title={label}>
|
||||
<span aria-hidden="true" className="downloads-service-full">{full}</span>
|
||||
<span aria-hidden="true" className="downloads-service-compact">{compact}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ReactElement | null {
|
||||
const displayStatus = displayedStatus(item, sessionRunning);
|
||||
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
|
||||
@@ -174,7 +186,7 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
|
||||
}
|
||||
if (column === "account") {
|
||||
const full = item.providerLabel || (item.provider ? providerLabels[item.provider] : "");
|
||||
return <span className="downloads-cell" title={full}>{normalizeDownloadServiceLabel(full)}</span>;
|
||||
return <DownloadServiceCell label={full} />;
|
||||
}
|
||||
if (column === "prio") return <span className="downloads-cell" />;
|
||||
if (column === "status") return <DownloadStatusCell status={displayStatus} title={statusTitle} />;
|
||||
@@ -393,7 +405,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
||||
}
|
||||
if (column === "account") {
|
||||
const full = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
|
||||
return <span className="downloads-cell" title={full}>{normalizeDownloadServiceLabel(full)}</span>;
|
||||
return <DownloadServiceCell label={full} />;
|
||||
}
|
||||
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
|
||||
if (column === "status") {
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
type DownloadSortColumn,
|
||||
type DownloadsTableActions
|
||||
} from "./DownloadsTable";
|
||||
import "./downloads.css";
|
||||
import "./downloads.css";
|
||||
|
||||
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
|
||||
|
||||
export interface DownloadsStatusModel {
|
||||
packages: number;
|
||||
@@ -104,11 +106,11 @@ export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel })
|
||||
const speed = model.status.speed.replace(/^Geschwindigkeit:\s*/i, "");
|
||||
const eta = model.status.eta.replace(/^ETA:\s*/i, "");
|
||||
const entries = [
|
||||
{ label: "Pakete", metric: "packages", numericValue: model.status.packages, value: String(model.status.packages) },
|
||||
{ label: "Links", metric: "links", numericValue: model.status.links, value: String(model.status.links) },
|
||||
{ label: "Pakete", metric: "packages", numericValue: model.status.packages, value: integerFormatter.format(model.status.packages) },
|
||||
{ label: "Links", metric: "links", numericValue: model.status.links, value: integerFormatter.format(model.status.links) },
|
||||
{ label: "Sitzung", metric: "session", numericValue: model.status.sessionBytes, value: model.status.session },
|
||||
{ label: "Gesamt", metric: "total", numericValue: model.status.totalBytes, value: model.status.total },
|
||||
{ label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: String(model.status.hosters) }
|
||||
{ label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: integerFormatter.format(model.status.hosters) }
|
||||
];
|
||||
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map((entry) => <div key={entry.metric}><span>{entry.label}</span><RollingMetricValue numericValue={entry.numericValue} value={entry.value} /></div>)}<div><span>Geschwindigkeit</span><strong data-status-metric="speed">{speed}</strong></div><div><span>ETA</span><strong data-status-metric="eta">{eta}</strong></div></section>;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,14 @@ export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): numbe
|
||||
return total;
|
||||
}
|
||||
|
||||
export function getPendingDownloadItemCount(items: Iterable<DownloadItem>): number {
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
if (item.status !== "completed" && item.status !== "cancelled" && item.status !== "failed") count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function getDownloadSpeedBps(packageSpeeds: Record<string, number>): number {
|
||||
let total = 0;
|
||||
for (const speed of Object.values(packageSpeeds)) {
|
||||
|
||||
@@ -545,7 +545,7 @@
|
||||
}
|
||||
|
||||
.downloads-link-state.online {
|
||||
background: var(--ui-primary);
|
||||
background: var(--ui-success);
|
||||
}
|
||||
|
||||
.downloads-link-state.offline {
|
||||
@@ -637,10 +637,11 @@
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.downloads-meter-label.is-track {
|
||||
clip-path: inset(0 0 0 var(--downloads-progress));
|
||||
color: var(--ui-progress-track-text, #fff);
|
||||
}
|
||||
|
||||
@@ -656,7 +657,16 @@
|
||||
color: var(--ui-progress-fill-text, #171a1f);
|
||||
}
|
||||
|
||||
.downloads-status-compact {
|
||||
.downloads-status-cell {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.downloads-service-cell {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.downloads-status-compact,
|
||||
.downloads-service-compact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -665,14 +675,22 @@
|
||||
--downloads-status-min: 90px;
|
||||
}
|
||||
|
||||
.md-shell.is-compact .downloads-status-full,
|
||||
.md-shell.is-minimum .downloads-status-full {
|
||||
display: none;
|
||||
}
|
||||
@container (max-width: 150px) {
|
||||
.downloads-status-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md-shell.is-compact .downloads-status-compact,
|
||||
.md-shell.is-minimum .downloads-status-compact {
|
||||
display: inline;
|
||||
.downloads-status-compact {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.downloads-service-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.downloads-service-compact {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.downloads-empty-state,
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
}
|
||||
|
||||
.settings-switch.is-on {
|
||||
border-color: var(--ui-accent);
|
||||
background: var(--ui-accent);
|
||||
border-color: var(--ui-success);
|
||||
background: var(--ui-success);
|
||||
}
|
||||
|
||||
.settings-switch.is-on > span {
|
||||
|
||||
Reference in New Issue
Block a user