feat: speedtest tracker integration (#5363)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
1 parent
c861878058
commit
22d599c0cf
21 files changed
+1005
-1
No files matched your search
+5
-1
@@ -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
|
||||
@@ -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());
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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];
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user