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

@@ -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";