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
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getTraefikProtocolKeys, getTraefikSourceDetails, getVisibleTraefikEntryPoints } from "./component";
|
||||
|
||||
const emptyProtocol = {
|
||||
routers: { total: 0, enabled: 0, warnings: 0, errors: 0 },
|
||||
services: { total: 0, enabled: 0, warnings: 0, errors: 0 },
|
||||
middlewares: { total: 0, enabled: 0, warnings: 0, errors: 0 },
|
||||
};
|
||||
|
||||
describe("Traefik source ownership", () => {
|
||||
test("scopes identical resource and endpoint keys to their integrations", () => {
|
||||
const dashboard = {
|
||||
version: "3.0",
|
||||
entryPoints: [],
|
||||
resources: [
|
||||
{ protocol: "http" as const, type: "router" as const, name: "api", provider: null, status: "enabled" as const },
|
||||
],
|
||||
failedEndpoints: ["/api/http/services"],
|
||||
http: emptyProtocol,
|
||||
tcp: emptyProtocol,
|
||||
udp: { routers: emptyProtocol.routers, services: emptyProtocol.services },
|
||||
};
|
||||
|
||||
const details = getTraefikSourceDetails([
|
||||
{ integrationId: "traefik-a", integrationName: "Traefik A", dashboard },
|
||||
{ integrationId: "traefik-b", integrationName: "Traefik B", dashboard },
|
||||
]);
|
||||
|
||||
expect(details.resources.map(({ key }) => key)).toEqual([
|
||||
"traefik-a:http:router:api:0",
|
||||
"traefik-b:http:router:api:0",
|
||||
]);
|
||||
expect(details.failedEndpoints.map(({ key }) => key)).toEqual([
|
||||
"traefik-a:/api/http/services:0",
|
||||
"traefik-b:/api/http/services:0",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Traefik advanced disclosure", () => {
|
||||
test("shows every protocol regardless of compact toggles and height", () => {
|
||||
expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, 100, true)).toEqual(["http", "tcp", "udp"]);
|
||||
});
|
||||
|
||||
test("shows every unique entry point regardless of compact width limits", () => {
|
||||
const entryPoints = ["web", "websecure", "metrics", "admin", "postgres", "dns", "ssh", "smtp", "web"];
|
||||
|
||||
expect(getVisibleTraefikEntryPoints(entryPoints, 100, true)).toEqual(entryPoints.slice(0, -1));
|
||||
expect(getVisibleTraefikEntryPoints(entryPoints, 100, false)).toEqual(["web", "websecure"]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
@@ -16,8 +16,10 @@ import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSumma
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -50,6 +52,47 @@ const emptyDashboard: TraefikDashboardData = {
|
||||
type ProtocolKey = "http" | "tcp" | "udp";
|
||||
type ResourceKey = "routers" | "services" | "middlewares";
|
||||
|
||||
interface TraefikDashboardSource {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
dashboard: TraefikDashboardData;
|
||||
}
|
||||
|
||||
export const getTraefikSourceDetails = (sources: readonly TraefikDashboardSource[]) => ({
|
||||
resources: sources.flatMap(({ integrationId, integrationName, dashboard }) =>
|
||||
dashboard.resources.map((resource, index) => ({
|
||||
...resource,
|
||||
integrationId,
|
||||
integrationName,
|
||||
key: `${integrationId}:${resource.protocol}:${resource.type}:${resource.name}:${index}`,
|
||||
})),
|
||||
),
|
||||
failedEndpoints: sources.flatMap(({ integrationId, integrationName, dashboard }) =>
|
||||
dashboard.failedEndpoints.map((endpoint, index) => ({
|
||||
endpoint,
|
||||
integrationId,
|
||||
integrationName,
|
||||
key: `${integrationId}:${endpoint}:${index}`,
|
||||
})),
|
||||
),
|
||||
});
|
||||
|
||||
export function getTraefikProtocolKeys(
|
||||
options: Pick<WidgetComponentProps<"traefik">["options"], "showTcp" | "showUdp">,
|
||||
height: number,
|
||||
isAdvanced: boolean,
|
||||
): ProtocolKey[] {
|
||||
const protocols: ProtocolKey[] = ["http"];
|
||||
if (isAdvanced || (options.showTcp && height >= 220)) protocols.push("tcp");
|
||||
if (isAdvanced || (options.showUdp && height >= 300)) protocols.push("udp");
|
||||
return protocols;
|
||||
}
|
||||
|
||||
export function getVisibleTraefikEntryPoints(values: string[], width: number, isAdvanced: boolean): string[] {
|
||||
const entryPoints = dedupe(values);
|
||||
return isAdvanced ? entryPoints : entryPoints.slice(0, getEntryPointLimit(width));
|
||||
}
|
||||
|
||||
export default function TraefikWidget(props: WidgetComponentProps<"traefik">) {
|
||||
if (props.integrationIds.length === 0) {
|
||||
throw new NoIntegrationDataError();
|
||||
@@ -66,11 +109,15 @@ function TraefikWidgetContent({
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"traefik">) {
|
||||
const t = useScopedI18n("widget.traefik");
|
||||
const data = getUsableWidgetQueryData(clientApi.widget.traefik.getDashboard.useQuery({ integrationIds }));
|
||||
const dashboardQuery = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds });
|
||||
const data = getUsableWidgetQueryData(dashboardQuery);
|
||||
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
const successfulData = data.flatMap((item) =>
|
||||
item.dashboard === null ? [] : [{ ...item, dashboard: item.dashboard }],
|
||||
);
|
||||
|
||||
const combined = data.reduce<TraefikDashboardData>(
|
||||
const combined = successfulData.reduce<TraefikDashboardData>(
|
||||
(acc, item) => ({
|
||||
version: acc.version ?? item.dashboard.version,
|
||||
entryPoints: [...acc.entryPoints, ...item.dashboard.entryPoints],
|
||||
@@ -86,6 +133,7 @@ function TraefikWidgetContent({
|
||||
emptyDashboard,
|
||||
);
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const totalRouters = combined.http.routers.total + combined.tcp.routers.total + combined.udp.routers.total;
|
||||
const totalErrors =
|
||||
getProtocolErrors(combined.http) +
|
||||
@@ -94,20 +142,38 @@ function TraefikWidgetContent({
|
||||
combined.failedEndpoints.length;
|
||||
const totalWarnings =
|
||||
getProtocolWarnings(combined.http) + getProtocolWarnings(combined.tcp) + getUdpWarnings(combined.udp);
|
||||
const protocolKeys: ProtocolKey[] = ["http"];
|
||||
if (options.showTcp && (displayMode === "advanced" || height >= 220)) protocolKeys.push("tcp");
|
||||
if (options.showUdp && (displayMode === "advanced" || height >= 300)) protocolKeys.push("udp");
|
||||
const protocolKeys = getTraefikProtocolKeys(options, height, isAdvanced);
|
||||
const entryPoints = getVisibleTraefikEntryPoints(combined.entryPoints, width, isAdvanced);
|
||||
const displayedVersion = successfulData.length === 1 ? successfulData[0]?.dashboard.version : null;
|
||||
|
||||
const summary = (
|
||||
<div className={classes.root}>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<div className={classes.titleBlock}>
|
||||
<Text className={classes.title}>{data.length === 1 ? data[0]?.integrationName : t("instances")}</Text>
|
||||
<Text className={classes.subtitle}>{combined.version ? `v${combined.version}` : t("versionUnknown")}</Text>
|
||||
<Text className={classes.title}>
|
||||
{successfulData.length === 1 ? successfulData[0]?.integrationName : t("instances")}
|
||||
</Text>
|
||||
<Text className={classes.subtitle}>
|
||||
{displayedVersion
|
||||
? `v${displayedVersion}`
|
||||
: successfulData.length > 1
|
||||
? successfulData.map(({ integrationName }) => integrationName).join(" · ")
|
||||
: t("versionUnknown")}
|
||||
</Text>
|
||||
</div>
|
||||
<HealthBadge errors={totalErrors} warnings={totalWarnings} />
|
||||
</Group>
|
||||
|
||||
{isAdvanced && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{successfulData.map(({ integrationId, integrationName, dashboard }) => (
|
||||
<Badge key={integrationId} variant="outline" size="sm" radius="sm">
|
||||
{integrationName} · {dashboard.version ? `v${dashboard.version}` : t("versionUnknown")}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<div className={classes.hero}>
|
||||
<SummaryMetric
|
||||
icon={<IconRoute size={getHeroIconSize(width)} />}
|
||||
@@ -127,46 +193,51 @@ function TraefikWidgetContent({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{options.showEntryPoints && combined.entryPoints.length > 0 && (displayMode === "advanced" || height >= 340) && (
|
||||
{(isAdvanced || options.showEntryPoints) && entryPoints.length > 0 && (isAdvanced || height >= 340) && (
|
||||
<div className={classes.entryPoints}>
|
||||
{dedupe(combined.entryPoints)
|
||||
.slice(0, getEntryPointLimit(width))
|
||||
.map((entryPoint) => (
|
||||
<Badge key={entryPoint} variant="light" size="sm" radius="sm">
|
||||
{entryPoint}
|
||||
</Badge>
|
||||
))}
|
||||
{entryPoints.map((entryPoint) => (
|
||||
<Badge key={entryPoint} variant="light" size="sm" radius="sm">
|
||||
{entryPoint}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (displayMode === "compact") return <ScrollArea h="100%">{summary}</ScrollArea>;
|
||||
if (!isAdvanced) {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const resources = [...combined.resources].toSorted(
|
||||
const sourceDetails = getTraefikSourceDetails(successfulData);
|
||||
const resources = sourceDetails.resources.toSorted(
|
||||
(left, right) =>
|
||||
resourceStatusOrder[left.status] - resourceStatusOrder[right.status] || left.name.localeCompare(right.name),
|
||||
);
|
||||
const resourceList = (
|
||||
<Stack gap="xs">
|
||||
{combined.failedEndpoints.map((endpoint) => (
|
||||
<Badge key={endpoint} color="red" variant="light">
|
||||
{t("failedEndpoint", { endpoint })}
|
||||
{sourceDetails.failedEndpoints.map(({ endpoint, integrationName, key }) => (
|
||||
<Badge key={key} color="red" variant="light">
|
||||
{integrationName} · {t("failedEndpoint", { endpoint })}
|
||||
</Badge>
|
||||
))}
|
||||
{resources.map((resource, index) => (
|
||||
<Group
|
||||
key={`${resource.protocol}:${resource.type}:${resource.name}:${index}`}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="xs"
|
||||
>
|
||||
{resources.map((resource) => (
|
||||
<Group key={resource.key} justify="space-between" wrap="nowrap" p="xs">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{resource.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"}
|
||||
{resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"} ·{" "}
|
||||
{resource.integrationName}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge
|
||||
@@ -189,20 +260,32 @@ function TraefikWidgetContent({
|
||||
|
||||
if (width < 900) {
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="md" p="md">
|
||||
{summary}
|
||||
{resourceList}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="md" p="md">
|
||||
{summary}
|
||||
{resourceList}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||
<ScrollArea h="100%">{resourceList}</ScrollArea>
|
||||
</SimpleGrid>
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||
<ScrollArea h="100%">{resourceList}</ScrollArea>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user