feat: modernize dashboard widgets
This commit is contained in:
1 parent
1a5e083fd0
commit
6f2cd7fc2b
270 files changed
+10189
-3544
No files matched your search
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { formatTotalTracearrBitrate, formatTracearrBitrate } from "./bitrate";
|
||||
|
||||
describe("Tracearr bitrate formatting", () => {
|
||||
test("sums raw Kbps before choosing the display unit", () => {
|
||||
expect(formatTotalTracearrBitrate([{ bitrate: 800 }, { bitrate: 1200 }])).toBe("2 Mbps");
|
||||
});
|
||||
|
||||
test("formats Gbps after aggregation", () => {
|
||||
expect(formatTotalTracearrBitrate([{ bitrate: 1_500_000 }, { bitrate: 500_000 }])).toBe("2 Gbps");
|
||||
});
|
||||
|
||||
test("ignores missing and invalid bitrate values", () => {
|
||||
expect(formatTotalTracearrBitrate([{ bitrate: null }, { bitrate: Number.NaN }, { bitrate: 500 }])).toBe("500 kbps");
|
||||
expect(formatTracearrBitrate(0)).toBe("—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TracearrStream } from "@homarr/integrations/types";
|
||||
|
||||
export function formatTracearrBitrate(kbps: number): string {
|
||||
if (!Number.isFinite(kbps) || kbps <= 0) return "—";
|
||||
if (kbps >= 1_000_000) return `${formatScaledBitrate(kbps / 1_000_000)} Gbps`;
|
||||
if (kbps >= 1000) return `${formatScaledBitrate(kbps / 1000)} Mbps`;
|
||||
return `${kbps} kbps`;
|
||||
}
|
||||
|
||||
export function formatTotalTracearrBitrate(streams: readonly Pick<TracearrStream, "bitrate">[]): string {
|
||||
const totalKbps = streams.reduce((sum, { bitrate }) => {
|
||||
if (bitrate === null || !Number.isFinite(bitrate) || bitrate <= 0) return sum;
|
||||
return sum + bitrate;
|
||||
}, 0);
|
||||
return formatTracearrBitrate(totalKbps);
|
||||
}
|
||||
|
||||
function formatScaledBitrate(value: number): string {
|
||||
return value % 1 === 0 ? value.toFixed(0) : value.toFixed(1);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { TracearrDashboardData } from "@homarr/integrations/types";
|
||||
@@ -8,31 +8,48 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import { formatTotalTracearrBitrate } from "./bitrate";
|
||||
import { RecentActivityList } from "./recent-activity-section";
|
||||
import { StatsBar } from "./stats-section";
|
||||
import { StreamsList } from "./streams-section";
|
||||
import { ViolationsList } from "./violations-section";
|
||||
|
||||
export default function TracearrWidget({ options, integrationIds, width }: WidgetComponentProps<"tracearr">) {
|
||||
export default function TracearrWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"tracearr">) {
|
||||
if (integrationIds.length === 0) {
|
||||
throw new NoIntegrationDataError();
|
||||
}
|
||||
|
||||
return <TracearrContent integrationIds={integrationIds} options={options} width={width} />;
|
||||
return (
|
||||
<TracearrContent
|
||||
integrationIds={integrationIds}
|
||||
options={options}
|
||||
width={width}
|
||||
height={height}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface TracearrContentProps {
|
||||
integrationIds: string[];
|
||||
options: WidgetComponentProps<"tracearr">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
displayMode: "compact" | "advanced";
|
||||
}
|
||||
|
||||
function TracearrContent({ integrationIds, options, width }: TracearrContentProps) {
|
||||
function TracearrContent({ integrationIds, options, width, height, displayMode }: TracearrContentProps) {
|
||||
const t = useScopedI18n("widget.tracearr");
|
||||
const { data: dashboardData = [] } = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds });
|
||||
|
||||
// Merge data from all integrations
|
||||
const combined = dashboardData.reduce<TracearrDashboardData>(
|
||||
const combinedData = dashboardData.reduce<TracearrDashboardData>(
|
||||
(acc, item) => {
|
||||
const { stats, streams, violations, recentActivity } = item.dashboard;
|
||||
const vData = violations ?? { data: [], meta: { total: 0, page: 1, pageSize: 5 } };
|
||||
@@ -53,7 +70,7 @@ function TracearrContent({ integrationIds, options, width }: TracearrContentProp
|
||||
transcodes: acc.streams.summary.transcodes + streams.summary.transcodes,
|
||||
directStreams: acc.streams.summary.directStreams + streams.summary.directStreams,
|
||||
directPlays: acc.streams.summary.directPlays + streams.summary.directPlays,
|
||||
totalBitrate: streams.summary.totalBitrate,
|
||||
totalBitrate: "—",
|
||||
byServer: [...acc.streams.summary.byServer, ...streams.summary.byServer],
|
||||
},
|
||||
},
|
||||
@@ -85,17 +102,51 @@ function TracearrContent({ integrationIds, options, width }: TracearrContentProp
|
||||
recentActivity: null,
|
||||
},
|
||||
);
|
||||
const combined: TracearrDashboardData = {
|
||||
...combinedData,
|
||||
streams: {
|
||||
...combinedData.streams,
|
||||
summary: {
|
||||
...combinedData.streams.summary,
|
||||
totalBitrate: formatTotalTracearrBitrate(combinedData.streams.data),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const noSectionsEnabled =
|
||||
!options.showStats && !options.showStreams && !options.showRecentActivity && !options.showViolations;
|
||||
|
||||
const compactLimit = height < 220 ? 2 : height < 360 ? 4 : 8;
|
||||
const streams = displayMode === "advanced" ? combined.streams.data : combined.streams.data.slice(0, compactLimit);
|
||||
const violations = combined.violations?.data ?? [];
|
||||
const recentActivity = combined.recentActivity?.data ?? [];
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={width >= 800 ? 2 : 1} spacing="md" p="md">
|
||||
<Stack gap="sm">
|
||||
{options.showStats && (
|
||||
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={width / 2} />
|
||||
)}
|
||||
{options.showStreams && <StreamsList streams={streams} width={width / 2} />}
|
||||
</Stack>
|
||||
<Stack gap="sm">
|
||||
{options.showViolations && <ViolationsList violations={violations} />}
|
||||
{options.showRecentActivity && <RecentActivityList sessions={recentActivity} />}
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="xs" p="xs">
|
||||
{options.showStats && <StatsBar stats={combined.stats} summary={combined.streams.summary} width={width} />}
|
||||
{options.showStreams && <StreamsList streams={combined.streams.data} width={width} />}
|
||||
{options.showViolations && <ViolationsList violations={combined.violations?.data ?? []} />}
|
||||
{options.showRecentActivity && <RecentActivityList sessions={combined.recentActivity?.data ?? []} />}
|
||||
{options.showStreams && <StreamsList streams={streams} width={width} />}
|
||||
{options.showViolations && <ViolationsList violations={violations.slice(0, compactLimit)} />}
|
||||
{options.showRecentActivity && <RecentActivityList sessions={recentActivity.slice(0, compactLimit)} />}
|
||||
{noSectionsEnabled && (
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("noSectionsEnabled")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("tracearr", {
|
||||
icon: IconActivityHeartbeat,
|
||||
queryKey: [["widget", "tracearr"]],
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
Reference in new issue
Block a user