feat: modernize dashboard widgets
This commit is contained in:
1 parent
1a5e083fd0
commit
6f2cd7fc2b
270 files changed
+10189
-3544
No files matched your search
@@ -1,7 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -30,23 +42,73 @@ export default function MediaTranscodingWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaTranscoding">) {
|
||||
const [queuePage, setQueuePage] = useState(1);
|
||||
const queuePageSize = 10;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const queuePageSize = getQueuePageSize(height, isAdvanced);
|
||||
const [queuePagination, setQueuePagination] = useState<QueuePaginationState>(() => ({
|
||||
page: 1,
|
||||
pageSize: queuePageSize,
|
||||
isAdvanced,
|
||||
}));
|
||||
const requestPagination = resolveQueuePagination(queuePagination, queuePageSize, isAdvanced, null);
|
||||
const input = {
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
pageSize: queuePageSize,
|
||||
page: queuePage,
|
||||
page: requestPagination.page,
|
||||
};
|
||||
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
|
||||
|
||||
const [view, setView] = useState<View>(options.defaultView);
|
||||
const t = useI18n("widget.mediaTranscoding");
|
||||
const totalQueuePages = transcodingData
|
||||
? Math.max(1, Math.ceil(transcodingData.data.queue.totalCount / queuePageSize))
|
||||
: null;
|
||||
const resolvedPagination = resolveQueuePagination(queuePagination, queuePageSize, isAdvanced, totalQueuePages);
|
||||
const queuePage = resolvedPagination.page;
|
||||
|
||||
useEffect(() => {
|
||||
setQueuePagination((current) => {
|
||||
const next = resolveQueuePagination(current, queuePageSize, isAdvanced, totalQueuePages);
|
||||
return current.page === next.page && current.pageSize === next.pageSize && current.isAdvanced === next.isAdvanced
|
||||
? current
|
||||
: next;
|
||||
});
|
||||
}, [isAdvanced, queuePageSize, totalQueuePages]);
|
||||
|
||||
const handleQueuePageChange = (page: number) => {
|
||||
setQueuePagination({ page, pageSize: queuePageSize, isAdvanced });
|
||||
};
|
||||
|
||||
if (!transcodingData) return <WidgetEmptyState />;
|
||||
|
||||
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
|
||||
const isTiny = width < 256;
|
||||
const queuePageCount = totalQueuePages ?? 1;
|
||||
const isTiny = !isAdvanced && width < 256;
|
||||
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
<Stack gap="xs" h="100%" p="xs">
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
|
||||
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
|
||||
<WorkersPanel workers={transcodingData.data.workers} isTiny={false} />
|
||||
</AdvancedPanel>
|
||||
<AdvancedPanel title={t("tab.queue")} icon={IconClipboardList}>
|
||||
<QueuePanel queue={transcodingData.data.queue} />
|
||||
</AdvancedPanel>
|
||||
<AdvancedPanel title={t("tab.statistics")} icon={IconReportAnalytics}>
|
||||
<StatisticsPanel statistics={transcodingData.data.statistics} />
|
||||
</AdvancedPanel>
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Pagination total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs" />
|
||||
<HealthCheckStatus statistics={transcodingData.data.statistics} />
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4} h="100%">
|
||||
@@ -89,12 +151,12 @@ export default function MediaTranscodingWidget({
|
||||
<Group gap="xs" ml="auto">
|
||||
{view === "queue" && (
|
||||
<>
|
||||
<Pagination.Root total={totalQueuePages} value={queuePage} onChange={setQueuePage} size="xs">
|
||||
<Pagination.Root total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs">
|
||||
<Group gap={2} justify="center">
|
||||
{!isTiny && <Pagination.First disabled={transcodingData.data.queue.startIndex === 1} />}
|
||||
<Pagination.Previous disabled={transcodingData.data.queue.startIndex === 1} />
|
||||
<Pagination.Next disabled={transcodingData.data.queue.startIndex === totalQueuePages} />
|
||||
{!isTiny && <Pagination.Last disabled={transcodingData.data.queue.startIndex === totalQueuePages} />}
|
||||
{!isTiny && <Pagination.First disabled={queuePage === 1} />}
|
||||
<Pagination.Previous disabled={queuePage === 1} />
|
||||
<Pagination.Next disabled={queuePage === queuePageCount} />
|
||||
{!isTiny && <Pagination.Last disabled={queuePage === queuePageCount} />}
|
||||
</Group>
|
||||
</Pagination.Root>
|
||||
<Text size="xs">
|
||||
@@ -113,3 +175,40 @@ export default function MediaTranscodingWidget({
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const getQueuePageSize = (height: number, isAdvanced: boolean): number => {
|
||||
if (isAdvanced) return 25;
|
||||
return Math.max(3, Math.min(15, Math.floor((height - 84) / 28)));
|
||||
};
|
||||
|
||||
interface QueuePaginationState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
export const resolveQueuePagination = (
|
||||
state: QueuePaginationState,
|
||||
pageSize: number,
|
||||
isAdvanced: boolean,
|
||||
totalPages: number | null,
|
||||
): QueuePaginationState => ({
|
||||
page:
|
||||
state.pageSize !== pageSize || state.isAdvanced !== isAdvanced
|
||||
? 1
|
||||
: Math.max(1, totalPages === null ? state.page : Math.min(state.page, totalPages)),
|
||||
pageSize,
|
||||
isAdvanced,
|
||||
});
|
||||
|
||||
const AdvancedPanel = ({ title, icon: Icon, children }: { title: string; icon: TablerIcon; children: ReactNode }) => (
|
||||
<Paper withBorder radius="sm" p="xs" mih={280} style={{ display: "flex", flexDirection: "column" }}>
|
||||
<Group gap="xs" mb="xs">
|
||||
<Icon size={16} />
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
@@ -11,6 +11,8 @@ export const views = ["workers", "queue", "statistics"] as const;
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
|
||||
icon: IconTransform,
|
||||
queryKey: [["widget", "mediaTranscoding"]],
|
||||
maxIntegrations: 1,
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getQueuePageSize, resolveQueuePagination } from "./component";
|
||||
import { toRingProgressSections } from "./panels/statistics.panel";
|
||||
|
||||
describe("media transcoding layout", () => {
|
||||
test("keeps compact queues bounded to the available height", () => {
|
||||
expect(getQueuePageSize(140, false)).toBe(3);
|
||||
expect(getQueuePageSize(500, false)).toBe(14);
|
||||
expect(getQueuePageSize(2000, false)).toBe(15);
|
||||
});
|
||||
|
||||
test("loads a bounded deeper queue in advanced mode", () => {
|
||||
expect(getQueuePageSize(200, true)).toBe(25);
|
||||
});
|
||||
|
||||
test("resets the queue when display mode or page size changes", () => {
|
||||
const compact = { page: 7, pageSize: 10, isAdvanced: false };
|
||||
|
||||
expect(resolveQueuePagination(compact, 25, true, 8)).toEqual({ page: 1, pageSize: 25, isAdvanced: true });
|
||||
expect(resolveQueuePagination(compact, 12, false, 8)).toEqual({ page: 1, pageSize: 12, isAdvanced: false });
|
||||
});
|
||||
|
||||
test("clamps the queue page when the result count shrinks", () => {
|
||||
expect(resolveQueuePagination({ page: 7, pageSize: 10, isAdvanced: false }, 10, false, 3)).toEqual({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
isAdvanced: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not create NaN chart sections for empty statistics", () => {
|
||||
expect(toRingProgressSections([{ name: "none", value: 0 }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -71,8 +71,9 @@ const StatisticRingProgress = ({ items, label }: StatisticRingProgressProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] {
|
||||
export function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] {
|
||||
const total = segments.reduce((prev, curr) => prev + curr.value, 0);
|
||||
if (total <= 0) return [];
|
||||
return segments.map((segment, index) => ({
|
||||
value: (segment.value * 100) / total,
|
||||
tooltip: `${segment.name}: ${segment.value}`,
|
||||
|
||||
Reference in new issue
Block a user