feat: speedtest tracker integration (#5363)

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
KyleandMeier Lukas authored and GitHub committed 2026-04-10 19:04:42 +02:00
1 parent c861878058
commit 22d599c0cf
21 files changed
+1005 -1

No files matched your search

+2
View File
@@ -39,6 +39,7 @@ import * as releases from "./releases";
import * as rssFeed from "./rssFeed";
import * as smartHomeEntityState from "./smart-home/entity-state";
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
import * as speedtestTracker from "./speedtest-tracker";
import * as stockPrice from "./stocks";
import * as systemDisks from "./system-disks";
import * as systemResources from "./system-resources";
@@ -87,6 +88,7 @@ export const widgetImports = {
"immich-serverStats": immichServerStats,
"immich-albumCarousel": immichAlbumCarousel,
tracearr,
speedtestTracker,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
@@ -0,0 +1,46 @@
"use client";
import { SimpleGrid, Stack } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconWaveSine } from "@tabler/icons-react";
import type { SpeedtestTrackerStats } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { formatStatsSpeed } from "./helpers";
import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card";
export function AveragesSection({ stats }: { stats: SpeedtestTrackerStats }) {
const t = useScopedI18n("widget.speedtestTracker");
return (
<Stack gap={6} h="100%">
<SectionLabel>
{t("averages")} · {t("tests", { count: stats.total_results.toString() })}
</SectionLabel>
<SimpleGrid cols={3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard
icon={IconArrowDown}
color="blue"
value={formatStatsSpeed(stats.download)}
label={t("avgDownload")}
compact
/>
<SpeedStatCard
icon={IconArrowUp}
color="teal"
value={formatStatsSpeed(stats.upload)}
label={t("avgUpload")}
compact
/>
<SpeedStatCard
icon={IconWaveSine}
color="orange"
value={`${stats.ping.avg.toFixed(1)} ms`}
label={t("avgPing")}
compact
/>
</SimpleGrid>
</Stack>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useMemo } from "react";
import { 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 { AveragesSection } from "./averages";
import { mergeStats } from "./helpers";
import { LatestResultSection } from "./latest-result";
import { RecentResultsSection } from "./recent-results";
export default function SpeedtestTrackerWidget({
options,
integrationIds,
isEditMode,
}: WidgetComponentProps<"speedtestTracker">) {
const t = useScopedI18n("widget.speedtestTracker");
const [dashboardData] = clientApi.widget.speedtestTracker.getDashboard.useSuspenseQuery({ integrationIds });
const utils = clientApi.useUtils();
clientApi.widget.speedtestTracker.subscribeToDashboard.useSubscription(
{ integrationIds },
{
enabled: !isEditMode,
onData(newData) {
utils.widget.speedtestTracker.getDashboard.setData({ integrationIds }, (prevData) => {
if (!prevData) return prevData;
return prevData.map((instance) =>
instance.integrationId === newData.integrationId
? { ...instance, dashboard: newData.dashboard, updatedAt: newData.timestamp }
: instance,
);
});
},
},
);
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: [] },
),
[dashboardData],
);
const twelveHoursAgo = Date.now() - 12 * 60 * 60 * 1000;
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > twelveHoursAgo);
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;
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>
)}
</Stack>
)}
{hasChart && (
<div style={{ flex: 2, minHeight: 0 }}>
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
</div>
)}
{noSectionsEnabled && (
<Text c="dimmed" ta="center" size="sm">
{t("noSectionsEnabled")}
</Text>
)}
</Stack>
);
}
@@ -0,0 +1,53 @@
import { formatNumber } from "@homarr/common";
import type {
SpeedtestTrackerDashboardData,
SpeedtestTrackerResult,
SpeedtestTrackerStats,
} from "@homarr/integrations/types";
export const mergeStats = (
statsA: SpeedtestTrackerDashboardData["stats"],
statsB: SpeedtestTrackerDashboardData["stats"],
): SpeedtestTrackerDashboardData["stats"] => {
if (!statsB) return statsA;
if (!statsA) return statsB;
return {
ping: {
avg: (statsA.ping.avg + statsB.ping.avg) / 2,
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_bits:
statsA.download.avg_bits !== undefined && statsB.download.avg_bits !== undefined
? (statsA.download.avg_bits + statsB.download.avg_bits) / 2
: (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_bits:
statsA.upload.avg_bits !== undefined && statsB.upload.avg_bits !== undefined
? (statsA.upload.avg_bits + statsB.upload.avg_bits) / 2
: (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,
};
};
export const formatBitsPerSec = (bps: number): string => `${formatNumber(bps, 2)}bps`;
export const formatResultSpeed = (result: SpeedtestTrackerResult, dir: "download" | "upload"): string => {
const bits = dir === "download" ? result.download_bits : result.upload_bits;
if (bits != null) return formatBitsPerSec(bits);
return "—";
};
export const formatStatsSpeed = (band: SpeedtestTrackerStats["download"]): string => {
if (band.avg_bits != null) return formatBitsPerSec(band.avg_bits);
return formatBitsPerSec(band.avg * 8);
};
@@ -0,0 +1,38 @@
import { IconServerOff, IconSpeedboat } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat,
createOptions() {
return optionsBuilder.from(
(factory) => ({
showLatestResult: factory.switch({
defaultValue: true,
}),
showStats: factory.switch({
defaultValue: true,
}),
showRecentResults: factory.switch({
defaultValue: true,
}),
showPingGraph: factory.switch({
defaultValue: true,
}),
}),
{
showPingGraph: {
shouldHide: (options) => !options.showRecentResults,
},
},
);
},
supportedIntegrations: ["speedtestTracker"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.speedtestTracker.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,62 @@
"use client";
import { Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconCircleCheck, IconCircleX, IconWaveSine } from "@tabler/icons-react";
import type { SpeedtestTrackerResult } from "@homarr/integrations/types";
import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client";
import { formatResultSpeed } from "./helpers";
import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card";
export function LatestResultSection({ result }: { result: SpeedtestTrackerResult }) {
const t = useScopedI18n("widget.speedtestTracker");
const locale = useCurrentLocale();
const timestamp = Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(result.created_at);
const cols = result.healthy !== null ? 4 : 3;
return (
<Stack gap={6} h="100%">
<Group justify="space-between" align="center" wrap="nowrap">
<SectionLabel>{t("latestResult")}</SectionLabel>
<Text size="xs" c="dimmed">
{timestamp}
</Text>
</Group>
<SimpleGrid cols={cols} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard
icon={IconArrowDown}
color="blue"
value={formatResultSpeed(result, "download")}
label={t("download")}
/>
<SpeedStatCard
icon={IconArrowUp}
color="teal"
value={formatResultSpeed(result, "upload")}
label={t("upload")}
/>
<SpeedStatCard
icon={IconWaveSine}
color="orange"
value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"}
label={t("ping")}
/>
{result.healthy !== null && (
<SpeedStatCard
icon={result.healthy ? IconCircleCheck : IconCircleX}
color={result.healthy ? "green" : "red"}
value={result.healthy ? t("healthy") : t("unhealthy")}
label={t("status")}
/>
)}
</SimpleGrid>
</Stack>
);
}
@@ -0,0 +1,359 @@
"use client";
import { useMemo } from "react";
import { AreaChart, ChartTooltip } from "@mantine/charts";
import { Stack, useMantineTheme } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { ReferenceLine, XAxis } from "recharts";
import { useRequiredBoard } from "@homarr/boards/context";
import type { SpeedtestTrackerResult } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { SectionLabel } from "./section-label";
interface XAxisTicks {
midnightTs: number | null;
xTicks: number[];
topDateTicks: number[];
}
function buildXAxisTicks(data: { ts: number }[]): XAxisTicks {
if (data.length === 0) return { midnightTs: null, xTicks: [], topDateTicks: [] };
const first = data[0]?.ts ?? 0;
const last = data.at(-1)?.ts ?? 0;
const twoHourMs = 2 * 60 * 60 * 1000;
const startTick = Math.ceil(first / twoHourMs) * twoHourMs;
const tickSet = new Set<number>();
for (let t = startTick; t <= last; t += twoHourMs) tickSet.add(t);
const midnightDate = new Date(first);
midnightDate.setHours(24, 0, 0, 0);
const mTs = midnightDate.getTime();
const foundMidnight = mTs > first && mTs < last ? mTs : null;
if (foundMidnight) tickSet.add(foundMidnight);
const topTicks: number[] = [first];
if (foundMidnight) topTicks.push(foundMidnight);
return {
midnightTs: foundMidnight,
xTicks: Array.from(tickSet).sort((tsA, tsB) => tsA - tsB),
topDateTicks: topTicks,
};
}
function makeXTickRenderer(midnightTs: number | null) {
return ({ x, y, payload }: { x: number; y: number; payload: { value: number } }) => {
const date = new Date(payload.value);
const timeStr = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const isMidnight = midnightTs !== null && payload.value === midnightTs;
const dateStr = isMidnight ? date.toLocaleDateString([], { month: "short", day: "numeric" }) : null;
return (
<g transform={`translate(${x},${y})`}>
<text x={0} y={12} textAnchor="middle" fontSize={10} fill="var(--mantine-color-dimmed)">
{timeStr}
</text>
{dateStr && (
<text x={0} y={23} textAnchor="middle" fontSize={9} fill="var(--mantine-color-dimmed)">
{dateStr}
</text>
)}
</g>
);
};
}
function renderTopDateTick(props: { x: number; y: number; payload: { value: number }; index: number }) {
const { x, y, payload, index } = props;
const date = new Date(payload.value);
const dateStr = date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
const anchor = index === 0 ? "start" : "middle";
return (
<g transform={`translate(${x},${y})`}>
<text x={0} y={-4} textAnchor={anchor} fontSize={10} fill="var(--mantine-color-dimmed)">
{dateStr}
</text>
</g>
);
}
function buildYAxisConfig(maxVal: number, stepOptions: { threshold: number; step: number }[]) {
const step = (stepOptions.find((opt) => maxVal <= opt.threshold) ?? stepOptions.at(-1))?.step ?? 1;
const roundedMax = Math.ceil(maxVal / step) * step || step;
const ticks: number[] = [];
for (let val = 0; val <= roundedMax; val += step) ticks.push(val);
return { ticks, domain: [0, roundedMax] as [number, number] };
}
function formatTooltipDate(label: number | string | undefined): string {
const timestamp = typeof label === "number" ? label : Number(label);
if (Number.isNaN(timestamp)) return "";
return new Date(timestamp).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function RecentResultsSection({
results,
showPingGraph,
}: {
results: SpeedtestTrackerResult[];
showPingGraph: boolean;
}) {
const t = useScopedI18n("widget.speedtestTracker");
return (
<Stack gap={4} h="100%" style={{ minHeight: 0 }}>
<SectionLabel>{t("recentResults")}</SectionLabel>
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<SpeedSubChart results={results} showLabel={showPingGraph} />
{showPingGraph && <PingSubChart results={results} />}
</div>
</Stack>
);
}
function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult[]; showLabel: boolean }) {
const t = useScopedI18n("widget.speedtestTracker");
const { ref, height } = useElementSize<HTMLDivElement>();
return (
<div style={{ flex: 2, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}>
{showLabel && <SectionLabel>{t("recentResultsSpeed")}</SectionLabel>}
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
{height > 0 && <SpeedHistoryChart results={results} height={height} />}
</div>
</div>
);
}
function PingSubChart({ results }: { results: SpeedtestTrackerResult[] }) {
const t = useScopedI18n("widget.speedtestTracker");
const { ref, height } = useElementSize<HTMLDivElement>();
return (
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}>
<SectionLabel>{t("recentResultsPing")}</SectionLabel>
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
{height > 0 && <PingHistoryChart results={results} height={height} />}
</div>
</div>
);
}
function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResult[]; height: number }) {
const board = useRequiredBoard();
const theme = useMantineTheme();
const t = useScopedI18n("widget.speedtestTracker");
const data = useMemo(
() =>
[...results]
.sort((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime())
.filter((result) => (result.download_bits ?? 0) > 0)
.map((result) => ({
ts: result.created_at.getTime(),
Download: parseFloat(((result.download_bits ?? 0) / 1_000_000).toFixed(2)),
Upload:
result.upload_bits != null && result.upload_bits > 0
? parseFloat((result.upload_bits / 1_000_000).toFixed(2))
: 0,
})),
[results],
);
const yConfig = useMemo(
() =>
buildYAxisConfig(Math.max(...data.map((item) => Math.max(item.Download, item.Upload)), 0), [
{ threshold: 400, step: 100 },
{ threshold: Infinity, step: 200 },
]),
[data],
);
const { midnightTs, xTicks, topDateTicks } = useMemo(() => buildXAxisTicks(data), [data]);
const renderXTick = useMemo(() => makeXTickRenderer(midnightTs), [midnightTs]);
if (data.length === 0) return null;
return (
<AreaChart
h={height}
data={data}
dataKey="ts"
series={[
{ name: "Download", label: t("download"), color: "blue" },
{ name: "Upload", label: t("upload"), color: "teal" },
]}
curveType="monotone"
gridAxis="y"
tickLine="none"
withLegend
fillOpacity={0.2}
styles={{ root: { padding: 5, borderRadius: theme.radius[board.itemRadius] } }}
valueFormatter={(val: number) => `${val} Mbps`}
xAxisProps={{
type: "number",
domain: ["dataMin", "dataMax"],
ticks: xTicks,
tick: renderXTick,
interval: 0,
height: midnightTs !== null ? 38 : 28,
}}
yAxisProps={{
ticks: yConfig.ticks,
domain: yConfig.domain,
tickFormatter: (val: number) => `${val}`,
width: 50,
tick: { fontSize: 10 },
}}
tooltipProps={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: (props: any) => {
const { label, payload, active } = props as {
label?: number | string;
payload?: Record<string, unknown>[];
active?: boolean;
};
if (!active || !payload?.length) return null;
return (
<ChartTooltip
label={formatTooltipDate(label)}
payload={payload}
valueFormatter={(val: number) => `${val} Mbps`}
/>
);
},
}}
>
<XAxis
xAxisId="topDates"
orientation="top"
type="number"
dataKey="ts"
domain={["dataMin", "dataMax"]}
ticks={topDateTicks}
tick={renderTopDateTick}
height={20}
axisLine={false}
tickLine={false}
interval={0}
/>
{xTicks.map((tickTs) => (
<ReferenceLine
key={tickTs}
x={tickTs}
yAxisId="left"
stroke="var(--mantine-color-dimmed)"
strokeDasharray="3 3"
strokeOpacity={0.35}
/>
))}
</AreaChart>
);
}
function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult[]; height: number }) {
const board = useRequiredBoard();
const theme = useMantineTheme();
const t = useScopedI18n("widget.speedtestTracker");
const data = useMemo(
() =>
[...results]
.sort((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime())
.map((result) =>
result.ping === null
? null
: {
ts: result.created_at.getTime(),
Ping: result.ping,
},
)
.filter((result) => result !== null),
[results],
);
const yConfig = useMemo(
() =>
buildYAxisConfig(Math.max(...data.map((item) => item.Ping), 0), [
{ threshold: 10, step: 2 },
{ threshold: 50, step: 10 },
{ threshold: 200, step: 50 },
{ threshold: Infinity, step: 100 },
]),
[data],
);
const { midnightTs, xTicks } = useMemo(() => buildXAxisTicks(data), [data]);
const renderXTick = useMemo(() => makeXTickRenderer(midnightTs), [midnightTs]);
if (data.length === 0) return null;
return (
<AreaChart
h={height}
data={data}
dataKey="ts"
series={[{ name: "Ping", label: t("ping"), color: "orange" }]}
curveType="monotone"
gridAxis="y"
tickLine="none"
withLegend={false}
fillOpacity={0.2}
styles={{ root: { padding: 5, borderRadius: theme.radius[board.itemRadius] } }}
valueFormatter={(val: number) => `${val} ms`}
xAxisProps={{
type: "number",
domain: ["dataMin", "dataMax"],
ticks: xTicks,
tick: renderXTick,
interval: 0,
height: midnightTs !== null ? 38 : 28,
}}
yAxisProps={{
ticks: yConfig.ticks,
domain: yConfig.domain,
tickFormatter: (val: number) => `${val}`,
width: 50,
tick: { fontSize: 10 },
}}
tooltipProps={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: (props: any) => {
const { label, payload, active } = props as {
label?: number | string;
payload?: Record<string, unknown>[];
active?: boolean;
};
if (!active || !payload?.length) return null;
return (
<ChartTooltip
label={formatTooltipDate(label)}
payload={payload}
valueFormatter={(val: number) => `${val} ms`}
/>
);
},
}}
>
{xTicks.map((tickTs) => (
<ReferenceLine
key={tickTs}
x={tickTs}
yAxisId="left"
stroke="var(--mantine-color-dimmed)"
strokeDasharray="3 3"
strokeOpacity={0.35}
/>
))}
</AreaChart>
);
}
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import { Text } from "@mantine/core";
export function SectionLabel({ children }: { children: ReactNode }) {
return (
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.05em" }}>
{children}
</Text>
);
}
@@ -0,0 +1,54 @@
"use client";
import { Card, Flex, Text } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context";
import type { TablerIcon } from "@homarr/ui";
export interface SpeedStatCardProps {
icon: TablerIcon;
color: string;
value: string;
label: string;
compact?: boolean;
}
export function SpeedStatCard({ icon: Icon, color, value, label, compact = false }: SpeedStatCardProps) {
const { ref, height, width } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38;
return (
<Card
ref={ref}
p={compact ? "xs" : "sm"}
radius={board.itemRadius}
bg={`var(--mantine-color-${color}-light)`}
h="100%"
style={{ flex: 1 }}
>
<Flex
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
>
<Icon size={compact ? 16 : 20} color={`var(--mantine-color-${color}-5)`} style={{ flexShrink: 0 }} />
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1}>
{value}
</Text>
{!hideLabel && (
<Text size="xs" c="dimmed" ta="center" lh={1.3}>
{label}
</Text>
)}
</Flex>
</Flex>
</Card>
);
}