feat: modernize dashboard widgets
This commit is contained in:
1 parent
1a5e083fd0
commit
6f2cd7fc2b
270 files changed
+10189
-3544
No files matched your search
@@ -10,7 +10,7 @@ import { formatStatsSpeed } from "./helpers";
|
||||
import { SectionLabel } from "./section-label";
|
||||
import { SpeedStatCard } from "./speed-stat-card";
|
||||
|
||||
export function AveragesSection({ stats }: { stats: SpeedtestTrackerStats }) {
|
||||
export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTrackerStats; width?: number }) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
|
||||
return (
|
||||
@@ -18,7 +18,7 @@ export function AveragesSection({ stats }: { stats: SpeedtestTrackerStats }) {
|
||||
<SectionLabel>
|
||||
{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"
|
||||
|
||||
@@ -1,58 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import { SegmentedControl, SimpleGrid, 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 { WidgetEmptyState } from "../common/empty-state";
|
||||
import { AveragesSection } from "./averages";
|
||||
import { mergeStats } from "./helpers";
|
||||
import { combineSpeedtestDashboards } from "./helpers";
|
||||
import { LatestResultSection } from "./latest-result";
|
||||
import { RecentResultsSection } from "./recent-results";
|
||||
|
||||
export default function SpeedtestTrackerWidget({ options, integrationIds }: WidgetComponentProps<"speedtestTracker">) {
|
||||
export default function SpeedtestTrackerWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"speedtestTracker">) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
const { data: dashboardData = [] } = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
|
||||
const [advancedRange, setAdvancedRange] = useState("24");
|
||||
|
||||
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: [] },
|
||||
),
|
||||
() => combineSpeedtestDashboards(dashboardData.map((item) => item.dashboard)),
|
||||
[dashboardData],
|
||||
);
|
||||
|
||||
const twelveHoursAgo = Date.now() - 12 * 60 * 60 * 1000;
|
||||
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > twelveHoursAgo);
|
||||
const rangeHours = displayMode === "advanced" ? Number(advancedRange) : 12;
|
||||
const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0;
|
||||
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 noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
|
||||
|
||||
const latest = options.showLatestResult && combined.latestResult && (
|
||||
<LatestResultSection result={combined.latestResult} width={displayMode === "advanced" ? width / 2 : width} />
|
||||
);
|
||||
const averages = options.showStats && combined.stats && (
|
||||
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? width / 2 : width} />
|
||||
);
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
return (
|
||||
<Stack h="100%" gap="md" p="md" style={{ overflow: "hidden" }}>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={advancedRange}
|
||||
onChange={setAdvancedRange}
|
||||
data={[
|
||||
{ value: "12", label: t("range.twelveHours") },
|
||||
{ value: "24", label: t("range.oneDay") },
|
||||
{ value: "0", label: t("range.all") },
|
||||
]}
|
||||
/>
|
||||
{hasStatSection && (
|
||||
<SimpleGrid cols={width >= 720 ? 2 : 1} spacing="md">
|
||||
{latest}
|
||||
{averages}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{hasChart && (
|
||||
<div style={{ flex: 1, minHeight: Math.max(220, height / 2) }}>
|
||||
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
{latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
|
||||
{averages && height >= 170 && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
|
||||
</Stack>
|
||||
)}
|
||||
{hasChart && (
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types";
|
||||
|
||||
import { combineSpeedtestDashboards, 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);
|
||||
});
|
||||
});
|
||||
@@ -11,34 +11,58 @@ export const mergeStats = (
|
||||
): 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 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 formatBitsPerSec = (bps: number): string => `${formatNumber(bps, 2)}bps`;
|
||||
|
||||
export const formatResultSpeed = (result: SpeedtestTrackerResult, dir: "download" | "upload"): string => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
|
||||
icon: IconSpeedboat,
|
||||
queryKey: [["widget", "speedtestTracker"]],
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -10,7 +10,7 @@ import { formatResultSpeed } from "./helpers";
|
||||
import { SectionLabel } from "./section-label";
|
||||
import { SpeedStatCard } from "./speed-stat-card";
|
||||
|
||||
export function LatestResultSection({ result }: { result: SpeedtestTrackerResult }) {
|
||||
export function LatestResultSection({ result, width = 500 }: { result: SpeedtestTrackerResult; width?: number }) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
const locale = useCurrentLocale();
|
||||
const timestamp = Intl.DateTimeFormat(locale, {
|
||||
@@ -19,7 +19,8 @@ export function LatestResultSection({ result }: { result: SpeedtestTrackerResult
|
||||
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%">
|
||||
|
||||
Reference in new issue
Block a user