feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -4,27 +4,36 @@ 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 { useI18n } 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");
export function AveragesSection({
stats,
width = 500,
compactSurface = false,
}: {
stats: SpeedtestTrackerStats;
width?: number;
compactSurface?: boolean;
}) {
const t = useI18n("widget.speedtestTracker");
return (
<Stack gap={6} h="100%">
<SectionLabel>
<SectionLabel uppercase={compactSurface}>
{t("averages")} · {t("tests", { count: stats.total_results.toString() })}
</SectionLabel>
<SimpleGrid cols={3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SimpleGrid cols={width < 220 ? 1 : 3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard
icon={IconArrowDown}
color="blue"
value={formatStatsSpeed(stats.download)}
label={t("avgDownload")}
compact
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconArrowUp}
@@ -32,6 +41,7 @@ export function AveragesSection({ stats }: { stats: SpeedtestTrackerStats }) {
value={formatStatsSpeed(stats.upload)}
label={t("avgUpload")}
compact
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconWaveSine}
@@ -39,6 +49,7 @@ export function AveragesSection({ stats }: { stats: SpeedtestTrackerStats }) {
value={`${stats.ping.avg.toFixed(1)} ms`}
label={t("avgPing")}
compact
legacySurface={compactSurface}
/>
</SimpleGrid>
</Stack>
@@ -1,71 +1,84 @@
"use client";
import { useMemo } from "react";
import { Stack, Text } from "@mantine/core";
import { Box, Group, 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 { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages";
import { mergeStats } from "./helpers";
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards } from "./helpers";
import { LatestResultSection } from "./latest-result";
import { RecentResultsSection } from "./recent-results";
export default function SpeedtestTrackerWidget({ options, integrationIds }: WidgetComponentProps<"speedtestTracker">) {
const t = useScopedI18n("widget.speedtestTracker");
const { data: dashboardData = [] } = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const emptyDashboardData = [] as const;
export default function SpeedtestTrackerWidget({
options,
integrationIds,
width,
}: WidgetComponentProps<"speedtestTracker">) {
const t = useI18n("widget.speedtestTracker");
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]);
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],
() => combineSpeedtestDashboards(successfulDashboards.map(({ dashboard }) => dashboard)),
[successfulDashboards],
);
const twelveHoursAgo = Date.now() - 12 * 60 * 60 * 1000;
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > twelveHoursAgo);
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff);
const hasStatSection =
(options.showLatestResult && combined.latestResult !== null) || (options.showStats && combined.stats !== null);
const hasChart = options.showRecentResults && recentFiltered.length > 0;
const showLatestResult = options.showLatestResult;
const showStats = options.showStats;
const showRecentResults = options.showRecentResults;
const showPingGraph = options.showPingGraph;
const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null);
const hasChart = showRecentResults && recentFiltered.length > 0;
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
const latest = showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={width} compactSurface />
);
const averages = showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={width} compactSurface />
);
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>
)}
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
</Stack>
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
</Group>
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
{dashboardData.length > 1 && (
<Text size="xs" c="dimmed" truncate>
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
</Text>
)}
{(latest || averages) && (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
{latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
</Stack>
)}
{hasChart && (
<div style={{ flex: 2, minHeight: 0 }}>
<RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} uppercaseLabels />
</div>
)}
{noSectionsEnabled && (
<Text c="dimmed" ta="center" size="sm">
{t("noSectionsEnabled")}
</Text>
)}
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
</Stack>
</Box>
);
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types";
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers";
const stats = (average: number, totalResults: number): SpeedtestTrackerStats => ({
ping: { avg: average, min: average, max: average },
download: { avg: average, avg_bits: average, min: average, max: average },
upload: { avg: average, avg_bits: average, min: average, max: average },
total_results: totalResults,
});
describe("speedtest dashboard aggregation", () => {
it("weights averages by result count", () => {
expect(mergeStats(stats(10, 1), stats(20, 3))?.ping.avg).toBe(17.5);
});
it("selects the newest latest result independent of integration order", () => {
const older = { id: 1, ping: 1, download_bits: 1, upload_bits: 1, healthy: true, created_at: new Date(1) };
const newer = { ...older, id: 2, created_at: new Date(2) };
const dashboards: SpeedtestTrackerDashboardData[] = [
{ latestResult: newer, stats: null, recentResults: [] },
{ latestResult: older, stats: null, recentResults: [] },
];
expect(combineSpeedtestDashboards(dashboards).latestResult?.id).toBe(2);
});
});
describe("speedtest source ownership", () => {
it("keeps integration identity when upstream result ids overlap", () => {
const result = { id: 1, ping: 1, download_bits: 1, upload_bits: 1, healthy: true, created_at: new Date(1) };
const dashboard = { latestResult: result, stats: null, recentResults: [result] };
expect(
getAvailableSpeedtestDashboards([
{ integrationId: "speed-a", integrationName: "Speed A", dashboard },
{ integrationId: "speed-b", integrationName: "Speed B", dashboard },
]).map(({ integrationId, integrationName }) => ({ integrationId, integrationName })),
).toEqual([
{ integrationId: "speed-a", integrationName: "Speed A" },
{ integrationId: "speed-b", integrationName: "Speed B" },
]);
});
});
describe("getCompactSections", () => {
const available = { latest: true, chart: true, averages: true };
it("prioritizes the latest result in a short widget", () => {
expect(getCompactSections(180, available)).toEqual({ latest: true, chart: false, averages: false });
});
it("reveals history before averages as height grows", () => {
expect(getCompactSections(300, available)).toEqual({ latest: true, chart: true, averages: false });
expect(getCompactSections(400, available)).toEqual({ latest: true, chart: true, averages: true });
});
});
@@ -1,53 +1,110 @@
import { formatNumber } from "@homarr/common";
import { formatBitRate } from "@homarr/common";
import type {
SpeedtestTrackerDashboardData,
SpeedtestTrackerResult,
SpeedtestTrackerStats,
} from "@homarr/integrations/types";
export interface SpeedtestDashboardSource {
integrationId: string;
integrationName: string;
dashboard: SpeedtestTrackerDashboardData;
}
export const getAvailableSpeedtestDashboards = (
results: readonly {
integrationId: string;
integrationName: string;
dashboard: SpeedtestTrackerDashboardData | null;
}[],
): SpeedtestDashboardSource[] =>
results.flatMap(({ integrationId, integrationName, dashboard }) =>
dashboard === null ? [] : [{ integrationId, integrationName, dashboard }],
);
export const mergeStats = (
statsA: SpeedtestTrackerDashboardData["stats"],
statsB: SpeedtestTrackerDashboardData["stats"],
): SpeedtestTrackerDashboardData["stats"] => {
if (!statsB) return statsA;
if (!statsA) return statsB;
const totalResults = statsA.total_results + statsB.total_results;
const weightedAverage = (left: number, right: number) =>
totalResults > 0 ? (left * statsA.total_results + right * statsB.total_results) / totalResults : (left + right) / 2;
return {
ping: {
avg: (statsA.ping.avg + statsB.ping.avg) / 2,
avg: weightedAverage(statsA.ping.avg, statsB.ping.avg),
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: weightedAverage(statsA.download.avg, statsB.download.avg),
avg_bits:
statsA.download.avg_bits !== undefined && statsB.download.avg_bits !== undefined
? (statsA.download.avg_bits + statsB.download.avg_bits) / 2
? weightedAverage(statsA.download.avg_bits, statsB.download.avg_bits)
: (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: weightedAverage(statsA.upload.avg, statsB.upload.avg),
avg_bits:
statsA.upload.avg_bits !== undefined && statsB.upload.avg_bits !== undefined
? (statsA.upload.avg_bits + statsB.upload.avg_bits) / 2
? weightedAverage(statsA.upload.avg_bits, statsB.upload.avg_bits)
: (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,
total_results: totalResults,
};
};
export const formatBitsPerSec = (bps: number): string => `${formatNumber(bps, 2)}bps`;
export const selectLatestResult = (
current: SpeedtestTrackerResult | null,
candidate: SpeedtestTrackerResult | null,
): SpeedtestTrackerResult | null => {
if (!candidate) return current;
if (!current) return candidate;
return candidate.created_at.getTime() > current.created_at.getTime() ? candidate : current;
};
export const combineSpeedtestDashboards = (
dashboards: SpeedtestTrackerDashboardData[],
): SpeedtestTrackerDashboardData =>
dashboards.reduce<SpeedtestTrackerDashboardData>(
(combined, dashboard) => ({
latestResult: selectLatestResult(combined.latestResult, dashboard.latestResult),
stats: mergeStats(combined.stats, dashboard.stats),
recentResults: [...combined.recentResults, ...dashboard.recentResults],
}),
{ latestResult: null, stats: null, recentResults: [] },
);
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);
if (bits != null) return formatBitRate(bits);
return "—";
};
export const formatStatsSpeed = (band: SpeedtestTrackerStats["download"]): string => {
if (band.avg_bits != null) return formatBitsPerSec(band.avg_bits);
return formatBitsPerSec(band.avg * 8);
if (band.avg_bits != null) return formatBitRate(band.avg_bits);
return formatBitRate(band.avg * 8);
};
type CompactSection = "latest" | "chart" | "averages";
export const getCompactSections = (
height: number,
available: Record<CompactSection, boolean>,
): Record<CompactSection, boolean> => {
const budget = height < 240 ? 1 : height < 360 ? 2 : 3;
const visible = new Set(
(["latest", "chart", "averages"] as const).filter((section) => available[section]).slice(0, budget),
);
return {
latest: visible.has("latest"),
chart: visible.has("chart"),
averages: visible.has("averages"),
};
};
@@ -1,5 +1,7 @@
import { IconServerOff, IconSpeedboat } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -29,7 +31,7 @@ export const { definition, componentLoader } = createWidgetDefinition("speedtest
},
);
},
supportedIntegrations: ["speedtestTracker"],
...getWidgetIntegrationConfig("speedtestTracker"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
@@ -4,28 +4,37 @@ 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 { useCurrentIntlLocale, useI18n } 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();
export function LatestResultSection({
result,
width = 500,
compactSurface = false,
}: {
result: SpeedtestTrackerResult;
width?: number;
compactSurface?: boolean;
}) {
const t = useI18n("widget.speedtestTracker");
const locale = useCurrentIntlLocale();
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;
const metricCount = result.healthy !== null ? 4 : 3;
const cols = width < 230 ? 2 : metricCount;
return (
<Stack gap={6} h="100%">
<Group justify="space-between" align="center" wrap="nowrap">
<SectionLabel>{t("latestResult")}</SectionLabel>
<Text size="xs" c="dimmed">
<SectionLabel uppercase={compactSurface}>{t("latestResult")}</SectionLabel>
<Text size="xs" c="dimmed" truncate title={timestamp} style={{ minWidth: 0 }}>
{timestamp}
</Text>
</Group>
@@ -35,18 +44,21 @@ export function LatestResultSection({ result }: { result: SpeedtestTrackerResult
color="blue"
value={formatResultSpeed(result, "download")}
label={t("download")}
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconArrowUp}
color="teal"
value={formatResultSpeed(result, "upload")}
label={t("upload")}
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconWaveSine}
color="orange"
value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"}
label={t("ping")}
legacySurface={compactSurface}
/>
{result.healthy !== null && (
<SpeedStatCard
@@ -54,6 +66,7 @@ export function LatestResultSection({ result }: { result: SpeedtestTrackerResult
color={result.healthy ? "green" : "red"}
value={result.healthy ? t("healthy") : t("unhealthy")}
label={t("status")}
legacySurface={compactSurface}
/>
)}
</SimpleGrid>
@@ -4,14 +4,17 @@ 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 { XAxis } from "recharts";
import { useRequiredBoard } from "@homarr/boards/context";
import { formatBitRate } from "@homarr/common";
import type { SpeedtestTrackerResult } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { SectionLabel } from "./section-label";
const formatChartBitRate = (value: number) => formatBitRate(value);
interface XAxisTicks {
midnightTs: number | null;
xTicks: number[];
@@ -40,17 +43,17 @@ function buildXAxisTicks(data: { ts: number }[]): XAxisTicks {
return {
midnightTs: foundMidnight,
xTicks: Array.from(tickSet).sort((tsA, tsB) => tsA - tsB),
xTicks: Array.from(tickSet).toSorted((tsA, tsB) => tsA - tsB),
topDateTicks: topTicks,
};
}
function makeXTickRenderer(midnightTs: number | null) {
function makeXTickRenderer(midnightTs: number | null, locale: string) {
return ({ x, y, payload }: { x: number | string; y: number | string; payload: { value: number } }) => {
const date = new Date(payload.value);
const timeStr = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const timeStr = date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
const isMidnight = midnightTs !== null && payload.value === midnightTs;
const dateStr = isMidnight ? date.toLocaleDateString([], { month: "short", day: "numeric" }) : null;
const dateStr = isMidnight ? date.toLocaleDateString(locale, { month: "short", day: "numeric" }) : null;
return (
<g transform={`translate(${x},${y})`}>
@@ -67,23 +70,29 @@ function makeXTickRenderer(midnightTs: number | null) {
};
}
function renderTopDateTick(props: {
x: number | string;
y: number | string;
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 makeTopDateTickRenderer(locale: string) {
return ({
x,
y,
payload,
index,
}: {
x: number | string;
y: number | string;
payload: { value: number };
index: number;
}) => {
const date = new Date(payload.value);
const dateStr = date.toLocaleDateString(locale, { 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 }[]) {
@@ -94,10 +103,10 @@ function buildYAxisConfig(maxVal: number, stepOptions: { threshold: number; step
return { ticks, domain: [0, roundedMax] as [number, number] };
}
function formatTooltipDate(label: number | string | undefined): string {
function formatTooltipDate(label: number | string | undefined, locale: string): string {
const timestamp = typeof label === "number" ? label : Number(label);
if (Number.isNaN(timestamp)) return "";
return new Date(timestamp).toLocaleString([], {
return new Date(timestamp).toLocaleString(locale, {
month: "short",
day: "numeric",
hour: "2-digit",
@@ -108,30 +117,40 @@ function formatTooltipDate(label: number | string | undefined): string {
export function RecentResultsSection({
results,
showPingGraph,
uppercaseLabels = false,
}: {
results: SpeedtestTrackerResult[];
showPingGraph: boolean;
uppercaseLabels?: boolean;
}) {
const t = useScopedI18n("widget.speedtestTracker");
const t = useI18n("widget.speedtestTracker");
return (
<Stack gap={4} h="100%" style={{ minHeight: 0 }}>
<SectionLabel>{t("recentResults")}</SectionLabel>
<SectionLabel uppercase={uppercaseLabels}>{t("recentResults")}</SectionLabel>
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<SpeedSubChart results={results} showLabel={showPingGraph} />
{showPingGraph && <PingSubChart results={results} />}
<SpeedSubChart results={results} showLabel={showPingGraph} uppercaseLabels={uppercaseLabels} />
{showPingGraph && <PingSubChart results={results} uppercaseLabels={uppercaseLabels} />}
</div>
</Stack>
);
}
function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult[]; showLabel: boolean }) {
const t = useScopedI18n("widget.speedtestTracker");
function SpeedSubChart({
results,
showLabel,
uppercaseLabels,
}: {
results: SpeedtestTrackerResult[];
showLabel: boolean;
uppercaseLabels: boolean;
}) {
const t = useI18n("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>}
{showLabel && <SectionLabel uppercase={uppercaseLabels}>{t("recentResultsSpeed")}</SectionLabel>}
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
{height > 0 && <SpeedHistoryChart results={results} height={height} />}
</div>
@@ -139,13 +158,13 @@ function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult
);
}
function PingSubChart({ results }: { results: SpeedtestTrackerResult[] }) {
const t = useScopedI18n("widget.speedtestTracker");
function PingSubChart({ results, uppercaseLabels }: { results: SpeedtestTrackerResult[]; uppercaseLabels: boolean }) {
const t = useI18n("widget.speedtestTracker");
const { ref, height } = useElementSize<HTMLDivElement>();
return (
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}>
<SectionLabel>{t("recentResultsPing")}</SectionLabel>
<SectionLabel uppercase={uppercaseLabels}>{t("recentResultsPing")}</SectionLabel>
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
{height > 0 && <PingHistoryChart results={results} height={height} />}
</div>
@@ -156,20 +175,18 @@ function PingSubChart({ results }: { results: SpeedtestTrackerResult[] }) {
function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResult[]; height: number }) {
const board = useRequiredBoard();
const theme = useMantineTheme();
const t = useScopedI18n("widget.speedtestTracker");
const t = useI18n("widget.speedtestTracker");
const locale = useCurrentIntlLocale();
const data = useMemo(
() =>
[...results]
.sort((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime())
results
.toSorted((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,
Download: result.download_bits ?? 0,
Upload: result.upload_bits != null && result.upload_bits > 0 ? result.upload_bits : 0,
})),
[results],
);
@@ -177,14 +194,15 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul
const yConfig = useMemo(
() =>
buildYAxisConfig(Math.max(...data.map((item) => Math.max(item.Download, item.Upload)), 0), [
{ threshold: 400, step: 100 },
{ threshold: Infinity, step: 200 },
{ threshold: 400_000_000, step: 100_000_000 },
{ threshold: Infinity, step: 200_000_000 },
]),
[data],
);
const { midnightTs, xTicks, topDateTicks } = useMemo(() => buildXAxisTicks(data), [data]);
const renderXTick = useMemo(() => makeXTickRenderer(midnightTs), [midnightTs]);
const renderXTick = useMemo(() => makeXTickRenderer(midnightTs, locale), [locale, midnightTs]);
const renderTopDateTick = useMemo(() => makeTopDateTickRenderer(locale), [locale]);
if (data.length === 0) return null;
@@ -203,7 +221,7 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul
withLegend
fillOpacity={0.2}
styles={{ root: { padding: 5, borderRadius: theme.radius[board.itemRadius] } }}
valueFormatter={(val: number) => `${val} Mbps`}
valueFormatter={formatChartBitRate}
xAxisProps={{
type: "number",
domain: ["dataMin", "dataMax"],
@@ -215,10 +233,17 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul
yAxisProps={{
ticks: yConfig.ticks,
domain: yConfig.domain,
tickFormatter: (val: number) => `${val}`,
width: 50,
tickFormatter: formatChartBitRate,
width: 70,
tick: { fontSize: 10 },
}}
referenceLines={xTicks.map((tickTs) => ({
x: tickTs,
yAxisId: "left",
stroke: "var(--mantine-color-dimmed)",
strokeDasharray: "3 3",
strokeOpacity: 0.35,
}))}
tooltipProps={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: (props: any) => {
@@ -230,9 +255,9 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul
if (!active || !payload?.length) return null;
return (
<ChartTooltip
label={formatTooltipDate(label)}
label={formatTooltipDate(label, locale)}
payload={payload}
valueFormatter={(val: number) => `${val} Mbps`}
valueFormatter={formatChartBitRate}
/>
);
},
@@ -251,16 +276,6 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul
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>
);
}
@@ -268,12 +283,13 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul
function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult[]; height: number }) {
const board = useRequiredBoard();
const theme = useMantineTheme();
const t = useScopedI18n("widget.speedtestTracker");
const t = useI18n("widget.speedtestTracker");
const locale = useCurrentIntlLocale();
const data = useMemo(
() =>
[...results]
.sort((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime())
results
.toSorted((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime())
.map((result) =>
result.ping === null
? null
@@ -298,7 +314,7 @@ function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult
);
const { midnightTs, xTicks } = useMemo(() => buildXAxisTicks(data), [data]);
const renderXTick = useMemo(() => makeXTickRenderer(midnightTs), [midnightTs]);
const renderXTick = useMemo(() => makeXTickRenderer(midnightTs, locale), [locale, midnightTs]);
if (data.length === 0) return null;
@@ -330,6 +346,13 @@ function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult
width: 50,
tick: { fontSize: 10 },
}}
referenceLines={xTicks.map((tickTs) => ({
x: tickTs,
yAxisId: "left",
stroke: "var(--mantine-color-dimmed)",
strokeDasharray: "3 3",
strokeOpacity: 0.35,
}))}
tooltipProps={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: (props: any) => {
@@ -341,24 +364,13 @@ function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult
if (!active || !payload?.length) return null;
return (
<ChartTooltip
label={formatTooltipDate(label)}
label={formatTooltipDate(label, locale)}
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>
/>
);
}
@@ -1,9 +1,15 @@
import type { ReactNode } from "react";
import { Text } from "@mantine/core";
export function SectionLabel({ children }: { children: ReactNode }) {
export function SectionLabel({ children, uppercase = false }: { children: ReactNode; uppercase?: boolean }) {
return (
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.05em" }}>
<Text
size="xs"
fw={600}
c="dimmed"
tt={uppercase ? "uppercase" : undefined}
style={{ letterSpacing: uppercase ? "0.05em" : undefined }}
>
{children}
</Text>
);
@@ -1,54 +1,74 @@
"use client";
import { Card, Flex, Text } from "@mantine/core";
import { Card, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
export interface SpeedStatCardProps {
icon: TablerIcon;
color: string;
color: "blue" | "teal" | "orange" | "green" | "red";
value: string;
label: string;
compact?: boolean;
legacySurface?: boolean;
}
export function SpeedStatCard({ icon: Icon, color, value, label, compact = false }: SpeedStatCardProps) {
export function SpeedStatCard({
icon: Icon,
color,
value,
label,
compact = false,
legacySurface = false,
}: SpeedStatCardProps) {
const { ref, height, width } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38;
const surfaceColor = `var(--mantine-color-${color}-filled)`;
const accentColor = `var(--mantine-color-${color}-5)`;
const surfaceAlpha = compact ? 0.1 : 0.14;
const modernBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`;
const surfaceBackground = legacySurface ? `var(--mantine-color-${color}-light)` : modernBackground;
const surfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
return (
<Card
ref={ref}
p={compact ? "xs" : "sm"}
radius={board.itemRadius}
bg={`var(--mantine-color-${color}-light)`}
h="100%"
style={{ flex: 1 }}
>
<Flex
<Tooltip label={`${label}: ${value}`} withArrow>
<Card
ref={ref}
p={compact ? "xs" : "sm"}
radius={board.itemRadius}
withBorder={!legacySurface}
bg={surfaceBackground}
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
style={{ flex: 1, minWidth: 0, borderColor: surfaceBorder }}
>
<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}
<Flex
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
style={{ minWidth: 0 }}
>
<Icon color={accentColor} style={zoomCompensatedSize(compact ? 16 : 20)} />
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0} style={{ minWidth: 0 }}>
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1} truncate w="100%">
{value}
</Text>
)}
{!hideLabel && (
<Text size="xs" c="dimmed" ta="center" lh={1.3} truncate w="100%">
{label}
</Text>
)}
{hideLabel && <VisuallyHidden>{label}</VisuallyHidden>}
</Flex>
</Flex>
</Flex>
</Card>
</Card>
</Tooltip>
);
}