feat: modernize widget experience
This commit is contained in:
1 parent
6331c88911
commit
1fa345edd1
165 files changed
+3698
-1649
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 { getNetworkControllerStatusLayout } from "./layout";
|
||||
import { WifiVariant } from "./variants/wifi-variant";
|
||||
import { WiredVariant } from "./variants/wired-variant";
|
||||
|
||||
@@ -36,12 +37,11 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => summary), [summaries]);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
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);
|
||||
const countLanUsers = data.reduce((sum, summary) => sum + summary.lan.users, 0);
|
||||
const useHorizontalStats = !isAdvanced && height < 150 && width >= 200;
|
||||
const layout = getNetworkControllerStatusLayout({ width, height, displayMode, content: options.content });
|
||||
|
||||
if (error && summaries === undefined) throw error;
|
||||
|
||||
@@ -56,25 +56,25 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={isAdvanced ? "md" : height < 120 ? "xs" : "sm"} h="100%">
|
||||
<SimpleGrid cols={isAdvanced && width >= 560 ? 2 : 1} h="100%" spacing="sm">
|
||||
{(isAdvanced || options.content === "wifi") && (
|
||||
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
|
||||
<Box p={layout.padding} h="100%">
|
||||
<SimpleGrid cols={layout.columns} h="100%" spacing="sm">
|
||||
{layout.showWifi && (
|
||||
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
|
||||
<WifiVariant
|
||||
countGuests={countWifiGuests}
|
||||
countUsers={countWifiUsers}
|
||||
compact={!isAdvanced}
|
||||
horizontal={useHorizontalStats}
|
||||
compact={layout.compact}
|
||||
horizontal={layout.horizontalStats}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{(isAdvanced || options.content === "wired") && (
|
||||
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
|
||||
{layout.showWired && (
|
||||
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
|
||||
<WiredVariant
|
||||
countGuests={countLanGuests}
|
||||
countUsers={countLanUsers}
|
||||
compact={!isAdvanced}
|
||||
horizontal={useHorizontalStats}
|
||||
compact={layout.compact}
|
||||
horizontal={layout.horizontalStats}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getNetworkControllerStatusLayout } from "./layout";
|
||||
|
||||
describe("network controller status layout", () => {
|
||||
test.each([
|
||||
{
|
||||
name: "minimum compact",
|
||||
input: { width: 160, height: 90, displayMode: "compact" as const, content: "wifi" as const },
|
||||
expected: { padding: "xs", columns: 1, showWifi: true, showWired: false, horizontalStats: false },
|
||||
},
|
||||
{
|
||||
name: "short compact",
|
||||
input: { width: 200, height: 149, displayMode: "compact" as const, content: "wired" as const },
|
||||
expected: { padding: "sm", columns: 1, showWifi: false, showWired: true, horizontalStats: true },
|
||||
},
|
||||
{
|
||||
name: "typical compact",
|
||||
input: { width: 320, height: 220, displayMode: "compact" as const, content: "wifi" as const },
|
||||
expected: { padding: "sm", columns: 1, showWifi: true, showWired: false, horizontalStats: false },
|
||||
},
|
||||
{
|
||||
name: "advanced below wide boundary",
|
||||
input: { width: 559, height: 320, displayMode: "advanced" as const, content: "wifi" as const },
|
||||
expected: { padding: "md", columns: 1, showWifi: true, showWired: true, horizontalStats: false },
|
||||
},
|
||||
{
|
||||
name: "wide advanced",
|
||||
input: { width: 560, height: 320, displayMode: "advanced" as const, content: "wired" as const },
|
||||
expected: { padding: "md", columns: 2, showWifi: true, showWired: true, horizontalStats: false },
|
||||
},
|
||||
{
|
||||
name: "advanced at 200 percent zoom",
|
||||
input: { width: 280, height: 320, displayMode: "advanced" as const, content: "wired" as const },
|
||||
expected: { padding: "md", columns: 1, showWifi: true, showWired: true, horizontalStats: false },
|
||||
},
|
||||
])("selects the $name tier", ({ input, expected }) => {
|
||||
expect(getNetworkControllerStatusLayout(input)).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("keeps breakpoint boundaries explicit", () => {
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 200, height: 150, displayMode: "compact", content: "wifi" }),
|
||||
).toMatchObject({ padding: "sm", horizontalStats: false });
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 199, height: 119, displayMode: "compact", content: "wifi" }),
|
||||
).toMatchObject({ padding: "xs", horizontalStats: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
|
||||
type NetworkStatusContent = WidgetComponentProps<"networkControllerStatus">["options"]["content"];
|
||||
type NetworkStatusDisplayMode = WidgetComponentProps<"networkControllerStatus">["displayMode"];
|
||||
|
||||
interface NetworkControllerStatusLayoutInput {
|
||||
width: number;
|
||||
height: number;
|
||||
displayMode: NetworkStatusDisplayMode;
|
||||
content: NetworkStatusContent;
|
||||
}
|
||||
|
||||
export interface NetworkControllerStatusLayout {
|
||||
padding: "xs" | "sm" | "md";
|
||||
columns: 1 | 2;
|
||||
showWifi: boolean;
|
||||
showWired: boolean;
|
||||
cardPadding: 0 | "md";
|
||||
withBorder: boolean;
|
||||
compact: boolean;
|
||||
horizontalStats: boolean;
|
||||
}
|
||||
|
||||
export const getNetworkControllerStatusLayout = ({
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
content,
|
||||
}: NetworkControllerStatusLayoutInput): NetworkControllerStatusLayout => {
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
return {
|
||||
padding: isAdvanced ? "md" : height < 120 ? "xs" : "sm",
|
||||
columns: isAdvanced && width >= 560 ? 2 : 1,
|
||||
showWifi: isAdvanced || content === "wifi",
|
||||
showWired: isAdvanced || content === "wired",
|
||||
cardPadding: isAdvanced ? "md" : 0,
|
||||
withBorder: isAdvanced,
|
||||
compact: !isAdvanced,
|
||||
horizontalStats: !isAdvanced && height < 150 && width >= 200,
|
||||
};
|
||||
};
|
||||
@@ -11,6 +11,8 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { getBinaryStatusKey } from "../../common/locale";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
|
||||
dayjs.extend(objectSupport);
|
||||
dayjs.extend(relativeTime);
|
||||
@@ -20,17 +22,23 @@ export default function NetworkControllerSummaryWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"networkControllerSummary">) {
|
||||
const { data: summaries = [], isPending } = clientApi.widget.networkController.summary.useQuery({
|
||||
const summaryQuery = clientApi.widget.networkController.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const { isPending } = summaryQuery;
|
||||
|
||||
const t = useI18n();
|
||||
const statusLabels = {
|
||||
enabled: t("widget.dnsHoleSummary.status.enabled"),
|
||||
disabled: t("widget.dnsHoleSummary.status.disabled"),
|
||||
unknown: t("widget.dnsHoleSummary.status.unknown"),
|
||||
};
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = !isAdvanced && height < 160;
|
||||
const columns = isAdvanced && width >= 720 ? 2 : 1;
|
||||
const isDense = height < 160;
|
||||
const showDetails = height >= 180 && width >= 240;
|
||||
const columns = width >= 720 ? 2 : 1;
|
||||
|
||||
if (isPending || summaries.length === 0) {
|
||||
return (
|
||||
@@ -43,12 +51,12 @@ export default function NetworkControllerSummaryWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" p={isAdvanced ? "md" : isDense ? "xs" : "sm"}>
|
||||
<ScrollArea h="100%" p={isDense ? "xs" : "sm"}>
|
||||
<SimpleGrid cols={columns} spacing="sm">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Card key={integration.id} p={isAdvanced ? "md" : "xs"} withBorder={summaries.length > 1 || isAdvanced}>
|
||||
<Card key={integration.id} p={isDense ? "xs" : "sm"} withBorder={summaries.length > 1}>
|
||||
<Stack gap="xs">
|
||||
{(isAdvanced || summaries.length > 1) && (
|
||||
{summaries.length > 1 && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate="end">
|
||||
{integration.name}
|
||||
@@ -59,24 +67,66 @@ export default function NetworkControllerSummaryWidget({
|
||||
</Group>
|
||||
)}
|
||||
<List spacing={isDense ? 2 : "xs"} center size={isDense ? "sm" : undefined}>
|
||||
<List.Item icon={<StatusIcon status={summary.wanStatus} size={isDense ? 16 : 20} />}>WAN</List.Item>
|
||||
<List.Item icon={<StatusIcon status={summary.www.status} size={isDense ? 16 : 20} />}>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.wanStatus}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
|
||||
size={isDense ? 16 : 20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
WAN
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.www.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.www.status)]}
|
||||
size={isDense ? 16 : 20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
WWW
|
||||
<Text c="dimmed" size="sm" ms="xs" span>
|
||||
{summary.www.latency}ms{isAdvanced ? ` · ${summary.www.ping}ms` : ""}
|
||||
{summary.www.latency}ms{showDetails ? ` · ${summary.www.ping}ms` : ""}
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item icon={<StatusIcon status={summary.wifi.status} size={isDense ? 16 : 20} />}>
|
||||
Wi-Fi{isAdvanced ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.wifi.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
|
||||
size={isDense ? 16 : 20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Wi-Fi{showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
|
||||
</List.Item>
|
||||
{isAdvanced && (
|
||||
<List.Item icon={<StatusIcon status={summary.lan.status} size={20} />}>
|
||||
{showDetails && (
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.lan.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
LAN · {summary.lan.users + summary.lan.guests}
|
||||
</List.Item>
|
||||
)}
|
||||
<List.Item icon={<StatusIcon status={summary.vpn.status} size={isDense ? 16 : 20} />}>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.vpn.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.vpn.status)]}
|
||||
size={isDense ? 16 : 20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
VPN
|
||||
<Text c="dimmed" size="sm" ms="xs" span>
|
||||
@@ -93,13 +143,13 @@ export default function NetworkControllerSummaryWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const StatusIcon = ({ status, size }: { status?: "enabled" | "disabled"; size: number }) => {
|
||||
const StatusIcon = ({ status, label, size }: { status?: "enabled" | "disabled"; label: string; size: number }) => {
|
||||
const mantineTheme = useMantineTheme();
|
||||
if (status === "enabled") {
|
||||
return <IconCircleCheckFilled aria-label={status} size={size} color={mantineTheme.colors.green[6]} />;
|
||||
return <IconCircleCheckFilled aria-label={label} size={size} color={mantineTheme.colors.green[6]} />;
|
||||
}
|
||||
if (status === "disabled") {
|
||||
return <IconCircleXFilled aria-label={status} size={size} color={mantineTheme.colors.red[6]} />;
|
||||
return <IconCircleXFilled aria-label={label} size={size} color={mantineTheme.colors.red[6]} />;
|
||||
}
|
||||
return <IconCircleXFilled aria-label="unknown" size={size} color={mantineTheme.colors.gray[6]} />;
|
||||
return <IconCircleXFilled aria-label={label} size={size} color={mantineTheme.colors.gray[6]} />;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconTopologyFull,
|
||||
queryKey: [["widget", "networkController"]],
|
||||
refetchInterval: null,
|
||||
|
||||
Reference in new issue
Block a user