From 22d599c0cf64b39f3d616ceadc6c2ac32551f99d Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 10 Apr 2026 11:04:42 -0600 Subject: [PATCH] feat: speedtest tracker integration (#5363) Co-authored-by: Meier Lukas --- .gitignore | 6 +- packages/api/src/router/widgets/index.ts | 1 + .../src/router/widgets/speedtest-tracker.ts | 55 +++ packages/definitions/src/integration.ts | 9 + packages/definitions/src/widget.ts | 1 + packages/integrations/src/base/creator.ts | 2 + packages/integrations/src/index.ts | 2 + .../speedtest-tracker-integration.ts | 98 +++++ .../speedtest-tracker-types.ts | 58 +++ packages/integrations/src/types.ts | 1 + .../request-handler/src/speedtest-tracker.ts | 19 + packages/translation/src/lang/en.json | 39 ++ packages/widgets/src/index.tsx | 2 + .../src/speedtest-tracker/averages.tsx | 46 +++ .../src/speedtest-tracker/component.tsx | 91 +++++ .../widgets/src/speedtest-tracker/helpers.ts | 53 +++ .../widgets/src/speedtest-tracker/index.ts | 38 ++ .../src/speedtest-tracker/latest-result.tsx | 62 +++ .../src/speedtest-tracker/recent-results.tsx | 359 ++++++++++++++++++ .../src/speedtest-tracker/section-label.tsx | 10 + .../src/speedtest-tracker/speed-stat-card.tsx | 54 +++ 21 files changed, 1005 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/router/widgets/speedtest-tracker.ts create mode 100644 packages/integrations/src/speedtest-tracker/speedtest-tracker-integration.ts create mode 100644 packages/integrations/src/speedtest-tracker/speedtest-tracker-types.ts create mode 100644 packages/request-handler/src/speedtest-tracker.ts create mode 100644 packages/widgets/src/speedtest-tracker/averages.tsx create mode 100644 packages/widgets/src/speedtest-tracker/component.tsx create mode 100644 packages/widgets/src/speedtest-tracker/helpers.ts create mode 100644 packages/widgets/src/speedtest-tracker/index.ts create mode 100644 packages/widgets/src/speedtest-tracker/latest-result.tsx create mode 100644 packages/widgets/src/speedtest-tracker/recent-results.tsx create mode 100644 packages/widgets/src/speedtest-tracker/section-label.tsx create mode 100644 packages/widgets/src/speedtest-tracker/speed-stat-card.tsx diff --git a/.gitignore b/.gitignore index 0aa3ed4c7..43488df7a 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,8 @@ e2e/shared/tmp apps/nextjs/public/images/background.png # next-intl -en.d.json.ts \ No newline at end of file +en.d.json.ts + +# personal agent config +AGENTS.md +.opencode \ No newline at end of file diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index e58c62e6a..9d4f579cc 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -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)), }); diff --git a/packages/api/src/router/widgets/speedtest-tracker.ts b/packages/api/src/router/widgets/speedtest-tracker.ts new file mode 100644 index 000000000..be4245921 --- /dev/null +++ b/packages/api/src/router/widgets/speedtest-tracker.ts @@ -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()); + }; + }); + }), +}); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index e9853b4bb..106d4a6a4 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -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]; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 732b3f1bc..b2a3147b1 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -35,5 +35,6 @@ export const widgetKinds = [ "immich-serverStats", "immich-albumCarousel", "tracearr", + "speedtestTracker", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index c0743b5d4..c6a2cebc9 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -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 Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 3514e022b..2e87cb479 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -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"; diff --git a/packages/integrations/src/speedtest-tracker/speedtest-tracker-integration.ts b/packages/integrations/src/speedtest-tracker/speedtest-tracker-integration.ts new file mode 100644 index 000000000..80517c69e --- /dev/null +++ b/packages/integrations/src/speedtest-tracker/speedtest-tracker-integration.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) { + return await fetchWithTrustedCertificatesAsync(this.url(path, queryParams), { + headers: this.getAuthHeaders(), + }); + } + + private getAuthHeaders(): Record { + return { + Authorization: `Bearer ${this.getSecretValue("apiKey")}`, + Accept: "application/json", + }; + } +} diff --git a/packages/integrations/src/speedtest-tracker/speedtest-tracker-types.ts b/packages/integrations/src/speedtest-tracker/speedtest-tracker-types.ts new file mode 100644 index 000000000..83aa5bb24 --- /dev/null +++ b/packages/integrations/src/speedtest-tracker/speedtest-tracker-types.ts @@ -0,0 +1,58 @@ +import { z } from "zod/v4"; + +const createEnvelopeSchema = (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; + +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; + +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; + +export interface SpeedtestTrackerDashboardData { + latestResult: SpeedtestTrackerResult | null; + stats: SpeedtestTrackerStats | null; + recentResults: SpeedtestTrackerResult[]; +} diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 7c7bc4780..4ba41a789 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -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"; diff --git a/packages/request-handler/src/speedtest-tracker.ts b/packages/request-handler/src/speedtest-tracker.ts new file mode 100644 index 000000000..799d37f16 --- /dev/null +++ b/packages/request-handler/src/speedtest-tracker.ts @@ -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 +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getDashboardDataAsync(); + }, + cacheDuration: dayjs.duration(10, "minutes"), + queryKey: "speedtestTrackerDashboard", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 0d88f2e63..6d5fe7ad1 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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", diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 2e3a3de03..89cbc2ab9 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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; diff --git a/packages/widgets/src/speedtest-tracker/averages.tsx b/packages/widgets/src/speedtest-tracker/averages.tsx new file mode 100644 index 000000000..41b116d56 --- /dev/null +++ b/packages/widgets/src/speedtest-tracker/averages.tsx @@ -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 ( + + + {t("averages")} · {t("tests", { count: stats.total_results.toString() })} + + + + + + + + ); +} diff --git a/packages/widgets/src/speedtest-tracker/component.tsx b/packages/widgets/src/speedtest-tracker/component.tsx new file mode 100644 index 000000000..699b354a9 --- /dev/null +++ b/packages/widgets/src/speedtest-tracker/component.tsx @@ -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( + (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 ( + + {hasStatSection && ( + + {options.showLatestResult && combined.latestResult && ( +
+ +
+ )} + {options.showStats && combined.stats && ( +
+ +
+ )} +
+ )} + {hasChart && ( +
+ +
+ )} + {noSectionsEnabled && ( + + {t("noSectionsEnabled")} + + )} +
+ ); +} diff --git a/packages/widgets/src/speedtest-tracker/helpers.ts b/packages/widgets/src/speedtest-tracker/helpers.ts new file mode 100644 index 000000000..075fdb90c --- /dev/null +++ b/packages/widgets/src/speedtest-tracker/helpers.ts @@ -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); +}; diff --git a/packages/widgets/src/speedtest-tracker/index.ts b/packages/widgets/src/speedtest-tracker/index.ts new file mode 100644 index 000000000..ea3c0c5d2 --- /dev/null +++ b/packages/widgets/src/speedtest-tracker/index.ts @@ -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")); diff --git a/packages/widgets/src/speedtest-tracker/latest-result.tsx b/packages/widgets/src/speedtest-tracker/latest-result.tsx new file mode 100644 index 000000000..fcc6136cf --- /dev/null +++ b/packages/widgets/src/speedtest-tracker/latest-result.tsx @@ -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 ( + + + {t("latestResult")} + + {timestamp} + + + + + + + {result.healthy !== null && ( + + )} + + + ); +} diff --git a/packages/widgets/src/speedtest-tracker/recent-results.tsx b/packages/widgets/src/speedtest-tracker/recent-results.tsx new file mode 100644 index 000000000..286baffc6 --- /dev/null +++ b/packages/widgets/src/speedtest-tracker/recent-results.tsx @@ -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(); + 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 ( + + + {timeStr} + + {dateStr && ( + + {dateStr} + + )} + + ); + }; +} + +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 ( + + + {dateStr} + + + ); +} + +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 ( + + {t("recentResults")} +
+ + {showPingGraph && } +
+
+ ); +} + +function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult[]; showLabel: boolean }) { + const t = useScopedI18n("widget.speedtestTracker"); + const { ref, height } = useElementSize(); + + return ( +
+ {showLabel && {t("recentResultsSpeed")}} +
+ {height > 0 && } +
+
+ ); +} + +function PingSubChart({ results }: { results: SpeedtestTrackerResult[] }) { + const t = useScopedI18n("widget.speedtestTracker"); + const { ref, height } = useElementSize(); + + return ( +
+ {t("recentResultsPing")} +
+ {height > 0 && } +
+
+ ); +} + +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 ( + `${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[]; + active?: boolean; + }; + if (!active || !payload?.length) return null; + return ( + `${val} Mbps`} + /> + ); + }, + }} + > + + {xTicks.map((tickTs) => ( + + ))} + + ); +} + +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 ( + `${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[]; + active?: boolean; + }; + if (!active || !payload?.length) return null; + return ( + `${val} ms`} + /> + ); + }, + }} + > + {xTicks.map((tickTs) => ( + + ))} + + ); +} diff --git a/packages/widgets/src/speedtest-tracker/section-label.tsx b/packages/widgets/src/speedtest-tracker/section-label.tsx new file mode 100644 index 000000000..255e05113 --- /dev/null +++ b/packages/widgets/src/speedtest-tracker/section-label.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import { Text } from "@mantine/core"; + +export function SectionLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx b/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx new file mode 100644 index 000000000..0817d07f4 --- /dev/null +++ b/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx @@ -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(); + const board = useRequiredBoard(); + const isWide = width > height + 20; + const hideLabel = height > 0 && height <= 38; + + return ( + + + + + + {value} + + {!hideLabel && ( + + {label} + + )} + + + + ); +}