feat: expand advanced widget experiences
This commit is contained in:
1 parent
9437d211c8
commit
c099a8eef6
119 files changed
+3624
-844
No files matched your search
@@ -1,19 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { AveragesSection } from "./averages";
|
||||
import { combineSpeedtestDashboards, getCompactSections } from "./helpers";
|
||||
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers";
|
||||
import { LatestResultSection } from "./latest-result";
|
||||
import { RecentResultsSection } from "./recent-results";
|
||||
|
||||
const emptyDashboardData = [] as const;
|
||||
|
||||
export default function SpeedtestTrackerWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
@@ -22,33 +26,36 @@ export default function SpeedtestTrackerWidget({
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"speedtestTracker">) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
const dashboardData =
|
||||
getUsableWidgetQueryData(clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds })) ?? [];
|
||||
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
|
||||
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
|
||||
const [advancedRange, setAdvancedRange] = useState("24");
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]);
|
||||
|
||||
const combined = useMemo(
|
||||
() => combineSpeedtestDashboards(dashboardData.map((item) => item.dashboard)),
|
||||
[dashboardData],
|
||||
() => combineSpeedtestDashboards(successfulDashboards.map(({ dashboard }) => dashboard)),
|
||||
[successfulDashboards],
|
||||
);
|
||||
|
||||
const rangeHours = displayMode === "advanced" ? Number(advancedRange) : 12;
|
||||
const rangeHours = isAdvanced ? 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 showLatestResult = isAdvanced || options.showLatestResult;
|
||||
const showStats = isAdvanced || options.showStats;
|
||||
const showRecentResults = isAdvanced || options.showRecentResults;
|
||||
const showPingGraph = isAdvanced || options.showPingGraph;
|
||||
const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null);
|
||||
const hasChart = showRecentResults && recentFiltered.length > 0;
|
||||
const noSectionsEnabled =
|
||||
!isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults;
|
||||
const advancedSectionWidth = width >= 720 ? width / 2 : width;
|
||||
|
||||
const latest = options.showLatestResult && combined.latestResult && (
|
||||
<LatestResultSection
|
||||
result={combined.latestResult}
|
||||
width={displayMode === "advanced" ? advancedSectionWidth : width}
|
||||
/>
|
||||
const latest = showLatestResult && combined.latestResult && (
|
||||
<LatestResultSection result={combined.latestResult} width={isAdvanced ? advancedSectionWidth : width} />
|
||||
);
|
||||
const averages = options.showStats && combined.stats && (
|
||||
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? advancedSectionWidth : width} />
|
||||
const averages = showStats && combined.stats && (
|
||||
<AveragesSection stats={combined.stats} width={isAdvanced ? advancedSectionWidth : width} />
|
||||
);
|
||||
const compactSections = getCompactSections(height, {
|
||||
latest: Boolean(latest),
|
||||
@@ -57,27 +64,90 @@ export default function SpeedtestTrackerWidget({
|
||||
});
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
|
||||
const sourcePanelWidth = sourceColumns === 2 ? Math.max(0, (width - 48) / 2) : Math.max(0, width - 32);
|
||||
const sourceSectionWidth = sourcePanelWidth >= 720 ? sourcePanelWidth / 2 : sourcePanelWidth;
|
||||
|
||||
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>
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="md" p="md">
|
||||
<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.lastThirty") },
|
||||
]}
|
||||
/>
|
||||
{successfulDashboards.length === 0 ? (
|
||||
<WidgetEmptyState />
|
||||
) : (
|
||||
<SimpleGrid cols={sourceColumns} spacing="md">
|
||||
{successfulDashboards.map(({ integrationId, integrationName, dashboard }) => {
|
||||
const sourceRecent = dashboard.recentResults.filter((result) => result.created_at.getTime() > cutoff);
|
||||
const sourceHasStats = dashboard.latestResult !== null || dashboard.stats !== null;
|
||||
const sourceHasChart = sourceRecent.length > 0;
|
||||
|
||||
return (
|
||||
<Card key={integrationId} data-integration-id={integrationId} p="sm" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{integrationName}
|
||||
</Text>
|
||||
{sourceHasStats && (
|
||||
<SimpleGrid cols={sourcePanelWidth >= 720 ? 2 : 1} spacing="md">
|
||||
{dashboard.latestResult && (
|
||||
<LatestResultSection result={dashboard.latestResult} width={sourceSectionWidth} />
|
||||
)}
|
||||
{dashboard.stats && <AveragesSection stats={dashboard.stats} width={sourceSectionWidth} />}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{sourceHasChart && (
|
||||
<Box h={Math.max(260, Math.min(420, height / 2))}>
|
||||
<RecentResultsSection results={sourceRecent} showPingGraph />
|
||||
</Box>
|
||||
)}
|
||||
{!sourceHasStats && !sourceHasChart && <WidgetEmptyState />}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</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>
|
||||
)}
|
||||
{hasChart && (
|
||||
<div style={{ flex: 1, minHeight: Math.max(220, height / 2) }}>
|
||||
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
|
||||
{(compactSections.latest || compactSections.averages) && (
|
||||
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
|
||||
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
|
||||
</Stack>
|
||||
)}
|
||||
{compactSections.chart && (
|
||||
<div style={{ flex: 2, minHeight: 0 }}>
|
||||
<RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} />
|
||||
</div>
|
||||
)}
|
||||
{noSectionsEnabled && (
|
||||
@@ -87,28 +157,6 @@ export default function SpeedtestTrackerWidget({
|
||||
)}
|
||||
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
|
||||
{(compactSections.latest || compactSections.averages) && (
|
||||
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
|
||||
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
|
||||
</Stack>
|
||||
)}
|
||||
{compactSections.chart && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types";
|
||||
|
||||
import { combineSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers";
|
||||
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers";
|
||||
|
||||
const stats = (average: number, totalResults: number): SpeedtestTrackerStats => ({
|
||||
ping: { avg: average, min: average, max: average },
|
||||
@@ -27,6 +27,23 @@ describe("speedtest dashboard aggregation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
|
||||
@@ -5,6 +5,23 @@ import type {
|
||||
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"],
|
||||
|
||||
Reference in new issue
Block a user