mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat: add tdarr integration (#1657)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { healthMonitoringRouter } from "./health-monitoring";
|
||||
import { indexerManagerRouter } from "./indexer-manager";
|
||||
import { mediaRequestsRouter } from "./media-requests";
|
||||
import { mediaServerRouter } from "./media-server";
|
||||
import { mediaTranscodingRouter } from "./media-transcoding";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { rssFeedRouter } from "./rssFeed";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
@@ -25,4 +26,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
rssFeed: rssFeedRouter,
|
||||
indexerManager: indexerManagerRouter,
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
mediaTranscoding: mediaTranscodingRouter,
|
||||
});
|
||||
|
||||
28
packages/api/src/router/widgets/media-transcoding.ts
Normal file
28
packages/api/src/router/widgets/media-transcoding.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
|
||||
|
||||
export const mediaTranscodingRouter = createTRPCRouter({
|
||||
getDataAsync: publicProcedure
|
||||
.unstable_concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.input(validation.common.paginated.pick({ page: true, pageSize: true }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
|
||||
pageOffset: input.page,
|
||||
pageSize: input.pageSize,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integrationId: ctx.integration.id,
|
||||
data,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
|
||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||
import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
|
||||
import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import type { RssFeed } from "./jobs/rss-feeds";
|
||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||
@@ -31,6 +32,7 @@ export const jobGroup = createCronJobGroup({
|
||||
healthMonitoring: healthMonitoringJob,
|
||||
sessionCleanup: sessionCleanupJob,
|
||||
updateChecker: updateCheckerJob,
|
||||
mediaTranscoding: mediaTranscodingJob,
|
||||
});
|
||||
|
||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const mediaTranscodingJob = createCronJob("mediaTranscoding", EVERY_5_MINUTES).withCallback(
|
||||
createRequestIntegrationJobHandler(mediaTranscodingRequestHandler.handler, {
|
||||
widgetKinds: ["mediaTranscoding"],
|
||||
getInput: {
|
||||
mediaTranscoding: () => ({ pageOffset: 0, pageSize: 10 }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -151,6 +151,13 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/dashdot.png",
|
||||
supportsSearch: false,
|
||||
},
|
||||
tdarr: {
|
||||
name: "Tdarr",
|
||||
secretKinds: [[]],
|
||||
category: ["mediaTranscoding"],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/tdarr.png",
|
||||
supportsSearch: false,
|
||||
},
|
||||
} as const satisfies Record<string, integrationDefinition>;
|
||||
|
||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||
@@ -217,4 +224,5 @@ export type IntegrationCategory =
|
||||
| "torrent"
|
||||
| "smartHomeServer"
|
||||
| "indexerManager"
|
||||
| "healthMonitoring";
|
||||
| "healthMonitoring"
|
||||
| "mediaTranscoding";
|
||||
|
||||
@@ -14,6 +14,7 @@ export const widgetKinds = [
|
||||
"downloads",
|
||||
"mediaRequests-requestList",
|
||||
"mediaRequests-requestStats",
|
||||
"mediaTranscoding",
|
||||
"rssFeed",
|
||||
"bookmarks",
|
||||
"indexerManager",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration"
|
||||
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
|
||||
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
|
||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
|
||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||
@@ -70,4 +71,5 @@ export const integrationCreators = {
|
||||
lidarr: LidarrIntegration,
|
||||
readarr: ReadarrIntegration,
|
||||
dashDot: DashDotIntegration,
|
||||
tdarr: TdarrIntegration,
|
||||
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
|
||||
|
||||
@@ -28,6 +28,9 @@ export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-moni
|
||||
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
||||
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request";
|
||||
export type { StreamSession } from "./interfaces/media-server/session";
|
||||
export type { TdarrQueue } from "./interfaces/media-transcoding/queue";
|
||||
export type { TdarrPieSegment, TdarrStatistics } from "./interfaces/media-transcoding/statistics";
|
||||
export type { TdarrWorker } from "./interfaces/media-transcoding/workers";
|
||||
|
||||
// Schemas
|
||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface 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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface TdarrPieSegment {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface 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[];
|
||||
}[];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface 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;
|
||||
}
|
||||
172
packages/integrations/src/media-transcoding/tdarr-integration.ts
Normal file
172
packages/integrations/src/media-transcoding/tdarr-integration.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import type { TdarrQueue } from "../interfaces/media-transcoding/queue";
|
||||
import type { TdarrStatistics } from "../interfaces/media-transcoding/statistics";
|
||||
import type { TdarrWorker } from "../interfaces/media-transcoding/workers";
|
||||
import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas";
|
||||
|
||||
export class TdarrIntegration extends Integration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const url = this.url("/api/v2/status");
|
||||
const response = await fetch(url);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Unexpected status code: ${response.status}`);
|
||||
}
|
||||
|
||||
await z.object({ status: z.string() }).parseAsync(await response.json());
|
||||
}
|
||||
|
||||
public async getStatisticsAsync(): Promise<TdarrStatistics> {
|
||||
const url = this.url("/api/v2/cruddb");
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
collection: "StatisticsJSONDB",
|
||||
mode: "getById",
|
||||
docID: "statistics",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const statisticsData = await getStatisticsSchema.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
totalFileCount: statisticsData.totalFileCount,
|
||||
totalTranscodeCount: statisticsData.totalTranscodeCount,
|
||||
totalHealthCheckCount: statisticsData.totalHealthCheckCount,
|
||||
failedTranscodeCount: statisticsData.table3Count,
|
||||
failedHealthCheckCount: statisticsData.table6Count,
|
||||
stagedTranscodeCount: statisticsData.table1Count,
|
||||
stagedHealthCheckCount: statisticsData.table4Count,
|
||||
pies: statisticsData.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],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
public async getWorkersAsync(): Promise<TdarrWorker[]> {
|
||||
const url = this.url("/api/v2/get-nodes");
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const nodesData = await getNodesResponseSchema.parseAsync(await response.json());
|
||||
const workers = Object.values(nodesData).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,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getQueueAsync(firstItemIndex: number, pageSize: number): Promise<TdarrQueue> {
|
||||
const transcodingQueue = await this.getTranscodingQueueAsync(firstItemIndex, pageSize);
|
||||
const healthChecks = await this.getHealthCheckDataAsync(firstItemIndex, pageSize, transcodingQueue.totalCount);
|
||||
|
||||
const combinedArray = [...transcodingQueue.array, ...healthChecks.array].slice(0, pageSize);
|
||||
return {
|
||||
array: combinedArray,
|
||||
totalCount: transcodingQueue.totalCount + healthChecks.totalCount,
|
||||
startIndex: firstItemIndex,
|
||||
endIndex: firstItemIndex + combinedArray.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
private async getTranscodingQueueAsync(firstItemIndex: number, pageSize: number) {
|
||||
const url = this.url("/api/v2/client/status-tables");
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
start: firstItemIndex,
|
||||
pageSize,
|
||||
filters: [],
|
||||
sorts: [],
|
||||
opts: { table: "table1" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const transcodesQueueData = await getStatusTableSchema.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
array: transcodesQueueData.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: transcodesQueueData.totalCount,
|
||||
startIndex: firstItemIndex,
|
||||
endIndex: firstItemIndex + transcodesQueueData.array.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
private async getHealthCheckDataAsync(firstItemIndex: number, pageSize: number, totalQueueCount: number) {
|
||||
const url = this.url("/api/v2/client/status-tables");
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
start: Math.max(firstItemIndex - totalQueueCount, 0),
|
||||
pageSize,
|
||||
filters: [],
|
||||
sorts: [],
|
||||
opts: {
|
||||
table: "table4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const healthCheckData = await getStatusTableSchema.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
array: healthCheckData.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,
|
||||
})),
|
||||
totalCount: healthCheckData.totalCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
export 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(),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
export 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(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export 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(),
|
||||
});
|
||||
@@ -31,7 +31,7 @@ const mapping: Record<OldmarrIntegrationType, IntegrationKind | null> = {
|
||||
readarr: "readarr",
|
||||
sabnzbd: "sabNzbd",
|
||||
sonarr: "sonarr",
|
||||
tdarr: null,
|
||||
tdarr: "tdarr",
|
||||
transmission: "transmission",
|
||||
plex: "plex",
|
||||
};
|
||||
|
||||
@@ -68,6 +68,7 @@ export const widgetKindMapping = {
|
||||
indexerManager: "indexer-manager",
|
||||
bookmarks: "bookmark",
|
||||
healthMonitoring: "health-monitoring",
|
||||
mediaTranscoding: "media-transcoding",
|
||||
} satisfies Record<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
|
||||
// Use null for widgets that did not exist in oldmarr
|
||||
// TODO: revert assignment so that only old widgets are needed in the object,
|
||||
|
||||
@@ -129,6 +129,10 @@ const optionMapping: OptionMapping = {
|
||||
fahrenheit: (oldOptions) => oldOptions.fahrenheit,
|
||||
fileSystem: (oldOptions) => oldOptions.fileSystem,
|
||||
},
|
||||
mediaTranscoding: {
|
||||
defaultView: (oldOptions) => oldOptions.defaultView,
|
||||
queuePageSize: (oldOptions) => oldOptions.queuePageSize,
|
||||
},
|
||||
app: null,
|
||||
};
|
||||
|
||||
|
||||
24
packages/request-handler/src/media-transcoding.ts
Normal file
24
packages/request-handler/src/media-transcoding.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { integrationCreator } from "@homarr/integrations";
|
||||
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
export const mediaTranscodingRequestHandler = createCachedIntegrationRequestHandler<
|
||||
{ queue: TdarrQueue; workers: TdarrWorker[]; statistics: TdarrStatistics },
|
||||
IntegrationKindByCategory<"mediaTranscoding">,
|
||||
{ pageOffset: number; pageSize: number }
|
||||
>({
|
||||
queryKey: "mediaTranscoding",
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = integrationCreator(integration);
|
||||
return {
|
||||
queue: await integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
|
||||
workers: await integrationInstance.getWorkersAsync(),
|
||||
statistics: await integrationInstance.getStatisticsAsync(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1643,6 +1643,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mediaTranscoding": {
|
||||
"name": "Media transcoding",
|
||||
"description": "Statistics, current queue and worker status of your media transcoding",
|
||||
"option": {
|
||||
"defaultView": {
|
||||
"label": "Default view"
|
||||
},
|
||||
"queuePageSize": {
|
||||
"label": "Queue page size"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"workers": "Workers",
|
||||
"queue": "Queue",
|
||||
"statistics": "Statistics"
|
||||
},
|
||||
"currentIndex": "{start}-{end} of {total}",
|
||||
"healthCheck": {
|
||||
"title": "Health check",
|
||||
"queued": "Queued",
|
||||
"status": {
|
||||
"healthy": "Healthy",
|
||||
"unhealthy": "Unhealthy"
|
||||
}
|
||||
},
|
||||
"panel": {
|
||||
"statistics": {
|
||||
"empty": "Empty",
|
||||
"transcodes": "Transcodes",
|
||||
"transcodesCount": "Transcodes: {value}",
|
||||
"healthChecksCount": "Health checks: {value}",
|
||||
"filesCount": "Files: {value}",
|
||||
"savedSpace": "Saved space: {value}",
|
||||
"healthChecks": "Health checks",
|
||||
"videoCodecs": "Codecs",
|
||||
"videoContainers": "Containers",
|
||||
"videoResolutions": "Resolutions"
|
||||
},
|
||||
"workers": {
|
||||
"empty": "Empty",
|
||||
"table": {
|
||||
"file": "File",
|
||||
"eta": "ETA",
|
||||
"progress": "Progress",
|
||||
"transcode": "Transcode",
|
||||
"healthCheck": "Health check"
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"empty": "Empty",
|
||||
"table": {
|
||||
"file": "File",
|
||||
"size": "Size",
|
||||
"transcode": "Transcode",
|
||||
"healthCheck": "Health check"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rssFeed": {
|
||||
"name": "RSS feeds",
|
||||
"description": "Monitor and display one or more generic RSS, ATOM or JSON feeds",
|
||||
@@ -2272,6 +2331,9 @@
|
||||
},
|
||||
"updateChecker": {
|
||||
"label": "Update checker"
|
||||
},
|
||||
"mediaTranscoding": {
|
||||
"label": "Media transcoding"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import * as indexerManager from "./indexer-manager";
|
||||
import * as mediaRequestsList from "./media-requests/list";
|
||||
import * as mediaRequestsStats from "./media-requests/stats";
|
||||
import * as mediaServer from "./media-server";
|
||||
import * as mediaTranscoding from "./media-transcoding";
|
||||
import * as notebook from "./notebook";
|
||||
import type { WidgetOptionDefinition } from "./options";
|
||||
import * as rssFeed from "./rssFeed";
|
||||
@@ -52,6 +53,7 @@ export const widgetImports = {
|
||||
bookmarks,
|
||||
indexerManager,
|
||||
healthMonitoring,
|
||||
mediaTranscoding,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
115
packages/widgets/src/media-transcoding/component.tsx
Normal file
115
packages/widgets/src/media-transcoding/component.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Center, Divider, Group, Pagination, SegmentedControl, 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 { WidgetComponentProps } from "../definition";
|
||||
import { HealthCheckStatus } from "./health-check-status";
|
||||
import { QueuePanel } from "./panels/queue.panel";
|
||||
import { StatisticsPanel } from "./panels/statistics.panel";
|
||||
import { WorkersPanel } from "./panels/workers.panel";
|
||||
|
||||
type Views = "workers" | "queue" | "statistics";
|
||||
|
||||
export default function MediaTranscodingWidget({ integrationIds, options }: WidgetComponentProps<"mediaTranscoding">) {
|
||||
const [queuePage, setQueuePage] = useState(1);
|
||||
const queuePageSize = 10;
|
||||
const [transcodingData] = clientApi.widget.mediaTranscoding.getDataAsync.useSuspenseQuery(
|
||||
{
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
pageSize: queuePageSize,
|
||||
page: queuePage,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
const [view, setView] = useState<Views>(options.defaultView);
|
||||
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
|
||||
|
||||
const t = useI18n("widget.mediaTranscoding");
|
||||
|
||||
return (
|
||||
<Stack gap={4} h="100%">
|
||||
{view === "workers" ? (
|
||||
<WorkersPanel workers={transcodingData.data.workers} />
|
||||
) : view === "queue" ? (
|
||||
<QueuePanel queue={transcodingData.data.queue} />
|
||||
) : (
|
||||
<StatisticsPanel statistics={transcodingData.data.statistics} />
|
||||
)}
|
||||
<Divider />
|
||||
<Group gap="xs" mb={4} ms={4} me={8}>
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{
|
||||
label: (
|
||||
<Center>
|
||||
<IconCpu2 size={18} />
|
||||
<Text size="xs" ml={8}>
|
||||
{t("tab.workers")}
|
||||
</Text>
|
||||
</Center>
|
||||
),
|
||||
value: "workers",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Center>
|
||||
<IconClipboardList size={18} />
|
||||
<Text size="xs" ml={8}>
|
||||
{t("tab.queue")}
|
||||
</Text>
|
||||
</Center>
|
||||
),
|
||||
value: "queue",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Center>
|
||||
<IconReportAnalytics size={18} />
|
||||
<Text size="xs" ml={8}>
|
||||
{t("tab.statistics")}
|
||||
</Text>
|
||||
</Center>
|
||||
),
|
||||
value: "statistics",
|
||||
},
|
||||
]}
|
||||
value={view}
|
||||
onChange={(value) => setView(value as Views)}
|
||||
size="xs"
|
||||
/>
|
||||
{view === "queue" && (
|
||||
<>
|
||||
<Pagination.Root total={totalQueuePages} value={queuePage} onChange={setQueuePage} size="sm">
|
||||
<Group gap={5} justify="center">
|
||||
<Pagination.First disabled={transcodingData.data.queue.startIndex === 1} />
|
||||
<Pagination.Previous disabled={transcodingData.data.queue.startIndex === 1} />
|
||||
<Pagination.Next disabled={transcodingData.data.queue.startIndex === totalQueuePages} />
|
||||
<Pagination.Last disabled={transcodingData.data.queue.startIndex === totalQueuePages} />
|
||||
</Group>
|
||||
</Pagination.Root>
|
||||
<Text size="xs">
|
||||
{t("currentIndex", {
|
||||
start: transcodingData.data.queue.startIndex + 1,
|
||||
end: transcodingData.data.queue.endIndex + 1,
|
||||
total: transcodingData.data.queue.totalCount,
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Group gap="xs" ml="auto">
|
||||
<HealthCheckStatus statistics={transcodingData.data.statistics} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Divider, Group, HoverCard, Indicator, 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";
|
||||
|
||||
interface HealthCheckStatusProps {
|
||||
statistics: TdarrStatistics;
|
||||
}
|
||||
|
||||
export function HealthCheckStatus(props: HealthCheckStatusProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const t = useI18n("widget.mediaTranscoding.healthCheck");
|
||||
|
||||
const indicatorColor = props.statistics.failedHealthCheckCount
|
||||
? "red"
|
||||
: props.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 gap="sm" align="center">
|
||||
<Group gap="xs">
|
||||
<IconHeartbeat size={18} />
|
||||
<Text size="sm">{t("title")}</Text>
|
||||
</Group>
|
||||
<Divider
|
||||
style={{
|
||||
alignSelf: "stretch",
|
||||
}}
|
||||
/>
|
||||
<RingProgress
|
||||
sections={[
|
||||
{ value: props.statistics.stagedHealthCheckCount, color: textColor("yellow", colorScheme) },
|
||||
{ value: props.statistics.totalHealthCheckCount, color: textColor("green", colorScheme) },
|
||||
{ value: props.statistics.failedHealthCheckCount, color: textColor("red", colorScheme) },
|
||||
]}
|
||||
/>
|
||||
<Group display="flex" w="100%">
|
||||
<Stack style={{ flex: 1 }} gap={0} align="center">
|
||||
<Text size="xs" c={textColor("yellow", colorScheme)}>
|
||||
{props.statistics.stagedHealthCheckCount}
|
||||
</Text>
|
||||
<Text size="xs">{t("queued")}</Text>
|
||||
</Stack>
|
||||
<Stack style={{ flex: 1 }} gap={0} align="center">
|
||||
<Text size="xs" c={textColor("green", colorScheme)}>
|
||||
{props.statistics.totalHealthCheckCount}
|
||||
</Text>
|
||||
<Text size="xs">{t("status.healthy")}</Text>
|
||||
</Stack>
|
||||
<Stack style={{ flex: 1 }} gap={0} align="center">
|
||||
<Text size="xs" c={textColor("red", colorScheme)}>
|
||||
{props.statistics.failedHealthCheckCount}
|
||||
</Text>
|
||||
<Text size="xs">{t("status.unhealthy")}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function textColor(color: MantineColor, theme: "light" | "dark") {
|
||||
return `${color}.${theme === "light" ? 8 : 5}`;
|
||||
}
|
||||
22
packages/widgets/src/media-transcoding/index.ts
Normal file
22
packages/widgets/src/media-transcoding/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
|
||||
icon: IconTransform,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
defaultView: factory.select({
|
||||
defaultValue: "statistics",
|
||||
options: [
|
||||
{ label: "Workers", value: "workers" },
|
||||
{ label: "Queue", value: "queue" },
|
||||
{ label: "Statistics", value: "statistics" },
|
||||
],
|
||||
}),
|
||||
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
|
||||
})),
|
||||
supportedIntegrations: ["tdarr"],
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Center, Group, ScrollArea, Table, Text, Title, Tooltip } from "@mantine/core";
|
||||
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import type { TdarrQueue } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface QueuePanelProps {
|
||||
queue: TdarrQueue;
|
||||
}
|
||||
|
||||
export function QueuePanel(props: QueuePanelProps) {
|
||||
const { queue } = props;
|
||||
|
||||
const t = useI18n("widget.mediaTranscoding.panel.queue");
|
||||
|
||||
if (queue.array.length === 0) {
|
||||
return (
|
||||
<Center style={{ flex: "1" }}>
|
||||
<Title order={3}>{t("empty")}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ flex: "1" }}>
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<Table.Thead>
|
||||
<tr>
|
||||
<th>{t("table.file")}</th>
|
||||
<th style={{ width: 80 }}>{t("table.size")}</th>
|
||||
</tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{queue.array.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<div>
|
||||
{item.type === "transcode" ? (
|
||||
<Tooltip label={t("table.transcode")}>
|
||||
<IconTransform size={14} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t("table.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>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type react from "react";
|
||||
import type { MantineColor, RingProgressProps } from "@mantine/core";
|
||||
import { Box, Center, Grid, Group, RingProgress, Stack, Text, Title, useMantineColorScheme } from "@mantine/core";
|
||||
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
const PIE_COLORS: MantineColor[] = ["cyan", "grape", "gray", "orange", "pink"];
|
||||
|
||||
interface StatisticsPanelProps {
|
||||
statistics: TdarrStatistics;
|
||||
}
|
||||
|
||||
export function StatisticsPanel(props: StatisticsPanelProps) {
|
||||
const t = useI18n("widget.mediaTranscoding.panel.statistics");
|
||||
|
||||
const allLibs = props.statistics.pies.find((pie) => pie.libraryName === "All");
|
||||
|
||||
if (!allLibs) {
|
||||
return (
|
||||
<Center style={{ flex: "1" }}>
|
||||
<Title order={3}>{t("empty")}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack style={{ flex: "1" }} gap="xs">
|
||||
<Group
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
justify="apart"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Stack align="center" gap={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.transcodeStatus)} />
|
||||
<Text size="xs">{t("transcodes")}</Text>
|
||||
</Stack>
|
||||
<Grid gutter="xs">
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconTransform size={18} />}
|
||||
label={t("transcodesCount", {
|
||||
value: props.statistics.totalTranscodeCount,
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconHeartbeat size={18} />}
|
||||
label={t("healthChecksCount", {
|
||||
value: props.statistics.totalHealthCheckCount,
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconFileDescription size={18} />}
|
||||
label={t("filesCount", {
|
||||
value: props.statistics.totalFileCount,
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconDatabaseHeart size={18} />}
|
||||
label={t("savedSpace", {
|
||||
value: humanFileSize(Math.floor(allLibs.savedSpace)),
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack align="center" gap={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.healthCheckStatus)} />
|
||||
<Text size="xs">{t("healthChecks")}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
w="100%"
|
||||
>
|
||||
<Stack align="center" gap={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoCodecs)} />
|
||||
<Text size="xs">{t("videoCodecs")}</Text>
|
||||
</Stack>
|
||||
<Stack align="center" gap={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoContainers)} />
|
||||
<Text size="xs">{t("videoContainers")}</Text>
|
||||
</Stack>
|
||||
<Stack align="center" gap={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoResolutions)} />
|
||||
<Text size="xs">{t("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}`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
color: PIE_COLORS[index % PIE_COLORS.length]!, // Ensures a valid color in the case that index > PIE_COLORS.length
|
||||
}));
|
||||
}
|
||||
|
||||
interface StatBoxProps {
|
||||
icon: react.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function StatBox(props: StatBoxProps) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
return (
|
||||
<Box
|
||||
style={(theme) => ({
|
||||
padding: theme.spacing.xs,
|
||||
border: "1px solid",
|
||||
borderRadius: theme.radius.md,
|
||||
borderColor: colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1],
|
||||
})}
|
||||
>
|
||||
<Stack gap="xs" align="center">
|
||||
{props.icon}
|
||||
<Text size="xs">{props.label}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Center, Group, Progress, ScrollArea, Table, Text, Title, Tooltip } from "@mantine/core";
|
||||
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import type { TdarrWorker } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface WorkersPanelProps {
|
||||
workers: TdarrWorker[];
|
||||
}
|
||||
|
||||
export function WorkersPanel(props: WorkersPanelProps) {
|
||||
const t = useI18n("widget.mediaTranscoding.panel.workers");
|
||||
|
||||
if (props.workers.length === 0) {
|
||||
return (
|
||||
<Center style={{ flex: "1" }}>
|
||||
<Title order={3}>{t("empty")}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ flex: "1" }}>
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<Table.Thead>
|
||||
<tr>
|
||||
<th>{t("table.file")}</th>
|
||||
<th style={{ width: 60 }}>{t("table.eta")}</th>
|
||||
<th style={{ width: 175 }}>{t("table.progress")}</th>
|
||||
</tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{props.workers.map((worker) => (
|
||||
<tr key={worker.id}>
|
||||
<td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<div>
|
||||
{worker.jobType === "transcode" ? (
|
||||
<Tooltip label={t("table.transcode")}>
|
||||
<IconTransform size={14} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t("table.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 wrap="nowrap" gap="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>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user