mirror of
https://github.com/ajnart/homarr.git
synced 2026-01-24 00:09:12 +01:00
✨ Add Tdarr integration and widget (#1882)
This commit is contained in:
96
public/locales/en/modules/media-transcoding.json
Normal file
96
public/locales/en/modules/media-transcoding.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"descriptor": {
|
||||
"name": "Media Transcoding",
|
||||
"description": "Displays information about media transcoding",
|
||||
"settings": {
|
||||
"title": "Media Transcoding Settings",
|
||||
"appId": {
|
||||
"label": "Select an app"
|
||||
},
|
||||
"defaultView": {
|
||||
"label": "Default view",
|
||||
"data": {
|
||||
"workers": "Workers",
|
||||
"queue": "Queue",
|
||||
"statistics": "Statistics"
|
||||
}
|
||||
},
|
||||
"showHealthCheck": {
|
||||
"label": "Show Health Check indicator"
|
||||
},
|
||||
"showHealthChecksInQueue": {
|
||||
"label": "Show Health Checks in queue"
|
||||
},
|
||||
"queuePageSize": {
|
||||
"label": "Queue: Items per page"
|
||||
},
|
||||
"showAppIcon": {
|
||||
"label": "Show app icon in the bottom right corner"
|
||||
}
|
||||
}
|
||||
},
|
||||
"noAppSelected": "Please select an app in the widget settings",
|
||||
"views": {
|
||||
"workers": {
|
||||
"table": {
|
||||
"header": {
|
||||
"name": "File",
|
||||
"eta": "ETA",
|
||||
"progress": "Progress"
|
||||
},
|
||||
"empty": "Empty",
|
||||
"tooltip": {
|
||||
"transcode": "Transcode",
|
||||
"healthCheck": "Health Check"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"table": {
|
||||
"header": {
|
||||
"name": "File",
|
||||
"size": "Size"
|
||||
},
|
||||
"footer": {
|
||||
"currentIndex": "{{start}}-{{end}} of {{total}}"
|
||||
},
|
||||
"empty": "Empty",
|
||||
"tooltip": {
|
||||
"transcode": "Transcode",
|
||||
"healthCheck": "Health Check"
|
||||
}
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"empty": "Empty",
|
||||
"box": {
|
||||
"transcodes": "Transcodes: {{value}}",
|
||||
"healthChecks": "Health Checks: {{value}}",
|
||||
"files": "Files: {{value}}",
|
||||
"spaceSaved": "Saved: {{value}}"
|
||||
},
|
||||
"pies": {
|
||||
"transcodes": "Transcodes",
|
||||
"healthChecks": "Health Checks",
|
||||
"videoCodecs": "Codecs",
|
||||
"videoContainers": "Containers",
|
||||
"videoResolutions": "Resolutions"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "Error",
|
||||
"message": "An error occurred while fetching data from Tdarr."
|
||||
},
|
||||
"tabs": {
|
||||
"workers": "Workers",
|
||||
"queue": "Queue",
|
||||
"statistics": "Statistics"
|
||||
},
|
||||
"healthCheckStatus": {
|
||||
"title": "Health Check",
|
||||
"queued": "Queued",
|
||||
"healthy": "Healthy",
|
||||
"unhealthy": "Unhealthy"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,11 @@ import { useTranslation } from 'next-i18next';
|
||||
import { highlight, languages } from 'prismjs';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings';
|
||||
import {
|
||||
BackgroundImageAttachment,
|
||||
BackgroundImageRepeat,
|
||||
BackgroundImageSize,
|
||||
} from '~/types/settings';
|
||||
|
||||
import { useBoardCustomizationFormContext } from '../form';
|
||||
|
||||
|
||||
@@ -202,5 +202,10 @@ export const availableIntegrations = [
|
||||
value: 'proxmox',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/proxmox.png',
|
||||
label: 'Proxmox',
|
||||
},
|
||||
{
|
||||
value: 'tdarr',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png',
|
||||
label: 'Tdarr',
|
||||
}
|
||||
] as const satisfies Readonly<SelectItem[]>;
|
||||
|
||||
@@ -85,7 +85,7 @@ export const WidgetsEditModal = ({
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{items.map(([key, _], index) => {
|
||||
{items.map(([key], index) => {
|
||||
const option = (currentWidgetDefinition as any).options[key] as IWidgetOptionValue;
|
||||
const value = moduleProperties[key] ?? option.defaultValue;
|
||||
|
||||
@@ -395,6 +395,7 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
/* eslint-enable no-case-declarations */
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -63,7 +63,7 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
|
||||
const handleEditClick = () => {
|
||||
openContextModalGeneric<WidgetEditModalInnerProps>({
|
||||
modal: 'integrationOptions',
|
||||
title: <Title order={4}>{t('descriptor.settings.title')}</Title>,
|
||||
title: t('descriptor.settings.title'),
|
||||
innerProps: {
|
||||
widgetId: widget.id,
|
||||
widgetType: integration,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { smartHomeEntityStateRouter } from './routers/smart-home/entity-state';
|
||||
import { usenetRouter } from './routers/usenet/router';
|
||||
import { userRouter } from './routers/user';
|
||||
import { weatherRouter } from './routers/weather';
|
||||
import { tdarrRouter } from '~/server/api/routers/tdarr';
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -51,6 +52,7 @@ export const rootRouter = createTRPCRouter({
|
||||
notebook: notebookRouter,
|
||||
smartHomeEntityState: smartHomeEntityStateRouter,
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
tdarr: tdarrRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
356
src/server/api/routers/tdarr.ts
Normal file
356
src/server/api/routers/tdarr.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import axios from 'axios';
|
||||
import { z } from 'zod';
|
||||
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { ConfigAppType } from '~/types/app';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { TdarrQueue, TdarrStatistics, TdarrWorker } from '~/types/api/tdarr';
|
||||
|
||||
const getStatisticsSchema = z.object({
|
||||
totalFileCount: z.number(),
|
||||
totalTranscodeCount: z.number(),
|
||||
totalHealthCheckCount: z.number(),
|
||||
table3Count: z.number(),
|
||||
table6Count: z.number(),
|
||||
table1Count: z.number(),
|
||||
table4Count: z.number(),
|
||||
pies: z.array(
|
||||
z.tuple([
|
||||
z.string(), // Library Name
|
||||
z.string(), // Library ID
|
||||
z.number(), // File count
|
||||
z.number(), // Number of transcodes
|
||||
z.number(), // Space saved (in GB)
|
||||
z.number(), // Number of health checks
|
||||
z.array(
|
||||
z.object({
|
||||
// Transcode Status (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
})
|
||||
),
|
||||
z.array(
|
||||
z.object({
|
||||
// Health Status (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
})
|
||||
),
|
||||
z.array(
|
||||
z.object({
|
||||
// Video files - Codecs (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
})
|
||||
),
|
||||
z.array(
|
||||
z.object({
|
||||
// Video files - Containers (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
})
|
||||
),
|
||||
z.array(
|
||||
z.object({
|
||||
// Video files - Resolutions (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
})
|
||||
),
|
||||
z.array(
|
||||
z.object({
|
||||
// Audio files - Codecs (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
})
|
||||
),
|
||||
z.array(
|
||||
z.object({
|
||||
// Audio files - Containers (Pie segments)
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
})
|
||||
),
|
||||
])
|
||||
),
|
||||
});
|
||||
|
||||
const getNodesResponseSchema = z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
_id: z.string(),
|
||||
nodeName: z.string(),
|
||||
nodePaused: z.boolean(),
|
||||
workers: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
_id: z.string(),
|
||||
file: z.string(),
|
||||
fps: z.number(),
|
||||
percentage: z.number(),
|
||||
ETA: z.string(),
|
||||
job: z.object({
|
||||
type: z.string(),
|
||||
}),
|
||||
status: z.string(),
|
||||
lastPluginDetails: z
|
||||
.object({
|
||||
number: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
originalfileSizeInGbytes: z.number(),
|
||||
estSize: z.number().optional(),
|
||||
outputFileSizeInGbytes: z.number().optional(),
|
||||
workerType: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
const getStatusTableSchema = z.object({
|
||||
array: z.array(
|
||||
z.object({
|
||||
_id: z.string(),
|
||||
HealthCheck: z.string(),
|
||||
TranscodeDecisionMaker: z.string(),
|
||||
file: z.string(),
|
||||
file_size: z.number(),
|
||||
container: z.string(),
|
||||
video_codec_name: z.string(),
|
||||
video_resolution: z.string(),
|
||||
})
|
||||
),
|
||||
totalCount: z.number(),
|
||||
});
|
||||
|
||||
export const tdarrRouter = createTRPCRouter({
|
||||
statistics: publicProcedure
|
||||
.input(z.object({
|
||||
appId: z.string(),
|
||||
configName: z.string(),
|
||||
}))
|
||||
.query(async ({ input }): Promise<TdarrStatistics> => {
|
||||
const app = getTdarrApp(input.appId, input.configName);
|
||||
const appUrl = new URL('api/v2/cruddb', app.url);
|
||||
|
||||
const body = {
|
||||
data: {
|
||||
collection: 'StatisticsJSONDB',
|
||||
mode: 'getById',
|
||||
docID: 'statistics',
|
||||
},
|
||||
};
|
||||
|
||||
const res = await axios.post(appUrl.toString(), body);
|
||||
const data: z.infer<typeof getStatisticsSchema> = res.data;
|
||||
|
||||
const zodRes = getStatisticsSchema.safeParse(data);
|
||||
if (!zodRes.success) {
|
||||
/*
|
||||
* Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type
|
||||
* definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types.
|
||||
*/
|
||||
console.error(zodRes.error);
|
||||
}
|
||||
|
||||
return {
|
||||
totalFileCount: data.totalFileCount,
|
||||
totalTranscodeCount: data.totalTranscodeCount,
|
||||
totalHealthCheckCount: data.totalHealthCheckCount,
|
||||
failedTranscodeCount: data.table3Count,
|
||||
failedHealthCheckCount: data.table6Count,
|
||||
stagedTranscodeCount: data.table1Count,
|
||||
stagedHealthCheckCount: data.table4Count,
|
||||
pies: data.pies.map((pie) => ({
|
||||
libraryName: pie[0],
|
||||
libraryId: pie[1],
|
||||
totalFiles: pie[2],
|
||||
totalTranscodes: pie[3],
|
||||
savedSpace: pie[4] * 1_000_000_000, // file_size is in GB, convert to bytes,
|
||||
totalHealthChecks: pie[5],
|
||||
transcodeStatus: pie[6],
|
||||
healthCheckStatus: pie[7],
|
||||
videoCodecs: pie[8],
|
||||
videoContainers: pie[9],
|
||||
videoResolutions: pie[10],
|
||||
audioCodecs: pie[11],
|
||||
audioContainers: pie[12],
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
workers: publicProcedure
|
||||
.input(z.object({
|
||||
appId: z.string(),
|
||||
configName: z.string(),
|
||||
})).query(async ({ input }): Promise<TdarrWorker[]> => {
|
||||
const app = getTdarrApp(input.appId, input.configName);
|
||||
const appUrl = new URL('api/v2/get-nodes', app.url);
|
||||
|
||||
const res = await axios.get(appUrl.toString());
|
||||
const data: z.infer<typeof getNodesResponseSchema> = res.data;
|
||||
|
||||
const zodRes = getNodesResponseSchema.safeParse(data);
|
||||
if (!zodRes.success) {
|
||||
/*
|
||||
* Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type
|
||||
* definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types.
|
||||
*/
|
||||
console.error(zodRes.error);
|
||||
}
|
||||
|
||||
const nodes = Object.values(data);
|
||||
const workers = nodes.flatMap((node) => {
|
||||
return Object.values(node.workers);
|
||||
});
|
||||
|
||||
return workers.map((worker) => ({
|
||||
id: worker._id,
|
||||
filePath: worker.file,
|
||||
fps: worker.fps,
|
||||
percentage: worker.percentage,
|
||||
ETA: worker.ETA,
|
||||
jobType: worker.job.type,
|
||||
status: worker.status,
|
||||
step: worker.lastPluginDetails?.number ?? '',
|
||||
originalSize: worker.originalfileSizeInGbytes * 1_000_000_000, // file_size is in GB, convert to bytes,
|
||||
estimatedSize: worker.estSize ? worker.estSize * 1_000_000_000 : null, // file_size is in GB, convert to bytes,
|
||||
outputSize: worker.outputFileSizeInGbytes ? worker.outputFileSizeInGbytes * 1_000_000_000 : null, // file_size is in GB, convert to bytes,
|
||||
}));
|
||||
}),
|
||||
|
||||
queue: publicProcedure
|
||||
.input(z.object({
|
||||
appId: z.string(),
|
||||
configName: z.string(),
|
||||
showHealthChecksInQueue: z.boolean(),
|
||||
pageSize: z.number(),
|
||||
page: z.number(),
|
||||
}))
|
||||
.query(async ({ input }): Promise<TdarrQueue> => {
|
||||
const app = getTdarrApp(input.appId, input.configName);
|
||||
|
||||
const appUrl = new URL('api/v2/client/status-tables', app.url);
|
||||
|
||||
const { page, pageSize, showHealthChecksInQueue } = input;
|
||||
|
||||
const firstItemIndex = page * pageSize;
|
||||
|
||||
const transcodeQueueBody = {
|
||||
data: {
|
||||
start: firstItemIndex,
|
||||
pageSize: pageSize,
|
||||
filters: [],
|
||||
sorts: [],
|
||||
opts: {
|
||||
table: 'table1',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const transcodeQueueRes = await axios.post(appUrl.toString(), transcodeQueueBody);
|
||||
const transcodeQueueData: z.infer<typeof getStatusTableSchema> = transcodeQueueRes.data;
|
||||
|
||||
const transcodeQueueZodRes = getStatusTableSchema.safeParse(transcodeQueueData);
|
||||
if (!transcodeQueueZodRes.success) {
|
||||
/*
|
||||
* Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type
|
||||
* definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types.
|
||||
*/
|
||||
console.error(transcodeQueueZodRes.error);
|
||||
}
|
||||
|
||||
const transcodeQueueResult = {
|
||||
array: transcodeQueueData.array.map((item) => ({
|
||||
id: item._id,
|
||||
healthCheck: item.HealthCheck,
|
||||
transcode: item.TranscodeDecisionMaker,
|
||||
filePath: item.file,
|
||||
fileSize: item.file_size * 1_000_000, // file_size is in MB, convert to bytes
|
||||
container: item.container,
|
||||
codec: item.video_codec_name,
|
||||
resolution: item.video_resolution,
|
||||
type: 'transcode' as const,
|
||||
})),
|
||||
totalCount: transcodeQueueData.totalCount,
|
||||
startIndex: firstItemIndex,
|
||||
endIndex: firstItemIndex + transcodeQueueData.array.length - 1,
|
||||
};
|
||||
|
||||
if (!showHealthChecksInQueue) {
|
||||
return transcodeQueueResult;
|
||||
}
|
||||
|
||||
const healthCheckQueueBody = {
|
||||
data: {
|
||||
start: Math.max(firstItemIndex - transcodeQueueData.totalCount, 0),
|
||||
pageSize: pageSize,
|
||||
filters: [],
|
||||
sorts: [],
|
||||
opts: {
|
||||
table: 'table4',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const healthCheckQueueRes = await axios.post(appUrl.toString(), healthCheckQueueBody);
|
||||
const healthCheckQueueData: z.infer<typeof getStatusTableSchema> = healthCheckQueueRes.data;
|
||||
|
||||
const healthCheckQueueZodRes = getStatusTableSchema.safeParse(healthCheckQueueData);
|
||||
if (!healthCheckQueueZodRes.success) {
|
||||
/*
|
||||
* Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type
|
||||
* definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types.
|
||||
*/
|
||||
console.error(healthCheckQueueZodRes.error);
|
||||
}
|
||||
|
||||
const healthCheckResultArray = healthCheckQueueData.array.map((item) => ({
|
||||
id: item._id,
|
||||
healthCheck: item.HealthCheck,
|
||||
transcode: item.TranscodeDecisionMaker,
|
||||
filePath: item.file,
|
||||
fileSize: item.file_size * 1_000_000, // file_size is in MB, convert to bytes
|
||||
container: item.container,
|
||||
codec: item.video_codec_name,
|
||||
resolution: item.video_resolution,
|
||||
type: 'health check' as const,
|
||||
}));
|
||||
|
||||
const combinedArray = [...transcodeQueueResult.array, ...healthCheckResultArray].slice(
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
|
||||
return {
|
||||
array: combinedArray,
|
||||
totalCount: transcodeQueueData.totalCount + healthCheckQueueData.totalCount,
|
||||
startIndex: firstItemIndex,
|
||||
endIndex: firstItemIndex + combinedArray.length - 1,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
function getTdarrApp(appId: string, configName: string): ConfigAppType {
|
||||
const config = getConfig(configName);
|
||||
|
||||
const app = config.apps.find((x) => x.id === appId);
|
||||
|
||||
if (!app) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `[Tdarr integration] App with ID "${appId}" could not be found.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!checkIntegrationsType(app.integration, ['tdarr'])) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `[Tdarr integration] App with ID "${appId}" is not using the Tdarr integration.`,
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const boardNamespaces = [
|
||||
'modules/notebook',
|
||||
'modules/smart-home/entity-state',
|
||||
'modules/smart-home/trigger-automation',
|
||||
'modules/media-transcoding',
|
||||
'widgets/error-boundary',
|
||||
'widgets/draggable-list',
|
||||
'widgets/location',
|
||||
|
||||
60
src/types/api/tdarr.ts
Normal file
60
src/types/api/tdarr.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export type TdarrPieSegment = {
|
||||
name: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type TdarrStatistics = {
|
||||
totalFileCount: number;
|
||||
totalTranscodeCount: number;
|
||||
totalHealthCheckCount: number;
|
||||
failedTranscodeCount: number;
|
||||
failedHealthCheckCount: number;
|
||||
stagedTranscodeCount: number;
|
||||
stagedHealthCheckCount: number;
|
||||
pies: {
|
||||
libraryName: string;
|
||||
libraryId: string;
|
||||
totalFiles: number;
|
||||
totalTranscodes: number;
|
||||
savedSpace: number;
|
||||
totalHealthChecks: number;
|
||||
transcodeStatus: TdarrPieSegment[];
|
||||
healthCheckStatus: TdarrPieSegment[];
|
||||
videoCodecs: TdarrPieSegment[];
|
||||
videoContainers: TdarrPieSegment[];
|
||||
videoResolutions: TdarrPieSegment[];
|
||||
audioCodecs: TdarrPieSegment[];
|
||||
audioContainers: TdarrPieSegment[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TdarrWorker = {
|
||||
id: string;
|
||||
filePath: string;
|
||||
fps: number;
|
||||
percentage: number;
|
||||
ETA: string;
|
||||
jobType: string;
|
||||
status: string;
|
||||
step: string;
|
||||
originalSize: number;
|
||||
estimatedSize: number | null;
|
||||
outputSize: number | null;
|
||||
};
|
||||
|
||||
export type TdarrQueue = {
|
||||
array: {
|
||||
id: string;
|
||||
healthCheck: string;
|
||||
transcode: string;
|
||||
filePath: string;
|
||||
fileSize: number;
|
||||
container: string;
|
||||
codec: string;
|
||||
resolution: string;
|
||||
type: 'transcode' | 'health check';
|
||||
}[];
|
||||
totalCount: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
};
|
||||
@@ -59,7 +59,8 @@ export type IntegrationType =
|
||||
| 'adGuardHome'
|
||||
| 'homeAssistant'
|
||||
| 'openmediavault'
|
||||
| 'proxmox';
|
||||
| 'proxmox'
|
||||
| 'tdarr';
|
||||
|
||||
export type AppIntegrationType = {
|
||||
type: IntegrationType | null;
|
||||
@@ -105,6 +106,7 @@ export const integrationFieldProperties: {
|
||||
homeAssistant: ['apiKey'],
|
||||
openmediavault: ['username', 'password'],
|
||||
proxmox: ['apiKey'],
|
||||
tdarr: [],
|
||||
};
|
||||
|
||||
export type IntegrationFieldDefinitionType = {
|
||||
|
||||
@@ -15,6 +15,7 @@ import notebook from './notebook/NotebookWidgetTile';
|
||||
import rss from './rss/RssWidgetTile';
|
||||
import smartHomeEntityState from './smart-home/entity-state/entity-state.widget';
|
||||
import smartHomeTriggerAutomation from './smart-home/trigger-automation/trigger-automation.widget';
|
||||
import mediaTranscoding from '~/widgets/media-transcoding/MediaTranscodingTile';
|
||||
import torrent from './torrent/TorrentTile';
|
||||
import usenet from './useNet/UseNetTile';
|
||||
import videoStream from './video/VideoStreamTile';
|
||||
@@ -42,4 +43,5 @@ export default {
|
||||
'smart-home/entity-state': smartHomeEntityState,
|
||||
'smart-home/trigger-automation': smartHomeTriggerAutomation,
|
||||
'health-monitoring': healthMonitoring,
|
||||
'media-transcoding': mediaTranscoding,
|
||||
};
|
||||
|
||||
90
src/widgets/media-transcoding/HealthCheckStatus.tsx
Normal file
90
src/widgets/media-transcoding/HealthCheckStatus.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Divider,
|
||||
Group,
|
||||
HoverCard,
|
||||
Indicator,
|
||||
MantineColor,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { IconHeartbeat } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useColorScheme } from '~/hooks/use-colorscheme';
|
||||
|
||||
import { TdarrStatistics } from '~/types/api/tdarr';
|
||||
|
||||
interface StatisticsBadgeProps {
|
||||
statistics?: TdarrStatistics;
|
||||
}
|
||||
|
||||
export function HealthCheckStatus(props: StatisticsBadgeProps) {
|
||||
const { statistics } = props;
|
||||
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { t } = useTranslation('modules/media-transcoding');
|
||||
|
||||
if (!statistics) {
|
||||
return <IconHeartbeat size={20} />;
|
||||
}
|
||||
|
||||
const indicatorColor = statistics.failedHealthCheckCount
|
||||
? 'red'
|
||||
: statistics.stagedHealthCheckCount
|
||||
? 'yellow'
|
||||
: 'green';
|
||||
|
||||
return (
|
||||
<HoverCard position="bottom" width={250} shadow="sm">
|
||||
<HoverCard.Target>
|
||||
<Indicator color={textColor(indicatorColor, colorScheme)} size={8} display="flex">
|
||||
<IconHeartbeat size={20} />
|
||||
</Indicator>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown bg={colorScheme === 'light' ? 'gray.2' : 'dark.8'}>
|
||||
<Stack spacing="sm" align="center">
|
||||
<Group spacing="xs">
|
||||
<IconHeartbeat size={18} />
|
||||
<Text size="sm">{t(`healthCheckStatus.title`)}</Text>
|
||||
</Group>
|
||||
<Divider
|
||||
style={{
|
||||
alignSelf: 'stretch',
|
||||
}}
|
||||
/>
|
||||
<RingProgress
|
||||
sections={[
|
||||
{ value: statistics.stagedHealthCheckCount, color: textColor('yellow', colorScheme) },
|
||||
{ value: statistics.totalHealthCheckCount, color: textColor('green', colorScheme) },
|
||||
{ value: statistics.failedHealthCheckCount, color: textColor('red', colorScheme) },
|
||||
]}
|
||||
/>
|
||||
<Group display="flex" w="100%">
|
||||
<Stack style={{ flex: 1 }} spacing={0} align="center">
|
||||
<Text size="xs" color={textColor('yellow', colorScheme)}>
|
||||
{statistics.stagedHealthCheckCount}
|
||||
</Text>
|
||||
<Text size="xs">{t(`healthCheckStatus.queued`)}</Text>
|
||||
</Stack>
|
||||
<Stack style={{ flex: 1 }} spacing={0} align="center">
|
||||
<Text size="xs" color={textColor('green', colorScheme)}>
|
||||
{statistics.totalHealthCheckCount}
|
||||
</Text>
|
||||
<Text size="xs">{t(`healthCheckStatus.healthy`)}</Text>
|
||||
</Stack>
|
||||
<Stack style={{ flex: 1 }} spacing={0} align="center">
|
||||
<Text size="xs" color={textColor('red', colorScheme)}>
|
||||
{statistics.failedHealthCheckCount}
|
||||
</Text>
|
||||
<Text size="xs">{t(`healthCheckStatus.unhealthy`)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function textColor(color: MantineColor, theme: 'light' | 'dark') {
|
||||
return `${color}.${theme === 'light' ? 8 : 5}`;
|
||||
}
|
||||
265
src/widgets/media-transcoding/MediaTranscodingTile.tsx
Normal file
265
src/widgets/media-transcoding/MediaTranscodingTile.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
Alert,
|
||||
Center,
|
||||
Code,
|
||||
Divider,
|
||||
Group,
|
||||
List,
|
||||
Pagination,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconClipboardList,
|
||||
IconCpu2,
|
||||
IconReportAnalytics, IconTransform,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { AppAvatar } from '~/components/AppAvatar';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
import { HealthCheckStatus } from '~/widgets/media-transcoding/HealthCheckStatus';
|
||||
import { QueuePanel } from '~/widgets/media-transcoding/QueuePanel';
|
||||
import { StatisticsPanel } from '~/widgets/media-transcoding/StatisticsPanel';
|
||||
import { WorkersPanel } from '~/widgets/media-transcoding/WorkersPanel';
|
||||
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'media-transcoding',
|
||||
icon: IconTransform,
|
||||
options: {
|
||||
defaultView: {
|
||||
type: 'select',
|
||||
data: [
|
||||
{
|
||||
value: 'workers',
|
||||
},
|
||||
{
|
||||
value: 'queue',
|
||||
},
|
||||
{
|
||||
value: 'statistics',
|
||||
},
|
||||
],
|
||||
defaultValue: 'workers',
|
||||
},
|
||||
showHealthCheck: {
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
showHealthChecksInQueue: {
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
queuePageSize: {
|
||||
type: 'number',
|
||||
defaultValue: 10,
|
||||
},
|
||||
showAppIcon: {
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
minWidth: 3,
|
||||
minHeight: 2,
|
||||
maxWidth: 12,
|
||||
maxHeight: 6,
|
||||
},
|
||||
component: MediaTranscodingTile,
|
||||
});
|
||||
|
||||
export type TdarrWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface TdarrQueueTileProps {
|
||||
widget: TdarrWidget;
|
||||
}
|
||||
|
||||
function MediaTranscodingTile({ widget }: TdarrQueueTileProps) {
|
||||
const { t } = useTranslation('modules/media-transcoding');
|
||||
const { config, name: configName } = useConfigContext();
|
||||
|
||||
const appId = config?.apps.find(
|
||||
(app) => app.integration.type === 'tdarr',
|
||||
)?.id;
|
||||
const app = config?.apps.find((app) => app.id === appId);
|
||||
const { defaultView, showHealthCheck, showHealthChecksInQueue, queuePageSize, showAppIcon } =
|
||||
widget.properties;
|
||||
|
||||
const [view, setView] = useState<'workers' | 'queue' | 'statistics'>(
|
||||
viewSchema.parse(defaultView)
|
||||
);
|
||||
|
||||
const [queuePage, setQueuePage] = useState(1);
|
||||
|
||||
const workers = api.tdarr.workers.useQuery(
|
||||
{
|
||||
appId: app?.id!,
|
||||
configName: configName!,
|
||||
},
|
||||
{ enabled: !!app?.id && !!configName && view === 'workers', refetchInterval: 2000 }
|
||||
);
|
||||
|
||||
const statistics = api.tdarr.statistics.useQuery(
|
||||
{
|
||||
appId: app?.id!,
|
||||
configName: configName!,
|
||||
},
|
||||
{ enabled: !!app?.id && !!configName, refetchInterval: 10000 }
|
||||
);
|
||||
|
||||
const queue = api.tdarr.queue.useQuery(
|
||||
{
|
||||
appId: app?.id!,
|
||||
configName: configName!,
|
||||
pageSize: queuePageSize,
|
||||
page: queuePage - 1,
|
||||
showHealthChecksInQueue,
|
||||
},
|
||||
{
|
||||
enabled: !!app?.id && !!configName && view === 'queue',
|
||||
refetchInterval: 2000,
|
||||
}
|
||||
);
|
||||
|
||||
if (statistics.isError || workers.isError || queue.isError) {
|
||||
return (
|
||||
<Group position="center">
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
my="lg"
|
||||
title={t('error.title')}
|
||||
color="red"
|
||||
radius="md"
|
||||
>
|
||||
{t('error.message')}
|
||||
<List>
|
||||
{statistics.isError && (
|
||||
<Code mt="sm" block>
|
||||
{statistics.error.message}
|
||||
</Code>
|
||||
)}
|
||||
{workers.isError && (
|
||||
<Code mt="sm" block>
|
||||
{workers.error.message}
|
||||
</Code>
|
||||
)}
|
||||
{queue.isError && (
|
||||
<Code mt="sm" block>
|
||||
{queue.error.message}
|
||||
</Code>
|
||||
)}
|
||||
</List>
|
||||
</Alert>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<Stack justify="center" h="100%">
|
||||
<Center>
|
||||
<Title order={3}>{t('noAppSelected')}</Title>
|
||||
</Center>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const totalQueuePages = Math.ceil((queue.data?.totalCount || 1) / queuePageSize);
|
||||
|
||||
return (
|
||||
<Stack spacing="xs" h="100%">
|
||||
{view === 'workers' ? (
|
||||
<WorkersPanel workers={workers.data} isLoading={workers.isLoading} />
|
||||
) : view === 'queue' ? (
|
||||
<QueuePanel queue={queue.data} isLoading={queue.isLoading} />
|
||||
) : (
|
||||
<StatisticsPanel statistics={statistics.data} isLoading={statistics.isLoading} />
|
||||
)}
|
||||
<Divider />
|
||||
<Group spacing="xs">
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{
|
||||
label: (
|
||||
<Center>
|
||||
<IconCpu2 size={18} />
|
||||
<Text size="xs" ml={8}>
|
||||
{t('tabs.workers')}
|
||||
</Text>
|
||||
</Center>
|
||||
),
|
||||
value: 'workers',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Center>
|
||||
<IconClipboardList size={18} />
|
||||
<Text size="xs" ml={8}>
|
||||
{t('tabs.queue')}
|
||||
</Text>
|
||||
</Center>
|
||||
),
|
||||
value: 'queue',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Center>
|
||||
<IconReportAnalytics size={18} />
|
||||
<Text size="xs" ml={8}>
|
||||
{t('tabs.statistics')}
|
||||
</Text>
|
||||
</Center>
|
||||
),
|
||||
value: 'statistics',
|
||||
},
|
||||
]}
|
||||
value={view}
|
||||
onChange={(value) => setView(viewSchema.parse(value))}
|
||||
size="xs"
|
||||
/>
|
||||
{view === 'queue' && !!queue.data && (
|
||||
<>
|
||||
<Pagination.Root total={totalQueuePages} value={queuePage} onChange={setQueuePage} size="sm">
|
||||
<Group spacing={5} position="center">
|
||||
<Pagination.First disabled={queuePage === 1} />
|
||||
<Pagination.Previous disabled={queuePage === 1} />
|
||||
<Pagination.Next disabled={queuePage === totalQueuePages} />
|
||||
<Pagination.Last disabled={queuePage === totalQueuePages} />
|
||||
</Group>
|
||||
</Pagination.Root>
|
||||
<Text size="xs">
|
||||
{t('views.queue.table.footer.currentIndex', {
|
||||
start: queue.data.startIndex + 1,
|
||||
end: queue.data.endIndex + 1,
|
||||
total: queue.data.totalCount,
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Group spacing="xs" ml="auto">
|
||||
{showHealthCheck && statistics.data && <HealthCheckStatus statistics={statistics.data} />}
|
||||
{showAppIcon && (
|
||||
<Tooltip label={app.name}>
|
||||
<div>
|
||||
<AppAvatar iconUrl={app.appearance.iconUrl} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const viewSchema = z.enum(['workers', 'queue', 'statistics']);
|
||||
|
||||
export default definition;
|
||||
69
src/widgets/media-transcoding/QueuePanel.tsx
Normal file
69
src/widgets/media-transcoding/QueuePanel.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Center, Group, ScrollArea, Table, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { IconHeartbeat, IconTransform } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { humanFileSize } from '~/tools/humanFileSize';
|
||||
import { WidgetLoading } from '~/widgets/loading';
|
||||
import { TdarrQueue } from '~/types/api/tdarr';
|
||||
|
||||
interface QueuePanelProps {
|
||||
queue: TdarrQueue | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function QueuePanel(props: QueuePanelProps) {
|
||||
const { queue, isLoading } = props;
|
||||
|
||||
const { t } = useTranslation('modules/media-transcoding');
|
||||
|
||||
if (isLoading) {
|
||||
return <WidgetLoading />;
|
||||
}
|
||||
|
||||
if (!queue?.array.length) {
|
||||
return (
|
||||
<Center
|
||||
style={{ flex: '1' }}
|
||||
>
|
||||
<Title order={3}>{t('views.queue.table.empty')}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ flex: '1' }}>
|
||||
<Table style={{ tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('views.queue.table.header.name')}</th>
|
||||
<th style={{ width: 80 }}>{t('views.queue.table.header.size')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{queue.array.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<Group spacing="xs" noWrap>
|
||||
<div>
|
||||
{item.type === 'transcode' ? (
|
||||
<Tooltip label={t('views.workers.table.tooltip.transcode')}>
|
||||
<IconTransform size={14} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t('views.workers.table.tooltip.healthCheck')}>
|
||||
<IconHeartbeat size={14} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Text lineClamp={1} size="xs">{item.filePath.split('\\').pop()?.split('/').pop() ?? item.filePath}</Text>
|
||||
</Group>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">{humanFileSize(item.fileSize)}</Text>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
167
src/widgets/media-transcoding/StatisticsPanel.tsx
Normal file
167
src/widgets/media-transcoding/StatisticsPanel.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Grid,
|
||||
Group,
|
||||
MantineColor,
|
||||
RingProgress,
|
||||
RingProgressProps,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconDatabaseHeart,
|
||||
IconFileDescription,
|
||||
IconHeartbeat,
|
||||
IconTransform,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ReactNode } from 'react';
|
||||
import { humanFileSize } from '~/tools/humanFileSize';
|
||||
import { WidgetLoading } from '~/widgets/loading';
|
||||
import { TdarrPieSegment, TdarrStatistics } from '~/types/api/tdarr';
|
||||
|
||||
const PIE_COLORS: MantineColor[] = ['cyan', 'grape', 'gray', 'orange', 'pink'];
|
||||
|
||||
interface StatisticsPanelProps {
|
||||
statistics: TdarrStatistics | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function StatisticsPanel(props: StatisticsPanelProps) {
|
||||
const { statistics, isLoading } = props;
|
||||
|
||||
const { t } = useTranslation('modules/media-transcoding');
|
||||
|
||||
if (isLoading) {
|
||||
return <WidgetLoading />;
|
||||
}
|
||||
|
||||
const allLibs = statistics?.pies.find((pie) => pie.libraryName === 'All');
|
||||
|
||||
if (!statistics || !allLibs) {
|
||||
return (
|
||||
<Center
|
||||
style={{ flex: '1' }}
|
||||
>
|
||||
<Title order={3}>{t('views.statistics.empty')}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack style={{ flex: '1' }} spacing="xs">
|
||||
<Group
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
position="apart"
|
||||
align="center"
|
||||
noWrap
|
||||
>
|
||||
<Stack align="center" spacing={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.transcodeStatus)} />
|
||||
<Text size="xs">{t('views.statistics.pies.transcodes')}</Text>
|
||||
</Stack>
|
||||
<Grid gutter="xs">
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconTransform size={18} />}
|
||||
label={t('views.statistics.box.transcodes', {
|
||||
value: statistics.totalTranscodeCount
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconHeartbeat size={18} />}
|
||||
label={t('views.statistics.box.healthChecks', {
|
||||
value: statistics.totalHealthCheckCount
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconFileDescription size={18} />}
|
||||
label={t('views.statistics.box.files', {
|
||||
value: statistics.totalFileCount
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconDatabaseHeart size={18} />}
|
||||
label={t('views.statistics.box.spaceSaved', {
|
||||
value: allLibs?.savedSpace ? humanFileSize(allLibs.savedSpace) : '-'
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack align="center" spacing={0}>
|
||||
<RingProgress
|
||||
size={120}
|
||||
sections={toRingProgressSections(allLibs.healthCheckStatus)}
|
||||
/>
|
||||
<Text size="xs">{t('views.statistics.pies.healthChecks')}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
position="apart"
|
||||
align="center"
|
||||
noWrap
|
||||
>
|
||||
<Stack align="center" spacing={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoCodecs)} />
|
||||
<Text size="xs">{t('views.statistics.pies.videoCodecs')}</Text>
|
||||
</Stack>
|
||||
<Stack align="center" spacing={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoContainers)} />
|
||||
<Text size="xs">{t('views.statistics.pies.videoContainers')}</Text>
|
||||
</Stack>
|
||||
<Stack align="center" spacing={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoResolutions)} />
|
||||
<Text size="xs">{t('views.statistics.pies.videoResolutions')}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps['sections'] {
|
||||
const total = segments.reduce((prev, curr) => prev + curr.value , 0);
|
||||
return segments.map((segment, index) => ({
|
||||
value: (segment.value * 100) / total,
|
||||
tooltip: `${segment.name}: ${segment.value}`,
|
||||
color: PIE_COLORS[index % PIE_COLORS.length], // Ensures a valid color in the case that index > PIE_COLORS.length
|
||||
}));
|
||||
}
|
||||
|
||||
type StatBoxProps = {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function StatBox(props: StatBoxProps) {
|
||||
const { icon, label } = props;
|
||||
return (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
padding: theme.spacing.xs,
|
||||
border: '1px solid',
|
||||
borderRadius: theme.radius.md,
|
||||
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[3] : theme.colors.gray[1],
|
||||
})}
|
||||
>
|
||||
<Stack spacing="xs" align="center">
|
||||
{icon}
|
||||
<Text size="xs">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
94
src/widgets/media-transcoding/WorkersPanel.tsx
Normal file
94
src/widgets/media-transcoding/WorkersPanel.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Center,
|
||||
Group,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconHeartbeat, IconTransform } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { WidgetLoading } from '~/widgets/loading';
|
||||
import { TdarrWorker } from '~/types/api/tdarr';
|
||||
|
||||
interface WorkersPanelProps {
|
||||
workers: TdarrWorker[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function WorkersPanel(props: WorkersPanelProps) {
|
||||
const { workers, isLoading } = props;
|
||||
|
||||
const { t } = useTranslation('modules/media-transcoding');
|
||||
|
||||
if (isLoading) {
|
||||
return <WidgetLoading />;
|
||||
}
|
||||
|
||||
if (!workers?.length) {
|
||||
return (
|
||||
<Center
|
||||
style={{ flex: '1' }}
|
||||
>
|
||||
<Title order={3}>{t('views.workers.table.empty')}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ flex: '1' }}>
|
||||
<Table style={{ tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('views.workers.table.header.name')}</th>
|
||||
<th style={{ width: 60 }}>{t('views.workers.table.header.eta')}</th>
|
||||
<th style={{ width: 175 }}>{t('views.workers.table.header.progress')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.map((worker) => (
|
||||
<tr key={worker.id}>
|
||||
<td>
|
||||
<Group spacing="xs" noWrap>
|
||||
<div>
|
||||
{worker.jobType === 'transcode' ? (
|
||||
<Tooltip label={t('views.workers.table.tooltip.transcode')}>
|
||||
<IconTransform size={14} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t('views.workers.table.tooltip.healthCheck')}>
|
||||
<IconHeartbeat size={14} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Text lineClamp={1} size="xs">{worker.filePath.split('\\').pop()?.split('/').pop() ?? worker.filePath}</Text>
|
||||
</Group>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">
|
||||
{worker.ETA.startsWith('0:') ? worker.ETA.substring(2) : worker.ETA}
|
||||
</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Group noWrap spacing="xs">
|
||||
<Text size="xs">{worker.step}</Text>
|
||||
<Progress
|
||||
value={worker.percentage}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<Text size="xs">{Math.round(worker.percentage)}%</Text>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { Icon } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
import { IntegrationType } from '~/types/app';
|
||||
import { AreaType } from '~/types/area';
|
||||
import { ShapeType } from '~/types/shape';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user