-
+ {(compactSections.latest || compactSections.averages) && (
+
+ {compactSections.latest && {latest}
}
+ {compactSections.averages && {averages}
}
+
+ )}
+ {compactSections.chart && (
+
+
)}
{noSectionsEnabled && (
@@ -87,28 +157,6 @@ export default function SpeedtestTrackerWidget({
)}
{!noSectionsEnabled && !hasStatSection && !hasChart &&
}
- );
- }
-
- return (
-
- {(compactSections.latest || compactSections.averages) && (
-
- {compactSections.latest && {latest}
}
- {compactSections.averages && {averages}
}
-
- )}
- {compactSections.chart && (
-
-
-
- )}
- {noSectionsEnabled && (
-
- {t("noSectionsEnabled")}
-
- )}
- {!noSectionsEnabled && !hasStatSection && !hasChart && }
-
+
);
}
diff --git a/packages/widgets/src/speedtest-tracker/helpers.spec.ts b/packages/widgets/src/speedtest-tracker/helpers.spec.ts
index 1e02bf183..7d7d702ad 100644
--- a/packages/widgets/src/speedtest-tracker/helpers.spec.ts
+++ b/packages/widgets/src/speedtest-tracker/helpers.spec.ts
@@ -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 };
diff --git a/packages/widgets/src/speedtest-tracker/helpers.ts b/packages/widgets/src/speedtest-tracker/helpers.ts
index b590d0214..eb58e91f4 100644
--- a/packages/widgets/src/speedtest-tracker/helpers.ts
+++ b/packages/widgets/src/speedtest-tracker/helpers.ts
@@ -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"],
diff --git a/packages/widgets/src/stocks/component.spec.ts b/packages/widgets/src/stocks/component.spec.ts
index f7a39e0b1..f9fa1c464 100644
--- a/packages/widgets/src/stocks/component.spec.ts
+++ b/packages/widgets/src/stocks/component.spec.ts
@@ -10,11 +10,25 @@ describe("stock summary", () => {
it("does not divide by a zero previous close", () => {
expect(getStockSummary([0, 5], 0)).toMatchObject({
currentPrice: 5,
+ previousClose: 0,
change: 5,
changePercentage: null,
+ minimum: 0,
+ maximum: 5,
graphValues: [50, 55],
});
});
+
+ it("derives every advanced metric from the owned price series", () => {
+ expect(getStockSummary([90, 110, 105], 100)).toMatchObject({
+ currentPrice: 105,
+ previousClose: 100,
+ change: 5,
+ changePercentage: 5,
+ minimum: 90,
+ maximum: 110,
+ });
+ });
});
describe("stock layout", () => {
diff --git a/packages/widgets/src/stocks/component.tsx b/packages/widgets/src/stocks/component.tsx
index a66f216e9..0ed620cb2 100644
--- a/packages/widgets/src/stocks/component.tsx
+++ b/packages/widgets/src/stocks/component.tsx
@@ -1,13 +1,15 @@
"use client";
import { Sparkline } from "@mantine/charts";
-import { Flex, Stack, Text, Title, useMantineTheme } from "@mantine/core";
+import { Badge, Box, Flex, Group, Paper, SimpleGrid, Stack, Text, Title, useMantineTheme } from "@mantine/core";
import { IconMinus, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
+import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
+import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
function round(value: number) {
@@ -25,8 +27,11 @@ function calculateChangePercentage(currentPrice: number, previousClose: number)
export interface StockSummary {
currentPrice: number;
+ previousClose: number;
change: number;
changePercentage: number | null;
+ minimum: number;
+ maximum: number;
graphValues: number[];
}
@@ -55,20 +60,29 @@ export function getStockSummary(priceHistory: number[], previousClose: number):
const minimum = Math.min(...priceHistory);
return {
currentPrice,
+ previousClose,
change: round(calculateChange(currentPrice, previousClose)),
changePercentage: calculateChangePercentage(currentPrice, previousClose),
+ minimum,
+ maximum: Math.max(...priceHistory),
graphValues: priceHistory.map((value) => value - minimum + 50),
};
}
-export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) {
+export default function StockPriceWidget({
+ options,
+ width,
+ height,
+ displayMode = "compact",
+}: WidgetComponentProps<"stockPrice">) {
const t = useScopedI18n("widget.stockPrice");
const locale = useCurrentIntlLocale();
const numberFormatter = new Intl.NumberFormat(locale);
const theme = useMantineTheme();
- const { data: result, error } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
+ const stockQuery = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
+ const result = getUsableWidgetQueryData(stockQuery);
- if (error && !result) throw error;
+ if (isInitialWidgetQueryPending(stockQuery)) return
;
if (!result) return
;
const { data } = result;
@@ -79,57 +93,126 @@ export default function StockPriceWidget({ options, width, height }: WidgetCompo
const stockGraphValues = summary.graphValues;
const trendColor = stockValuesChange > 0 ? "green.7" : stockValuesChange < 0 ? "red.7" : "gray.6";
const layout = getStockLayout(width, height);
+ const formatValue = (value: number) => numberFormatter.format(round(value));
+ const formatSignedValue = (value: number) => `${value > 0 ? "+" : ""}${formatValue(value)}`;
- return (
-
-
+ const content =
+ displayMode === "advanced" ? (
+
+
+
+ {data.symbol}
+ {data.shortName}
+
+
+
+ {t("option.timeRange.label")}: {t(`option.timeRange.option.${options.timeRange}.label`)}
+
+
+ {t("option.timeInterval.label")}: {t(`option.timeInterval.option.${options.timeInterval}.label`)}
+
+
+
-
-
- {stockValuesChange > 0 ? (
-
- ) : stockValuesChange < 0 ? (
-
- ) : (
-
+ = 840 ? 6 : width >= 480 ? 3 : 2} spacing="xs">
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ {stockValuesChange > 0 ? (
+
+ ) : stockValuesChange < 0 ? (
+
+ ) : (
+
+ )}
+ {data.symbol}
+
+ {layout.showName && (
+
+ {data.shortName}
+
)}
- {data.symbol}
-
- {layout.showName && (
-
- {data.shortName}
+
+
+
+ {formatValue(summary.currentPrice)}
+
+
+ {layout.showChange && (
+
+ {numberFormatter.format(stockValuesChange)}
+ {stockValuesChangePercentage === null
+ ? null
+ : ` (${stockValuesChange > 0 ? "+" : ""}${numberFormatter.format(stockValuesChangePercentage)}%)`}
)}
-
-
- {numberFormatter.format(round(summary.currentPrice))}
-
+ {layout.showRange && (
+
+ {t(`option.timeRange.option.${options.timeRange}.label`)}
+
+ )}
+
+ );
- {layout.showChange && (
-
- {numberFormatter.format(stockValuesChange)}
- {stockValuesChangePercentage === null
- ? null
- : ` (${stockValuesChange > 0 ? "+" : ""}${numberFormatter.format(stockValuesChangePercentage)}%)`}
-
+ return (
+
+ {content}
+ {stockQuery.error && (
+
+
+
)}
-
- {layout.showRange && (
-
- {t(`option.timeRange.option.${options.timeRange}.label`)}
-
- )}
-
+
+ );
+}
+
+function StockMetric({ label, value, color }: { label: string; value: string; color?: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
);
}
diff --git a/packages/widgets/src/stocks/index.ts b/packages/widgets/src/stocks/index.ts
index 2b6e637e6..ea6398180 100644
--- a/packages/widgets/src/stocks/index.ts
+++ b/packages/widgets/src/stocks/index.ts
@@ -12,7 +12,7 @@ const timeRangeOptions = stockPriceTimeFrames.range;
const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
- supportsAdvancedFocus: false,
+ supportsAdvancedFocus: true,
icon: IconBuildingBank,
queryKey: [["widget", "stockPrice", "getPriceHistory"]],
queryMatcher: ({ input }, scope) =>
diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx
index 80e0dd1e8..f9b8cac37 100644
--- a/packages/widgets/src/system-disks/component.tsx
+++ b/packages/widgets/src/system-disks/component.tsx
@@ -9,16 +9,21 @@ import { formatBytesPair } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
-import { getUsableWidgetQueryData } from "../common/query-state";
+import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
+import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
+import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
-type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
+type DiskDisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
+
+export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage));
export const getDisplayText = (
item: { used: string; available: string; percentage: number },
- displayMode: DisplayMode,
+ displayMode: DiskDisplayMode,
+ translatedFreeText?: string,
) => {
switch (displayMode) {
case "percentage":
@@ -27,27 +32,26 @@ export const getDisplayText = (
const usedInBytes = Number(item.used);
const availableInBytes = Number(item.available);
if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) {
- const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
- return `${used} / ${available}`;
+ const { used, available: total } = formatBytesPair(usedInBytes, usedInBytes + availableInBytes);
+ return `${used} / ${total}`;
}
return `${item.used} / ${item.available}`;
}
case "free":
- return `${Math.round(100 - item.percentage)}% free`;
+ return translatedFreeText ?? `${Math.round(100 - clampPercentage(item.percentage))}% free`;
default:
return `${Math.round(item.percentage)}%`;
}
};
-export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage));
-
-const getAbsoluteText = (item: { used: string; available: string }) => {
- const usedInBytes = Number(item.used);
- const availableInBytes = Number(item.available);
- if (!Number.isFinite(usedInBytes) || !Number.isFinite(availableInBytes)) return `${item.used} / ${item.available}`;
- const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
- return `${used} / ${available}`;
-};
+export const getAdvancedDisplayTexts = (
+ item: { used: string; available: string; percentage: number },
+ translatedFreeText?: string,
+) => ({
+ percentage: getDisplayText(item, "percentage"),
+ absolute: getDisplayText(item, "absolute"),
+ free: getDisplayText(item, "free", translatedFreeText),
+});
interface SystemDiskCardProps {
deviceName: string;
@@ -58,6 +62,9 @@ interface SystemDiskCardProps {
showBackgroundBar: boolean;
integrationName?: string;
secondaryText?: string;
+ freeText?: string;
+ smartStatus?: string;
+ isAdvanced: boolean;
showSecondaryText: boolean;
showTemperature: boolean;
}
@@ -71,6 +78,9 @@ const SystemDiskCard = ({
showBackgroundBar,
integrationName,
secondaryText,
+ freeText,
+ smartStatus,
+ isAdvanced,
showSecondaryText,
showTemperature,
}: SystemDiskCardProps) => {
@@ -98,7 +108,9 @@ const SystemDiskCard = ({
}, [displayText, healthy]);
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
- const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature;
+ const hasTemperature = temperature !== null && temperature !== undefined;
+ const temperatureText = hasTemperature ? `${temperature}°C` : "—°C";
+ const hasHiddenTemperature = hasTemperature && !showTemperature;
const hasHiddenSecondaryText = Boolean(secondaryText && secondaryText !== displayText && !showSecondaryText);
const tooltipLabel = [
healthy ? displayText : `${displayText} (${unhealthyLabel})`,
@@ -140,11 +152,19 @@ const SystemDiskCard = ({
{secondaryText}
)}
+ {isAdvanced && freeText && freeText !== displayText && (
+
+ {freeText}
+
+ )}
+ {isAdvanced && (
+
+ {t("widget.systemDisks.status.smart")}: {smartStatus?.trim() || "—"}
+
+ )}
- {showTemperature && temperature !== null && temperature !== undefined ? (
- {temperature}°C
- ) : null}
+ {showTemperature && (hasTemperature || isAdvanced) ? {temperatureText} : null}