feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -1,20 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import { Button, Divider, Group, Pagination, Paper, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { views } from ".";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { HealthCheckStatus } from "./health-check-status";
|
||||
import { QueuePanel } from "./panels/queue.panel";
|
||||
import { StatisticsPanel } from "./panels/statistics.panel";
|
||||
import { WorkersPanel } from "./panels/workers.panel";
|
||||
import { views } from "./views";
|
||||
|
||||
type View = (typeof views)[number];
|
||||
|
||||
@@ -26,30 +31,112 @@ const viewIcons = {
|
||||
statistics: IconReportAnalytics,
|
||||
} satisfies Record<View, TablerIcon>;
|
||||
|
||||
type TranscodingControlMode = "buttons" | "select";
|
||||
|
||||
const narrowWidthLayout = { minimumWidth: 0, advancedColumns: 1, controlMode: "select" } as const;
|
||||
|
||||
const widthBreakpoints = [
|
||||
{ minimumWidth: 1100, advancedColumns: 3, controlMode: "buttons" },
|
||||
{ minimumWidth: 700, advancedColumns: 2, controlMode: "buttons" },
|
||||
{ minimumWidth: 560, advancedColumns: 1, controlMode: "buttons" },
|
||||
narrowWidthLayout,
|
||||
] as const satisfies readonly {
|
||||
minimumWidth: number;
|
||||
advancedColumns: number;
|
||||
controlMode: TranscodingControlMode;
|
||||
}[];
|
||||
|
||||
const getTranscodingWidthLayout = (width: number) =>
|
||||
widthBreakpoints.find((candidate) => width >= candidate.minimumWidth) ?? narrowWidthLayout;
|
||||
|
||||
export default function MediaTranscodingWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
widgetRuntimeRef,
|
||||
}: 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);
|
||||
useWidgetRuntimeQueries(widgetRuntimeRef, [
|
||||
getQueryKey(clientApi.widget.mediaTranscoding.getDataAsync, input, "query"),
|
||||
]);
|
||||
const transcodingQuery = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
|
||||
const transcodingData = getUsableWidgetQueryData(transcodingQuery);
|
||||
|
||||
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 });
|
||||
};
|
||||
const widthLayout = useMemo(() => getTranscodingWidthLayout(width), [width]);
|
||||
|
||||
if (isInitialWidgetQueryPending(transcodingQuery)) return <WidgetQueryLoadingState />;
|
||||
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 < 280 || height < 140);
|
||||
const footerLayout = getTranscodingFooterLayout(width, height);
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
<Stack gap="xs" h="100%" p="xs" pos="relative">
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={widthLayout.advancedColumns} 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={queuePageCount > 1 ? "space-between" : "flex-end"} wrap="nowrap">
|
||||
{queuePageCount > 1 && (
|
||||
<QueuePaginationControls
|
||||
total={queuePageCount}
|
||||
value={queuePage}
|
||||
onChange={handleQueuePageChange}
|
||||
layout={footerLayout}
|
||||
/>
|
||||
)}
|
||||
<HealthCheckStatus statistics={transcodingData.data.statistics} />
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4} h="100%">
|
||||
<Stack gap={4} h="100%" pos="relative">
|
||||
{view === "workers" ? (
|
||||
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
|
||||
) : view === "queue" ? (
|
||||
@@ -58,52 +145,65 @@ export default function MediaTranscodingWidget({
|
||||
<StatisticsPanel statistics={transcodingData.data.statistics} />
|
||||
)}
|
||||
<Divider />
|
||||
<Group gap="xs" mb={4} ms={4} me={8}>
|
||||
<SegmentedControl
|
||||
data={views.map((value) => {
|
||||
const Icon = viewIcons[value];
|
||||
return {
|
||||
label: (
|
||||
<Center style={{ gap: 4 }}>
|
||||
<Icon size={12} />
|
||||
{!isTiny && (
|
||||
<Text span size="xs">
|
||||
{t(`tab.${value}`)}
|
||||
</Text>
|
||||
)}
|
||||
</Center>
|
||||
),
|
||||
<Group gap="xs" mb={4} ms={4} me={8} wrap="nowrap">
|
||||
{widthLayout.controlMode === "buttons" ? (
|
||||
<Button.Group style={{ flex: 1, minWidth: 0 }}>
|
||||
{views.map((value) => {
|
||||
const Icon = viewIcons[value];
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
size="compact-xs"
|
||||
variant={value === view ? "filled" : "default"}
|
||||
leftSection={<Icon size="var(--mantine-font-size-xs)" />}
|
||||
aria-pressed={value === view}
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => setView(value)}
|
||||
>
|
||||
{t(`tab.${value}`)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Button.Group>
|
||||
) : (
|
||||
<Select
|
||||
size="xs"
|
||||
value={view}
|
||||
data={views.map((value) => ({
|
||||
value,
|
||||
};
|
||||
})}
|
||||
value={view}
|
||||
onChange={(value) => {
|
||||
const nextView = viewBySegmentValue[value];
|
||||
if (nextView) {
|
||||
setView(nextView);
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
label: t(`tab.${value}`),
|
||||
}))}
|
||||
aria-label={t("option.defaultView.label")}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
|
||||
<Group gap="xs" ml="auto">
|
||||
{view === "queue" && (
|
||||
const nextView = viewBySegmentValue[value];
|
||||
if (nextView) {
|
||||
setView(nextView);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group gap="xs" ml="auto" wrap="nowrap">
|
||||
{view === "queue" && queuePageCount > 1 && (
|
||||
<>
|
||||
<Pagination.Root total={totalQueuePages} value={queuePage} onChange={setQueuePage} 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} />}
|
||||
</Group>
|
||||
</Pagination.Root>
|
||||
<Text size="xs">
|
||||
{t("currentIndex", {
|
||||
start: String(transcodingData.data.queue.startIndex + 1),
|
||||
end: String(transcodingData.data.queue.endIndex + 1),
|
||||
total: String(transcodingData.data.queue.totalCount),
|
||||
})}
|
||||
</Text>
|
||||
<QueuePaginationControls
|
||||
total={queuePageCount}
|
||||
value={queuePage}
|
||||
onChange={handleQueuePageChange}
|
||||
layout={footerLayout}
|
||||
/>
|
||||
{footerLayout.showPageRange && (
|
||||
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("currentIndex", {
|
||||
start: String(transcodingData.data.queue.startIndex + 1),
|
||||
end: String(transcodingData.data.queue.endIndex + 1),
|
||||
total: String(transcodingData.data.queue.totalCount),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -113,3 +213,66 @@ 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)));
|
||||
};
|
||||
|
||||
export const getTranscodingFooterLayout = (width: number, height: number) => ({
|
||||
showTabLabels: width >= 420 && height >= 160,
|
||||
showPageEdges: width >= 360,
|
||||
showPageItems: width >= 600,
|
||||
showPageRange: width >= 760,
|
||||
});
|
||||
|
||||
interface QueuePaginationControlsProps {
|
||||
total: number;
|
||||
value: number;
|
||||
onChange: (page: number) => void;
|
||||
layout: ReturnType<typeof getTranscodingFooterLayout>;
|
||||
}
|
||||
|
||||
const QueuePaginationControls = ({ total, value, onChange, layout }: QueuePaginationControlsProps) => (
|
||||
<Pagination.Root total={total} value={value} onChange={onChange} size="xs">
|
||||
<Group gap={2} justify="center" wrap="nowrap">
|
||||
{layout.showPageEdges && <Pagination.First disabled={value === 1} />}
|
||||
<Pagination.Previous disabled={value === 1} />
|
||||
{layout.showPageItems && <Pagination.Items />}
|
||||
<Pagination.Next disabled={value === total} />
|
||||
{layout.showPageEdges && <Pagination.Last disabled={value === total} />}
|
||||
</Group>
|
||||
</Pagination.Root>
|
||||
);
|
||||
|
||||
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="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Divider, Group, HoverCard, Indicator, RingProgress, Stack, Text } from "@mantine/core";
|
||||
import { ActionIcon, Divider, Group, Indicator, Popover, RingProgress, Stack, Text } from "@mantine/core";
|
||||
import { useColorScheme } from "@mantine/hooks";
|
||||
import { IconHeartbeat } from "@tabler/icons-react";
|
||||
|
||||
import type { TdarrStatistics } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
|
||||
interface HealthCheckStatusProps {
|
||||
statistics: TdarrStatistics;
|
||||
}
|
||||
@@ -13,6 +15,7 @@ interface HealthCheckStatusProps {
|
||||
export function HealthCheckStatus(props: HealthCheckStatusProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const t = useI18n("widget.mediaTranscoding.healthCheck");
|
||||
const tCommon = useI18n("widget.mediaTranscoding.panel.common");
|
||||
|
||||
const indicatorColor = props.statistics.failedHealthCheckCount
|
||||
? "red"
|
||||
@@ -21,21 +24,23 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
|
||||
: "green";
|
||||
|
||||
return (
|
||||
<HoverCard position="bottom" width={250} shadow="sm">
|
||||
<HoverCard.Target>
|
||||
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
|
||||
<IconHeartbeat size={16} />
|
||||
</Indicator>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown
|
||||
<Popover position="bottom" width={250} shadow="sm" withArrow>
|
||||
<Popover.Target>
|
||||
<ActionIcon className={actionTargetClasses.root} variant="subtle" size="sm" aria-label={tCommon("healthCheck")}>
|
||||
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
|
||||
<IconHeartbeat size="var(--mantine-font-size-md)" />
|
||||
</Indicator>
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
style={{
|
||||
backgroundColor: "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8))",
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm" align="center">
|
||||
<Group gap="xs">
|
||||
<IconHeartbeat size={18} />
|
||||
<Text size="sm">{t("title")}</Text>
|
||||
<IconHeartbeat size="var(--mantine-font-size-lg)" />
|
||||
<Text size="sm">{tCommon("healthCheck")}</Text>
|
||||
</Group>
|
||||
<Divider
|
||||
style={{
|
||||
@@ -70,8 +75,8 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,20 @@ import { IconTransform } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { capitalize } from "@homarr/common";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { getWidgetIntegrationConfig } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import { views } from "./views";
|
||||
|
||||
export const views = ["workers", "queue", "statistics"] as const;
|
||||
export { views };
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
|
||||
icon: IconTransform,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "mediaTranscoding", "getDataAsync"]],
|
||||
queryMatcher: matchesWidgetRuntimeQuery,
|
||||
...getWidgetIntegrationConfig("mediaTranscoding"),
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
@@ -21,5 +26,4 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaTran
|
||||
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaTranscoding"),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getQueuePageSize, getTranscodingFooterLayout, 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("reveals footer detail only as space becomes available", () => {
|
||||
expect(getTranscodingFooterLayout(280, 220)).toEqual({
|
||||
showTabLabels: false,
|
||||
showPageEdges: false,
|
||||
showPageItems: false,
|
||||
showPageRange: false,
|
||||
});
|
||||
expect(getTranscodingFooterLayout(600, 220)).toEqual({
|
||||
showTabLabels: true,
|
||||
showPageEdges: true,
|
||||
showPageItems: true,
|
||||
showPageRange: false,
|
||||
});
|
||||
expect(getTranscodingFooterLayout(760, 220).showPageRange).toBe(true);
|
||||
expect(getTranscodingFooterLayout(760, 120).showTabLabels).toBe(false);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import type { TdarrQueue } from "@homarr/integrations";
|
||||
import { useByteFormatter } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface QueuePanelProps {
|
||||
@@ -24,13 +24,15 @@ interface QueuePanelProps {
|
||||
|
||||
export function QueuePanel(props: QueuePanelProps) {
|
||||
const { queue } = props;
|
||||
const { formatBytes } = useByteFormatter();
|
||||
|
||||
const t = useI18n("widget.mediaTranscoding.panel.queue");
|
||||
const tCommon = useI18n("widget.mediaTranscoding.panel.common");
|
||||
|
||||
if (queue.array.length === 0) {
|
||||
return (
|
||||
<Center style={{ flex: "1" }}>
|
||||
<Title order={6}>{t("empty")}</Title>
|
||||
<Title order={6}>{tCommon("empty")}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +44,7 @@ export function QueuePanel(props: QueuePanelProps) {
|
||||
<TableTr>
|
||||
<TableTh ta="start" py={4}>
|
||||
<Text size="xs" fw="bold">
|
||||
{t("table.file")}
|
||||
{tCommon("file")}
|
||||
</Text>
|
||||
</TableTh>
|
||||
<TableTh ta="start" py={4}>
|
||||
@@ -58,12 +60,12 @@ export function QueuePanel(props: QueuePanelProps) {
|
||||
<TableTd py={2}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{item.type === "transcode" ? (
|
||||
<Tooltip label={t("table.transcode")}>
|
||||
<IconTransform size={12} />
|
||||
<Tooltip label={tCommon("transcode")}>
|
||||
<IconTransform size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t("table.healthCheck")}>
|
||||
<IconHeartbeat size={12} />
|
||||
<Tooltip label={tCommon("healthCheck")}>
|
||||
<IconHeartbeat size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text lineClamp={1} size="xs">
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Card, Center, Group, RingProgress, ScrollArea, Stack, Text, Title, Tool
|
||||
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
|
||||
import { useByteFormatter } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
@@ -16,6 +16,8 @@ interface StatisticsPanelProps {
|
||||
|
||||
export function StatisticsPanel(props: StatisticsPanelProps) {
|
||||
const t = useI18n("widget.mediaTranscoding.panel.statistics");
|
||||
const tCommon = useI18n("widget.mediaTranscoding.panel.common");
|
||||
const { formatBytes } = useByteFormatter();
|
||||
|
||||
const allLibs = props.statistics;
|
||||
|
||||
@@ -23,7 +25,7 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
|
||||
if (!(allLibs.totalFileCount > 0)) {
|
||||
return (
|
||||
<Center style={{ flex: "1" }}>
|
||||
<Title order={6}>{t("empty")}</Title>
|
||||
<Title order={6}>{tCommon("empty")}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -31,10 +33,14 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Group wrap="wrap" justify="center" p={4} w="100%" gap="xs">
|
||||
<StatisticItem icon={IconTransform} label={t("transcodesCount")} value={props.statistics.totalTranscodeCount} />
|
||||
<StatisticItem
|
||||
icon={IconTransform}
|
||||
label={tCommon("transcodes")}
|
||||
value={props.statistics.totalTranscodeCount}
|
||||
/>
|
||||
<StatisticItem
|
||||
icon={IconHeartbeat}
|
||||
label={t("healthChecksCount")}
|
||||
label={tCommon("healthChecks")}
|
||||
value={props.statistics.totalHealthCheckCount}
|
||||
/>
|
||||
<StatisticItem icon={IconFileDescription} label={t("filesCount")} value={props.statistics.totalFileCount} />
|
||||
@@ -45,8 +51,8 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="center" wrap="wrap" grow>
|
||||
<StatisticRingProgress items={allLibs.transcodeStatus} label={t("transcodes")} />
|
||||
<StatisticRingProgress items={allLibs.healthCheckStatus} label={t("healthChecks")} />
|
||||
<StatisticRingProgress items={allLibs.transcodeStatus} label={tCommon("transcodes")} />
|
||||
<StatisticRingProgress items={allLibs.healthCheckStatus} label={tCommon("healthChecks")} />
|
||||
<StatisticRingProgress items={allLibs.videoCodecs} label={t("videoCodecs")} />
|
||||
<StatisticRingProgress items={allLibs.videoContainers} label={t("videoContainers")} />
|
||||
<StatisticRingProgress items={allLibs.videoResolutions} label={t("videoResolutions")} />
|
||||
@@ -71,8 +77,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}`,
|
||||
@@ -91,9 +98,9 @@ function StatisticItem(props: StatisticItemProps) {
|
||||
const board = useRequiredBoard();
|
||||
return (
|
||||
<Tooltip label={props.label}>
|
||||
<Card p={0} radius={board.itemRadius} miw={48} flex={1}>
|
||||
<Card p={0} radius={board.itemRadius} miw={48} flex={1} bg="transparent">
|
||||
<Group justify="center" align="center" gap="xs" w="100%" wrap="nowrap">
|
||||
<props.icon size={16} style={{ minWidth: 16 }} />
|
||||
<props.icon size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
|
||||
<Text size="md">{props.value}</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
@@ -25,11 +25,12 @@ interface WorkersPanelProps {
|
||||
|
||||
export function WorkersPanel(props: WorkersPanelProps) {
|
||||
const t = useI18n("widget.mediaTranscoding.panel.workers");
|
||||
const tCommon = useI18n("widget.mediaTranscoding.panel.common");
|
||||
|
||||
if (props.workers.length === 0) {
|
||||
return (
|
||||
<Center style={{ flex: "1" }}>
|
||||
<Title order={6}>{t("empty")}</Title>
|
||||
<Title order={6}>{tCommon("empty")}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +42,7 @@ export function WorkersPanel(props: WorkersPanelProps) {
|
||||
<TableTr>
|
||||
<TableTh ta="start" py={4}>
|
||||
<Text size="xs" fw="bold">
|
||||
{t("table.file")}
|
||||
{tCommon("file")}
|
||||
</Text>
|
||||
</TableTh>
|
||||
<TableTh ta="start" py={4} w={50}>
|
||||
@@ -65,12 +66,12 @@ export function WorkersPanel(props: WorkersPanelProps) {
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<div>
|
||||
{worker.jobType === "transcode" ? (
|
||||
<Tooltip label={t("table.transcode")}>
|
||||
<IconTransform size={14} />
|
||||
<Tooltip label={tCommon("transcode")}>
|
||||
<IconTransform size="var(--mantine-font-size-sm)" style={{ flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t("table.healthCheck")}>
|
||||
<IconHeartbeat size={14} />
|
||||
<Tooltip label={tCommon("healthCheck")}>
|
||||
<IconHeartbeat size="var(--mantine-font-size-sm)" style={{ flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const views = ["workers", "queue", "statistics"] as const;
|
||||
Reference in new issue
Block a user