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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user