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

+5 -1
View File
@@ -67,4 +67,8 @@ e2e/shared/tmp
apps/nextjs/public/images/background.png
# next-intl
en.d.json.ts
en.d.json.ts
# personal agent config
AGENTS.md
.opencode
+1
View File
@@ -28,4 +28,5 @@ export const widgetRouter = createTRPCRouter({
notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)),
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
});
@@ -0,0 +1,55 @@
import { observable } from "@trpc/server/observable";
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeToDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
.subscription(({ ctx }) => {
return observable<{
integrationId: string;
dashboard: SpeedtestTrackerDashboardData;
timestamp: Date;
}>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
return innerHandler.subscribe((dashboard) => {
emit.next({
integrationId: integration.id,
dashboard,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
}),
});
+9
View File
@@ -356,6 +356,14 @@ export const integrationDefs = {
category: ["mediaMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"),
},
speedtestTracker: {
name: "Speedtest Tracker",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png",
category: ["speedtest"],
// @ts-expect-error - docs page will be created when integration is merged
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -445,6 +453,7 @@ export const integrationCategories = [
"timetable",
"photoService",
"mediaMonitoring",
"speedtest",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
+1
View File
@@ -35,5 +35,6 @@ export const widgetKinds = [
"immich-serverStats",
"immich-albumCarousel",
"tracearr",
"speedtestTracker",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -42,6 +42,7 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { QuayIntegration } from "../quay/quay-integration";
import { SearchChIntegration } from "../search-ch/search-ch-integration";
import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
@@ -117,6 +118,7 @@ export const integrationCreators = {
glances: GlancesIntegration,
searchCh: SearchChIntegration,
immich: ImmichIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
+2
View File
@@ -31,6 +31,7 @@ export { ICalIntegration } from "./ical/ical-integration";
export { CoolifyIntegration } from "./coolify/coolify-integration";
export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -58,6 +59,7 @@ export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-
export type { Notification } from "./interfaces/notifications/notification-types";
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
// Schemas
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
@@ -0,0 +1,98 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import {
speedtestTrackerLatestResultEnvelopeSchema,
speedtestTrackerResultsCollectionSchema,
speedtestTrackerStatsEnvelopeSchema,
} from "./speedtest-tracker-types";
import type {
SpeedtestTrackerDashboardData,
SpeedtestTrackerResult,
SpeedtestTrackerResultsCollection,
SpeedtestTrackerStats,
} from "./speedtest-tracker-types";
export class SpeedtestTrackerIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const url = this.url("/api/v1/results/latest");
const response = await input.fetchAsync(url, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return { success: true };
}
public async getLatestResultAsync(): Promise<SpeedtestTrackerResult | null> {
const response = await this.getAsync("/api/v1/results/latest");
// Speedtest Tracker returns 404 when no tests have been run yet (fresh install)
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new ResponseError(response);
}
return speedtestTrackerLatestResultEnvelopeSchema.parse(await response.json()).data;
}
public async getStatsAsync(): Promise<SpeedtestTrackerStats | null> {
const response = await this.getAsync("/api/v1/stats");
if (!response.ok) {
throw new ResponseError(response);
}
return speedtestTrackerStatsEnvelopeSchema.parse(await response.json()).data;
}
public async getRecentResultsAsync(perPage = 10): Promise<SpeedtestTrackerResultsCollection> {
const response = await this.getAsync("/api/v1/results", {
result_count: String(perPage),
sort: "-created_at",
});
if (!response.ok) {
throw new ResponseError(response);
}
return speedtestTrackerResultsCollectionSchema.parse(await response.json());
}
public async getDashboardDataAsync(): Promise<SpeedtestTrackerDashboardData> {
const [latestResult, stats, recentCollection] = await Promise.all([
this.getLatestResultAsync(),
this.getStatsAsync(),
this.getRecentResultsAsync(30),
]);
return {
latestResult,
stats,
recentResults: recentCollection.data,
};
}
private async getAsync(path: `/api/v1/${string}`, queryParams?: Record<string, string>) {
return await fetchWithTrustedCertificatesAsync(this.url(path, queryParams), {
headers: this.getAuthHeaders(),
});
}
private getAuthHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
Accept: "application/json",
};
}
}
@@ -0,0 +1,58 @@
import { z } from "zod/v4";
const createEnvelopeSchema = <T extends z.ZodType>(dataSchema: T) => z.object({ data: dataSchema });
const parseTimestamp = (timestamp: string): Date => new Date(`${timestamp.replace(" ", "T")}Z`);
export const speedtestTrackerResultSchema = z.object({
id: z.number().int(),
ping: z.number().nullable(),
download_bits: z.number().int().nullish(),
upload_bits: z.number().int().nullish(),
healthy: z.boolean().nullable(),
created_at: z.string().transform(parseTimestamp),
});
export type SpeedtestTrackerResult = z.infer<typeof speedtestTrackerResultSchema>;
export const speedtestTrackerLatestResultEnvelopeSchema = createEnvelopeSchema(speedtestTrackerResultSchema);
export const speedtestTrackerStatsBandwidthSchema = z.object({
avg: z.number(),
avg_bits: z.number().optional(),
min: z.number(),
max: z.number(),
});
export const speedtestTrackerStatsSchema = z.object({
ping: z.object({
avg: z.number(),
min: z.number(),
max: z.number(),
}),
download: speedtestTrackerStatsBandwidthSchema,
upload: speedtestTrackerStatsBandwidthSchema,
total_results: z.number().int(),
});
export type SpeedtestTrackerStats = z.infer<typeof speedtestTrackerStatsSchema>;
export const speedtestTrackerStatsEnvelopeSchema = createEnvelopeSchema(speedtestTrackerStatsSchema);
export const speedtestTrackerResultsCollectionSchema = z.object({
data: z.array(speedtestTrackerResultSchema),
meta: z.object({
current_page: z.number().int(),
last_page: z.number().int(),
total: z.number().int(),
per_page: z.number().int().optional(),
}),
});
export type SpeedtestTrackerResultsCollection = z.infer<typeof speedtestTrackerResultsCollectionSchema>;
export interface SpeedtestTrackerDashboardData {
latestResult: SpeedtestTrackerResult | null;
stats: SpeedtestTrackerStats | null;
recentResults: SpeedtestTrackerResult[];
}
+1
View File
@@ -14,3 +14,4 @@ export * from "./interfaces/media-releases";
export * from "./coolify/coolify-types";
export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
@@ -0,0 +1,19 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const speedtestTrackerRequestHandler = createCachedIntegrationRequestHandler<
SpeedtestTrackerDashboardData,
"speedtestTracker",
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(10, "minutes"),
queryKey: "speedtestTrackerDashboard",
});
+39
View File
@@ -2732,6 +2732,45 @@
"noItems": "No notifications to display.",
"option": {}
},
"speedtestTracker": {
"name": "Speedtest Tracker",
"description": "Displays speed test results from your Speedtest Tracker instance.",
"option": {
"showLatestResult": {
"label": "Show latest result"
},
"showStats": {
"label": "Show averages"
},
"showRecentResults": {
"label": "Show recent results"
},
"showPingGraph": {
"label": "Show ping graph"
}
},
"latestResult": "Latest Result",
"averages": "Averages",
"tests": "{count} tests",
"recentResults": "Recent Results",
"recentResultsSpeed": "Speed",
"recentResultsPing": "Ping",
"download": "Download",
"upload": "Upload",
"ping": "Ping",
"status": "Status",
"avgDownload": "Avg Download",
"avgUpload": "Avg Upload",
"avgPing": "Avg Ping",
"healthy": "Healthy",
"unhealthy": "Unhealthy",
"ok": "OK",
"fail": "Fail",
"noSectionsEnabled": "Enable at least one section in widget options.",
"error": {
"internalServerError": "Failed to fetch speedtest data"
}
},
"systemResources": {
"name": "System resources",
"description": "CPU, Memory, Disk and other hardware usage of your system",
+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>
);
}