feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:28 +02:00
1 parent 1a5e083fd0
commit 6f2cd7fc2b
270 files changed
+10189 -3544

No files matched your search

@@ -10,7 +10,7 @@ import { formatStatsSpeed } from "./helpers";
import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card";
export function AveragesSection({ stats }: { stats: SpeedtestTrackerStats }) {
export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTrackerStats; width?: number }) {
const t = useScopedI18n("widget.speedtestTracker");
return (
@@ -18,7 +18,7 @@ export function AveragesSection({ stats }: { stats: SpeedtestTrackerStats }) {
<SectionLabel>
{t("averages")} · {t("tests", { count: stats.total_results.toString() })}
</SectionLabel>
<SimpleGrid cols={3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SimpleGrid cols={width < 220 ? 1 : 3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard
icon={IconArrowDown}
color="blue"
@@ -1,58 +1,84 @@
"use client";
import { useMemo } from "react";
import { Stack, Text } from "@mantine/core";
import { useMemo, useState } from "react";
import { SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages";
import { mergeStats } from "./helpers";
import { combineSpeedtestDashboards } from "./helpers";
import { LatestResultSection } from "./latest-result";
import { RecentResultsSection } from "./recent-results";
export default function SpeedtestTrackerWidget({ options, integrationIds }: WidgetComponentProps<"speedtestTracker">) {
export default function SpeedtestTrackerWidget({
options,
integrationIds,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"speedtestTracker">) {
const t = useScopedI18n("widget.speedtestTracker");
const { data: dashboardData = [] } = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const [advancedRange, setAdvancedRange] = useState("24");
const combined = useMemo(
() =>
dashboardData.reduce<SpeedtestTrackerDashboardData>(
(acc, item) => ({
latestResult: item.dashboard.latestResult ?? acc.latestResult,
stats: mergeStats(acc.stats, item.dashboard.stats),
recentResults: [...acc.recentResults, ...item.dashboard.recentResults],
}),
{ latestResult: null, stats: null, recentResults: [] },
),
() => combineSpeedtestDashboards(dashboardData.map((item) => item.dashboard)),
[dashboardData],
);
const twelveHoursAgo = Date.now() - 12 * 60 * 60 * 1000;
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > twelveHoursAgo);
const rangeHours = displayMode === "advanced" ? Number(advancedRange) : 12;
const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0;
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff);
const hasStatSection =
(options.showLatestResult && combined.latestResult !== null) || (options.showStats && combined.stats !== null);
const hasChart = options.showRecentResults && recentFiltered.length > 0;
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
const latest = options.showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={displayMode === "advanced" ? width / 2 : width} />
);
const averages = options.showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? width / 2 : width} />
);
if (displayMode === "advanced") {
return (
<Stack h="100%" gap="md" p="md" style={{ overflow: "hidden" }}>
<SegmentedControl
size="xs"
value={advancedRange}
onChange={setAdvancedRange}
data={[
{ value: "12", label: t("range.twelveHours") },
{ value: "24", label: t("range.oneDay") },
{ value: "0", label: t("range.all") },
]}
/>
{hasStatSection && (
<SimpleGrid cols={width >= 720 ? 2 : 1} spacing="md">
{latest}
{averages}
</SimpleGrid>
)}
{hasChart && (
<div style={{ flex: 1, minHeight: Math.max(220, height / 2) }}>
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
</div>
)}
</Stack>
);
}
return (
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
{hasStatSection && (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
{options.showLatestResult && combined.latestResult && (
<div style={{ flex: 1, minHeight: 0 }}>
<LatestResultSection result={combined.latestResult} />
</div>
)}
{options.showStats && combined.stats && (
<div style={{ flex: 1, minHeight: 0 }}>
<AveragesSection stats={combined.stats} />
</div>
)}
{latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{averages && height >= 170 && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
</Stack>
)}
{hasChart && (
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types";
import { combineSpeedtestDashboards, mergeStats } from "./helpers";
const stats = (average: number, totalResults: number): SpeedtestTrackerStats => ({
ping: { avg: average, min: average, max: average },
download: { avg: average, avg_bits: average, min: average, max: average },
upload: { avg: average, avg_bits: average, min: average, max: average },
total_results: totalResults,
});
describe("speedtest dashboard aggregation", () => {
it("weights averages by result count", () => {
expect(mergeStats(stats(10, 1), stats(20, 3))?.ping.avg).toBe(17.5);
});
it("selects the newest latest result independent of integration order", () => {
const older = { id: 1, ping: 1, download_bits: 1, upload_bits: 1, healthy: true, created_at: new Date(1) };
const newer = { ...older, id: 2, created_at: new Date(2) };
const dashboards: SpeedtestTrackerDashboardData[] = [
{ latestResult: newer, stats: null, recentResults: [] },
{ latestResult: older, stats: null, recentResults: [] },
];
expect(combineSpeedtestDashboards(dashboards).latestResult?.id).toBe(2);
});
});
@@ -11,34 +11,58 @@ export const mergeStats = (
): SpeedtestTrackerDashboardData["stats"] => {
if (!statsB) return statsA;
if (!statsA) return statsB;
const totalResults = statsA.total_results + statsB.total_results;
const weightedAverage = (left: number, right: number) =>
totalResults > 0 ? (left * statsA.total_results + right * statsB.total_results) / totalResults : (left + right) / 2;
return {
ping: {
avg: (statsA.ping.avg + statsB.ping.avg) / 2,
avg: weightedAverage(statsA.ping.avg, statsB.ping.avg),
min: Math.min(statsA.ping.min, statsB.ping.min),
max: Math.max(statsA.ping.max, statsB.ping.max),
},
download: {
avg: (statsA.download.avg + statsB.download.avg) / 2,
avg: weightedAverage(statsA.download.avg, statsB.download.avg),
avg_bits:
statsA.download.avg_bits !== undefined && statsB.download.avg_bits !== undefined
? (statsA.download.avg_bits + statsB.download.avg_bits) / 2
? weightedAverage(statsA.download.avg_bits, statsB.download.avg_bits)
: (statsA.download.avg_bits ?? statsB.download.avg_bits),
min: Math.min(statsA.download.min, statsB.download.min),
max: Math.max(statsA.download.max, statsB.download.max),
},
upload: {
avg: (statsA.upload.avg + statsB.upload.avg) / 2,
avg: weightedAverage(statsA.upload.avg, statsB.upload.avg),
avg_bits:
statsA.upload.avg_bits !== undefined && statsB.upload.avg_bits !== undefined
? (statsA.upload.avg_bits + statsB.upload.avg_bits) / 2
? weightedAverage(statsA.upload.avg_bits, statsB.upload.avg_bits)
: (statsA.upload.avg_bits ?? statsB.upload.avg_bits),
min: Math.min(statsA.upload.min, statsB.upload.min),
max: Math.max(statsA.upload.max, statsB.upload.max),
},
total_results: statsA.total_results + statsB.total_results,
total_results: totalResults,
};
};
export const selectLatestResult = (
current: SpeedtestTrackerResult | null,
candidate: SpeedtestTrackerResult | null,
): SpeedtestTrackerResult | null => {
if (!candidate) return current;
if (!current) return candidate;
return candidate.created_at.getTime() > current.created_at.getTime() ? candidate : current;
};
export const combineSpeedtestDashboards = (
dashboards: SpeedtestTrackerDashboardData[],
): SpeedtestTrackerDashboardData =>
dashboards.reduce<SpeedtestTrackerDashboardData>(
(combined, dashboard) => ({
latestResult: selectLatestResult(combined.latestResult, dashboard.latestResult),
stats: mergeStats(combined.stats, dashboard.stats),
recentResults: [...combined.recentResults, ...dashboard.recentResults],
}),
{ latestResult: null, stats: null, recentResults: [] },
);
export const formatBitsPerSec = (bps: number): string => `${formatNumber(bps, 2)}bps`;
export const formatResultSpeed = (result: SpeedtestTrackerResult, dir: "download" | "upload"): string => {
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat,
queryKey: [["widget", "speedtestTracker"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from(
@@ -10,7 +10,7 @@ import { formatResultSpeed } from "./helpers";
import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card";
export function LatestResultSection({ result }: { result: SpeedtestTrackerResult }) {
export function LatestResultSection({ result, width = 500 }: { result: SpeedtestTrackerResult; width?: number }) {
const t = useScopedI18n("widget.speedtestTracker");
const locale = useCurrentLocale();
const timestamp = Intl.DateTimeFormat(locale, {
@@ -19,7 +19,8 @@ export function LatestResultSection({ result }: { result: SpeedtestTrackerResult
hour: "2-digit",
minute: "2-digit",
}).format(result.created_at);
const cols = result.healthy !== null ? 4 : 3;
const metricCount = result.healthy !== null ? 4 : 3;
const cols = width < 230 ? 2 : metricCount;
return (
<Stack gap={6} h="100%">