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
@@ -11,6 +11,7 @@ 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 { getNetworkControllerStatusLayout } from "./layout";
|
||||
import { WifiVariant } from "./variants/wifi-variant";
|
||||
import { WiredVariant } from "./variants/wired-variant";
|
||||
@@ -36,7 +37,7 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
const t = useScopedI18n("widget.networkControllerStatus");
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => summary), [summaries]);
|
||||
const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => (summary ? [summary] : [])), [summaries]);
|
||||
const countWifiGuests = data.reduce((sum, summary) => sum + summary.wifi.guests, 0);
|
||||
const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0);
|
||||
const countLanGuests = data.reduce((sum, summary) => sum + summary.lan.guests, 0);
|
||||
@@ -56,7 +57,10 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={layout.padding} h="100%">
|
||||
<Box p={layout.padding} h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={summaries ?? []} />
|
||||
</Box>
|
||||
<SimpleGrid cols={layout.columns} h="100%" spacing="sm">
|
||||
{layout.showWifi && (
|
||||
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
|
||||
|
||||
@@ -8,11 +8,16 @@ import objectSupport from "dayjs/plugin/objectSupport";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import { getBinaryStatusKey } from "../../common/locale";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
||||
import type { NetworkControllerMatrixMetricKey, NetworkControllerMatrixSectionKey } from "./display";
|
||||
import { getNetworkControllerMatrix } from "./display";
|
||||
|
||||
dayjs.extend(objectSupport);
|
||||
dayjs.extend(relativeTime);
|
||||
@@ -22,14 +27,20 @@ export default function NetworkControllerSummaryWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"networkControllerSummary">) {
|
||||
const summaryQuery = clientApi.widget.networkController.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const results = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const summaries = results.filter(
|
||||
(result): result is typeof result & { summary: NonNullable<typeof result.summary>; updatedAt: Date } =>
|
||||
result.summary !== null && result.updatedAt !== undefined,
|
||||
);
|
||||
const { isPending } = summaryQuery;
|
||||
|
||||
const t = useI18n();
|
||||
const locale = useCurrentIntlLocale();
|
||||
const statusLabels = {
|
||||
enabled: t("widget.dnsHoleSummary.status.enabled"),
|
||||
disabled: t("widget.dnsHoleSummary.status.disabled"),
|
||||
@@ -39,19 +50,106 @@ export default function NetworkControllerSummaryWidget({
|
||||
const isDense = height < 160;
|
||||
const showDetails = height >= 180 && width >= 240;
|
||||
const columns = width >= 720 ? 2 : 1;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const queryIndicators = (
|
||||
<Group gap={0} justify="flex-end">
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("widget.networkControllerSummary.name")} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (isPending || summaries.length === 0) {
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{isPending ? t("common.action.loading") : t("widget.networkControllerSummary.error.integrationsDisconnected")}
|
||||
</Text>
|
||||
</Center>
|
||||
<Stack h="100%" gap={0}>
|
||||
{queryIndicators}
|
||||
<Center p="sm" style={{ flex: 1 }}>
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{isPending
|
||||
? t("common.action.loading")
|
||||
: t("widget.networkControllerSummary.error.integrationsDisconnected")}
|
||||
</Text>
|
||||
</Center>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdvanced) {
|
||||
const controllerColumns = width >= 960 ? 2 : 1;
|
||||
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" p="sm" pos="relative">
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={controllerColumns} spacing="sm">
|
||||
{summaries.map(({ integration, summary, updatedAt }) => (
|
||||
<Card key={integration.id} p="sm" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate="end">
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{integration.kind}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Text size="10px" c="dimmed">
|
||||
{t("widget.networkControllerSummary.advanced.updated")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
{new Date(updatedAt).toLocaleString(locale)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<SimpleGrid cols={matrixColumns} spacing="xs">
|
||||
{getNetworkControllerMatrix(summary).map((section) => (
|
||||
<Card key={section.key} p="xs" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<StatusIcon
|
||||
status={section.status}
|
||||
label={statusLabels[getBinaryStatusKey(section.status)]}
|
||||
size={16}
|
||||
/>
|
||||
<Text size="sm" fw={600}>
|
||||
{getMatrixSectionLabel(section.key, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{statusLabels[getBinaryStatusKey(section.status)]}
|
||||
</Text>
|
||||
</Group>
|
||||
{section.metrics.length > 0 && (
|
||||
<SimpleGrid cols={2} spacing={4} verticalSpacing={2}>
|
||||
{section.metrics.map((metric) => (
|
||||
<Stack key={metric.key} gap={0}>
|
||||
<Text size="10px" c="dimmed">
|
||||
{getMatrixMetricLabel(metric.key, t)}
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{formatMatrixMetric(metric.key, metric.value)}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" p={isDense ? "xs" : "sm"}>
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={columns} spacing="sm">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Card key={integration.id} p={isDense ? "xs" : "sm"} withBorder={summaries.length > 1}>
|
||||
@@ -144,6 +242,20 @@ export default function NetworkControllerSummaryWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const getMatrixMetricLabel = (key: NetworkControllerMatrixMetricKey, t: TranslationFunction) =>
|
||||
t(`widget.networkControllerSummary.advanced.metric.${key}`);
|
||||
|
||||
const formatMatrixMetric = (key: NetworkControllerMatrixMetricKey, value: number) => {
|
||||
if (key === "latency" || key === "ping") return `${value}ms`;
|
||||
if (key === "uptime") return dayjs.duration(value, "seconds").humanize();
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getMatrixSectionLabel = (key: NetworkControllerMatrixSectionKey, t: TranslationFunction) => {
|
||||
if (key === "vpn") return t("widget.networkControllerSummary.card.vpn.label");
|
||||
return t(`widget.networkControllerSummary.card.${key}`);
|
||||
};
|
||||
|
||||
const StatusIcon = ({ status, label, size }: { status?: "enabled" | "disabled"; label: string; size: number }) => {
|
||||
const mantineTheme = useMantineTheme();
|
||||
if (status === "enabled") {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getNetworkControllerMatrix } from "./display";
|
||||
|
||||
describe("Network controller advanced matrix", () => {
|
||||
test("retains every summary field without aggregating users and guests", () => {
|
||||
expect(
|
||||
getNetworkControllerMatrix({
|
||||
wanStatus: "enabled",
|
||||
www: { status: "disabled", latency: 22, ping: 14, uptime: 86400 },
|
||||
wifi: { status: "enabled", users: 12, guests: 2 },
|
||||
lan: { status: "enabled", users: 18, guests: 5 },
|
||||
vpn: { status: "disabled", users: 3 },
|
||||
}),
|
||||
).toEqual([
|
||||
{ key: "wan", status: "enabled", metrics: [] },
|
||||
{
|
||||
key: "web",
|
||||
status: "disabled",
|
||||
metrics: [
|
||||
{ key: "latency", value: 22 },
|
||||
{ key: "ping", value: 14 },
|
||||
{ key: "uptime", value: 86400 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "wifi",
|
||||
status: "enabled",
|
||||
metrics: [
|
||||
{ key: "users", value: 12 },
|
||||
{ key: "guests", value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "lan",
|
||||
status: "enabled",
|
||||
metrics: [
|
||||
{ key: "users", value: 18 },
|
||||
{ key: "guests", value: 5 },
|
||||
],
|
||||
},
|
||||
{ key: "vpn", status: "disabled", metrics: [{ key: "users", value: 3 }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
||||
|
||||
export type NetworkControllerMatrixSectionKey = "wan" | "web" | "wifi" | "lan" | "vpn";
|
||||
export type NetworkControllerMatrixMetricKey = "latency" | "ping" | "uptime" | "users" | "guests";
|
||||
|
||||
export interface NetworkControllerMatrixSection {
|
||||
key: NetworkControllerMatrixSectionKey;
|
||||
status: "enabled" | "disabled";
|
||||
metrics: { key: NetworkControllerMatrixMetricKey; value: number }[];
|
||||
}
|
||||
|
||||
export const getNetworkControllerMatrix = (summary: NetworkControllerSummary): NetworkControllerMatrixSection[] => [
|
||||
{ key: "wan", status: summary.wanStatus, metrics: [] },
|
||||
{
|
||||
key: "web",
|
||||
status: summary.www.status,
|
||||
metrics: [
|
||||
{ key: "latency", value: summary.www.latency },
|
||||
{ key: "ping", value: summary.www.ping },
|
||||
{ key: "uptime", value: summary.www.uptime },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "wifi",
|
||||
status: summary.wifi.status,
|
||||
metrics: [
|
||||
{ key: "users", value: summary.wifi.users },
|
||||
{ key: "guests", value: summary.wifi.guests },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "lan",
|
||||
status: summary.lan.status,
|
||||
metrics: [
|
||||
{ key: "users", value: summary.lan.users },
|
||||
{ key: "guests", value: summary.lan.guests },
|
||||
],
|
||||
},
|
||||
{ key: "vpn", status: summary.vpn.status, metrics: [{ key: "users", value: summary.vpn.users }] },
|
||||
];
|
||||
@@ -6,7 +6,7 @@ import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconTopologyFull,
|
||||
queryKey: [["widget", "networkController"]],
|
||||
refetchInterval: null,
|
||||
|
||||
Reference in new issue
Block a user