Compare commits

..
Author SHA1 Message Date
Thomas 4b5774ec74 Merge branch 'dev' into pr-container-images 2026-07-14 17:24:32 +02:00
ajnart 8d450c69d2 fix(ci): fork-safe cleanup, manual triggers, draft PR skipping
- cleanup: pull_request -> pull_request_target so fork PRs trigger cleanup
- cleanup: add workflow_dispatch for manual stale image deletion
- publish: skip fork PRs (no packages:write) and draft PRs
- publish: add ready_for_review trigger
- publish: add workflow_dispatch for manual fork PR builds
- publish: unified tag derivation step
2026-07-11 15:41:06 +02:00
ajnart 8514df985b fix(ci): use separate homarr-test package with pr-<N> tag 2026-07-11 13:21:20 +02:00
ajnart 28d7a75b63 fix(ci): use homarr-test-pr- tag prefix instead of pr- 2026-07-11 12:19:27 +02:00
ajnart 97a661d04f fix(ci): apply CodeRabbit review fixes
- Rename tag prefix from omar-test-pr- to pr-
- Add contents: read permission to publish workflow
- Disable persist-credentials on checkout step
2026-07-11 12:17:47 +02:00
ajnart 3a1bd4e969 style: auto-fix lint and format 2026-07-11 10:03:45 +00:00
ajnart d8f831093e feat(ci): add PR Docker preview image publish and cleanup workflows 2026-07-11 12:01:56 +02:00
65 changed files with 890 additions and 1032 deletions

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ jobs:
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
persist-credentials: false
- name: Setup
uses: ./tooling/github/setup
@@ -0,0 +1,64 @@
name: "[PR] Cleanup Docker preview image"
on:
pull_request_target:
types: [closed]
branches:
- dev
workflow_dispatch:
inputs:
pr-number:
description: "PR number whose preview image to delete"
required: true
type: number
permissions:
packages: write
env:
REGISTRY: ghcr.io
IMAGE_TAG: pr-${{ github.event.inputs.pr-number || github.event.number }}
jobs:
delete-image:
name: Delete PR preview image from GHCR
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Delete package version
uses: actions/github-script@v8
with:
script: |
const packageName = 'homarr-test';
const org = context.repo.owner;
const tag = process.env.IMAGE_TAG;
const versions = await github.paginate(
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
{
package_type: 'container',
package_name: packageName,
org,
per_page: 100,
}
);
const target = versions.find(v =>
v.metadata?.container?.tags?.includes(tag)
);
if (!target) {
console.log(`No package version found with tag "${tag}". Nothing to delete.`);
return;
}
console.log(`Deleting package version ${target.id} (tag: ${tag})`);
await github.rest.packages.deletePackageVersionForOrg({
package_type: 'container',
package_name: packageName,
org,
package_version_id: target.id,
});
console.log(`Successfully deleted package version ${target.id}`);
@@ -0,0 +1,89 @@
name: "[PR] Publish Docker preview image"
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches:
- dev
workflow_dispatch:
inputs:
pr-number:
description: "PR number (uses head ref of that PR)"
required: true
type: number
ref:
description: "Git ref to build (branch, tag, or SHA)"
required: false
type: string
default: ""
permissions:
contents: read
packages: write
env:
SKIP_ENV_VALIDATION: true
REGISTRY: ghcr.io
GHCR_REPO: ghcr.io/homarr-labs/homarr-test
TURBO_TELEMETRY_DISABLED: 1
concurrency:
group: pr-docker-${{ github.event.inputs.pr-number || github.event.number }}
cancel-in-progress: true
jobs:
build-and-push:
name: Build and push PR preview image
runs-on: ubuntu-latest
timeout-minutes: 25
if: |
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request' &&
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository
)
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref || '' }}
- name: Determine tag
id: tag
run: |
PR_NUMBER="${{ github.event.inputs.pr-number || github.event.number }}"
echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "pr-number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: "${{ env.GHCR_REPO }}"
tags: |
type=raw,value=${{ steps.tag.outputs.tag }}
- name: Log in to the Container registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
network: host
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
@@ -1,44 +1,41 @@
---
title: "Media Request List"
description: "See a list of all media requests from your integration"
title: 'Media Request List'
description: 'See a list of all media requests from your integration'
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { IconAd } from "@tabler/icons-react";
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import { mediaRequestListWidget } from '.';
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
import { overseerrIntegration } from '@site/docs/integrations/overseerr';
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
<WidgetHeader
widget={mediaRequestListWidget}
categories={['Media requests']}
/>
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
### Screenshots
import mediaRequestList from "./img/list.png";
import mediaRequestList from './img/list.png';
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: jellyseerrIntegration,
},
{
integration: overseerrIntegration,
},
]}
/>
<WidgetIntegrations items={[{
integration: jellyseerrIntegration,
}, {
integration: overseerrIntegration,
}]} />
### Interactions
@@ -50,5 +47,4 @@ import mediaRequestList from "./img/list.png";
<AddingWidget />
### Configuration
<WidgetConfig configuration={mediaRequestListWidget.configuration} />
@@ -14,18 +14,6 @@ export const mediaRequestListWidget: WidgetDefinition = {
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Statuses to show",
description: "Only requests with these statuses will be shown. Defaults to all statuses.",
values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'",
defaultValue: "All statuses",
},
{
name: "Show only recent requests",
description: "Limit to requests created within this many days. Set to 0 to disable the time filter.",
values: "0-365",
defaultValue: "0",
},
],
},
};
+1 -1
View File
@@ -43,7 +43,7 @@
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^14.0.5",
"swiper": "^14.0.2",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
@@ -2,7 +2,6 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
@@ -34,7 +33,6 @@ import {
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets";
import { createWidgetQueryPersister } from "./query-cache-persister";
@@ -92,13 +90,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
},
});
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
for (const { definition } of Object.values(widgetImports)) {
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
if (def.refetchInterval === undefined) continue;
const key = def.queryKey ?? [["widget", def.kind]];
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
client.setQueryDefaults(key, { refetchInterval: interval });
}
return client;
});
@@ -1,17 +1,9 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -19,12 +11,10 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -242,14 +232,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group justify="space-between" wrap="nowrap">
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
queryKey={widgetQueryKey}
isFetching={isWidgetFetching}
t={t}
/>
<Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
</Text>
</Group>
</Menu.Item>
<Menu.Item
@@ -286,65 +273,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
};
interface WidgetQueryStatusProps {
const WidgetCacheAge = ({
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient;
queryKey: QueryKey;
isFetching: boolean;
t: TranslationFunction;
}
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
const queries = queryClient.getQueryCache().findAll({ queryKey });
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
const ageLabel = useTimeAgo(new Date(latest));
if (isFetching) return <Loader size={12} />;
if (isFetching) {
return (
<Group gap={4} wrap="nowrap">
<Loader size={12} />
<Text size="xs" c="dimmed">
{t("item.menu.status.loading")}
</Text>
</Group>
);
}
const timestamps = queryClient
.getQueryCache()
.findAll({ queryKey })
.map((q) => q.state.dataUpdatedAt)
.filter(Boolean);
if (timestamps.length === 0) return null;
if (queries.length === 0) {
return (
<Text size="xs" c="dimmed">
{t("item.menu.status.idle")}
</Text>
);
}
const hasError = queries.some((q) => q.state.status === "error");
if (hasError) {
const errorQuery = queries.find((q) => q.state.status === "error");
const errorMessage =
errorQuery?.state.error instanceof Error
? errorQuery.state.error.message
: String(errorQuery?.state.error ?? "Unknown error");
return (
<Tooltip label={errorMessage} multiline position="left" w={250}>
<Group gap={4} wrap="nowrap">
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red.6">
{t("item.menu.status.error")} · {ageLabel}
</Text>
</Group>
</Tooltip>
);
}
return (
<Group gap={4} wrap="nowrap">
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed">
{t("item.menu.status.success")} · {ageLabel}
</Text>
</Group>
);
const latest = Math.max(...timestamps);
const seconds = Math.floor((Date.now() - latest) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
};
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.11.0"
"pnpm": ">=11.10.0"
},
"packageManager": "pnpm@11.11.0"
"packageManager": "pnpm@11.10.0"
}
+2 -2
View File
@@ -69,7 +69,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.11.0"
"pnpm": ">=11.10.0"
},
"packageManager": "pnpm@11.11.0"
"packageManager": "pnpm@11.10.0"
}
+2 -12
View File
@@ -22,20 +22,10 @@ export const downloadsRouter = createTRPCRouter({
},
})
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(
z.object({
limitPerIntegration: z.number().default(50),
qbittorrentSort: z.string().optional(),
qbittorrentSortReverse: z.boolean().optional(),
}),
)
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, {
limit: input.limitPerIntegration,
qbittorrentSort: input.qbittorrentSort,
qbittorrentSortReverse: input.qbittorrentSortReverse,
});
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
@@ -3,10 +3,7 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import {
mediaRequestListInputSchema,
mediaRequestListRequestHandler,
} from "@homarr/request-handler/media-request-list";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -19,14 +16,13 @@ export const mediaRequestsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
return results
+4 -7
View File
@@ -3,7 +3,6 @@ import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions";
@@ -12,8 +11,6 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const logger = createLogger({ module: "releasesRouter" });
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
@@ -74,13 +71,13 @@ export const releasesRouter = createTRPCRouter({
for (const secret of secrets) {
try {
tokensByProvider.set(secret.kind, decryptSecret(secret.value));
} catch (error) {
logger.warn("Failed to decrypt widget secret", { itemId: input.itemId, kind: secret.kind, error });
} catch {
// Skip corrupt secrets
}
}
}
} catch (error) {
logger.warn("Failed to load release widget tokens", { itemId: input.itemId, error });
} catch {
// Access denied or table not yet migrated -- proceed without tokens
}
}
@@ -27,18 +27,10 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
};
}
public async getClientJobsAndStatusAsync(input: {
limit: number;
qbittorrentSort?: string;
qbittorrentSortReverse?: boolean;
}): Promise<DownloadClientJobsAndStatus> {
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = await this.getClientAsync();
const torrents = await client.listTorrents({
limit: input.limit,
sort: input.qbittorrentSort,
reverse: input.qbittorrentSortReverse,
});
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 },
@@ -117,12 +117,6 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null,
};
}
@@ -3,11 +3,7 @@ import type { DownloadClientItem } from "./download-client-items";
export interface IDownloadClientIntegration {
/** Get download client's status and list of all of it's items */
getClientJobsAndStatusAsync(input: {
limit: number;
qbittorrentSort?: string;
qbittorrentSortReverse?: boolean;
}): Promise<DownloadClientJobsAndStatus>;
getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus>;
/** Pauses the client or all of it's items */
pauseQueueAsync(): Promise<void>;
/** Pause a single item using it's ID */
@@ -13,12 +13,6 @@ export interface StreamSession {
episodeName?: string | null;
albumName?: string | null;
episodeCount?: number | null;
playback: {
state: "playing" | "paused" | "buffering" | null;
positionMs: number | null;
durationMs: number | null;
};
location: "lan" | "wan" | null;
metadata: {
video: {
resolution: {
@@ -17,10 +17,6 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
function ticksToMs(ticks: number | null | undefined): number | null {
return ticks ? Math.round(ticks / 10_000) : null;
}
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({
@@ -54,9 +50,6 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
@@ -64,12 +57,6 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing",
positionMs,
durationMs,
},
location: null,
metadata: {
video: {
resolution:
@@ -13,8 +13,6 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -29,8 +27,6 @@ const sessions: StreamSession[] = [
episodeName: "Ozymandias",
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -45,8 +41,6 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -61,8 +55,6 @@ const sessions: StreamSession[] = [
episodeName: "The We We Are",
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -77,8 +69,6 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: "Random Access Memories",
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -93,8 +83,6 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -153,9 +153,9 @@ export class OverseerrIntegration
);
} else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults;
else return [];
else return Promise.all([]);
const settled = await Promise.allSettled(
return await Promise.all(
requests.map(async (request): Promise<MediaRequest> => {
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
@@ -184,10 +184,6 @@ export class OverseerrIntegration
};
}),
);
return settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value);
}
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -305,10 +301,6 @@ export class OverseerrIntegration
},
});
if (!response.ok) {
throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`);
}
if (type === "tv") {
const series = (await response.json()) as TvInformation;
return {
@@ -3,54 +3,6 @@ interface MediaContainer {
Track?: Session[];
}
interface Stream {
$: {
streamType: string;
codec?: string;
language?: string;
selected?: string;
width?: string;
height?: string;
frameRate?: string;
channels?: string;
};
}
interface Part {
Stream?: Stream[];
$: {
container?: string;
};
}
interface Media {
Part?: Part[];
$: {
videoResolution?: string;
videoCodec?: string;
audioCodec?: string;
bitrate?: string;
container?: string;
};
}
interface TranscodeSession {
$: {
throttled?: string;
progress?: string;
speed?: string;
videoDecision?: string;
audioDecision?: string;
sourceVideoCodec?: string;
sourceAudioCodec?: string;
videoCodec?: string;
audioCodec?: string;
container?: string;
width?: string;
height?: string;
};
}
interface Session {
User?: {
$: {
@@ -63,20 +15,13 @@ interface Session {
$: {
product: string;
title: string;
address?: string;
state?: string;
local?: string;
};
}[];
Session?: {
$: {
id: string;
bandwidth?: string;
location?: string;
};
}[];
TranscodeSession?: TranscodeSession[];
Media?: Media[];
$: {
grandparentTitle?: string;
parentTitle?: string;
@@ -84,8 +29,6 @@ interface Session {
index?: number;
type: string;
live?: string;
viewOffset?: string;
duration?: string;
};
}
@@ -17,32 +17,6 @@ import type { PlexResponse } from "./interface";
const logger = createLogger({ module: "plexIntegration" });
function parseOptionalNumber(value: string | undefined): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
function parseLocation(value: string | undefined): "lan" | "wan" | null {
return value === "lan" || value === "wan" ? value : null;
}
function parsePlaybackState(value: string | undefined): "playing" | "paused" | "buffering" | null {
return value === "playing" || value === "paused" || value === "buffering" ? value : null;
}
function parseResolution(
width: string | undefined,
height: string | undefined,
): { width: number; height: number } | null {
const parsedWidth = parseOptionalNumber(width);
const parsedHeight = parseOptionalNumber(height);
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
}
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey");
@@ -69,24 +43,11 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const userElement = mediaElement.User ? mediaElement.User[0] : undefined;
const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined;
const sessionElement = mediaElement.Session ? mediaElement.Session[0] : undefined;
const transcodeElement = mediaElement.TranscodeSession ? mediaElement.TranscodeSession[0] : undefined;
const mediaInfoElement = mediaElement.Media ? mediaElement.Media[0] : undefined;
if (!playerElement) {
return undefined;
}
const positionMs = parseOptionalNumber(mediaElement.$.viewOffset);
const durationMs = parseOptionalNumber(mediaElement.$.duration);
const playbackState = parsePlaybackState(playerElement.$.state);
const location = parseLocation(sessionElement?.$.location);
const streams = mediaInfoElement?.Part?.[0]?.Stream ?? [];
const videoStream = streams.find((stream) => stream.$.streamType === "1");
const audioStream = streams.find((stream) => stream.$.streamType === "2");
return {
sessionId: sessionElement?.$.id ?? "unknown",
sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
@@ -102,30 +63,7 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
episodeName: mediaElement.$.title ?? null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: mediaElement.$.index ?? null,
playback: {
state: playbackState,
positionMs,
durationMs,
},
location,
metadata: {
video: {
resolution: parseResolution(videoStream?.$.width, videoStream?.$.height),
frameRate: parseOptionalNumber(videoStream?.$.frameRate),
},
audio: {
channelCount: parseOptionalNumber(audioStream?.$.channels),
codec: audioStream?.$.codec ?? mediaInfoElement?.$.audioCodec ?? null,
},
transcoding: {
container: transcodeElement?.$.container ?? null,
resolution: parseResolution(transcodeElement?.$.width, transcodeElement?.$.height),
target: {
audioCodec: transcodeElement?.$.audioCodec ?? null,
videoCodec: transcodeElement?.$.videoCodec ?? null,
},
},
},
metadata: null,
},
};
})
@@ -23,8 +23,6 @@ type OptionMapping = {
const optionMapping: OptionMapping = {
"mediaRequests-requestList": {
linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab,
statusFilter: () => undefined,
recentDays: () => undefined,
},
"mediaRequests-requestStats": {},
bookmarks: {
+17 -4
View File
@@ -300,7 +300,10 @@ async function getContainersWithStatsAsync() {
const stats = await instance
.getContainer(container.Id)
.stats({ stream: false, "one-shot": true })
// Not in one-shot mode: the daemon then includes precpu_stats (the
// previous reading), which is required to compute the current CPU usage
// as a delta. One-shot omits it and would only expose lifetime totals.
.stats({ stream: false })
.catch(
() =>
({
@@ -336,12 +339,22 @@ export function calculateCpuUsage(stats: ContainerStats): number {
}
const numberOfCpus = stats.cpu_stats.online_cpus;
const usage = stats.cpu_stats.system_cpu_usage;
if (!usage || usage === 0) {
// Docker's own formula for the current (instantaneous) CPU usage: it compares
// the latest reading against the previous one (precpu_stats). Dividing the
// cumulative total_usage by the cumulative system_cpu_usage instead would
// report the container's lifetime average, which stays high long after a load
// spike has ended. precpu_stats is only present when the stats are NOT fetched
// in one-shot mode; when it is absent (e.g. Podman) the deltas fall back to the
// cumulative totals, matching the previous behaviour.
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - (stats.precpu_stats?.system_cpu_usage ?? 0);
if (systemDelta <= 0 || cpuDelta < 0) {
return 0;
}
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
return (cpuDelta / systemDelta) * numberOfCpus * 100;
}
export function calculateMemoryUsage(stats: ContainerStats): number {
+1 -1
View File
@@ -7,7 +7,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
export const downloadClientRequestHandler = createIntegrationRequestHandler<
DownloadClientJobsAndStatus,
IntegrationKindByCategory<"downloadClient">,
{ limit: number; qbittorrentSort?: string; qbittorrentSortReverse?: boolean }
{ limit: number }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
@@ -13,7 +13,6 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
}
export const createIntegrationRequestHandler = <
@@ -33,7 +32,6 @@ export const createIntegrationRequestHandler = <
return options.requestAsync(integration, input.options);
},
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
});
return {
@@ -1,7 +1,6 @@
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
@@ -36,6 +35,7 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached) cache.delete(key);
const existing = inflight.get(key);
if (existing) return existing;
@@ -55,9 +55,6 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
})
.catch((err) => {
inflight.delete(key);
if (options.fallbackToStaleOnError && cached) {
return cached;
}
throw err;
});
@@ -1,44 +1,16 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest, MediaRequestStatus } from "@homarr/integrations/types";
import type { MediaRequest } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const mediaRequestStatusValues = [
"pending",
"approved",
"declined",
"failed",
"completed",
] as const satisfies readonly MediaRequestStatus[];
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export const mediaRequestListInputSchema = z.object({
statuses: z.array(z.enum(mediaRequestStatusValues)).nonempty(),
recentDays: z.number().min(0).max(365),
});
export type MediaRequestListInput = z.infer<typeof mediaRequestListInputSchema>;
export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
MediaRequest[],
IntegrationKindByCategory<"mediaRequest">,
MediaRequestListInput
Record<string, never>
>({
async requestAsync(integration, input) {
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
const requests = await integrationInstance.getRequestsAsync();
const cutoff = input.recentDays > 0 ? Date.now() - input.recentDays * MS_PER_DAY : null;
const statusSet = new Set<MediaRequestStatus>(input.statuses);
return requests.filter((request) => {
if (!statusSet.has(request.status)) return false;
if (cutoff !== null && request.createdAt.getTime() < cutoff) return false;
return true;
});
return await integrationInstance.getRequestsAsync();
},
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
});
@@ -134,33 +134,7 @@ const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
baseUrl,
auth: token,
request: { fetch: fetchWithTrustedCertificatesAsync },
throttle: {
enabled: true,
onRateLimit: (
retryAfter: number,
options: { url?: string; method?: string },
_octokit: unknown,
retryCount: number,
) => {
logger.warn(`GitHub rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
url: options.url,
method: options.method,
});
return retryCount < 1;
},
onSecondaryRateLimit: (
retryAfter: number,
options: { url?: string; method?: string },
_octokit: unknown,
retryCount: number,
) => {
logger.warn(`GitHub secondary rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
url: options.url,
method: options.method,
});
return retryCount < 1;
},
},
throttle: { enabled: true },
userAgent,
});
@@ -75,6 +75,26 @@ describe("calculateCpuUsage", () => {
// (500 / 100000) * 2 * 100 = 1
expect(calculateCpuUsage(stats)).toBe(1);
});
test("should use the delta against precpu_stats for the current usage", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_002_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// Despite a large lifetime total_usage, only the recent delta counts:
// (2000 / 10000) * 4 * 100 = 80
expect(calculateCpuUsage(stats)).toBe(80);
});
test("should report 0 for a container that was busy over its lifetime but is idle now", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// No CPU delta since the last reading -> current usage is 0, even though the
// lifetime average would be high. This is the regression the fix addresses.
expect(calculateCpuUsage(stats)).toBe(0);
});
});
describe("calculateMemoryUsage", () => {
+1 -7
View File
@@ -1598,16 +1598,10 @@
},
"menu": {
"label": {
"settings": "",
"settings": "Settings",
"refresh": "",
"options": "",
"actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
}
},
"create": {
+2 -39
View File
@@ -1622,12 +1622,6 @@
"refresh": "Refresh",
"options": "Options",
"actions": "Actions"
},
"status": {
"success": "OK",
"error": "Error",
"loading": "Loading",
"idle": "Idle"
}
},
"create": {
@@ -2717,11 +2711,6 @@
"user": "User",
"name": "Name",
"id": "Id",
"status": "Status",
"directPlay": "Direct play",
"transcoding": "Transcoding",
"paused": "Paused",
"remaining": "{minutes} min left",
"metadata": {
"title": "Stats for nerds",
"video": {
@@ -3064,28 +3053,8 @@
"limitPerIntegration": {
"label": "Limit items per integration",
"description": "This will limit the number of items shown per integration, not globally"
},
"qbittorrentSort": {
"label": "qBittorrent sort order",
"option": {
"added_on": "Date Added",
"completion_on": "Completion Date",
"dlspeed": "Download Speed",
"eta": "ETA",
"name": "Name",
"priority": "Priority",
"progress": "Progress",
"ratio": "Ratio",
"size": "Size",
"state": "State",
"time_active": "Time Active",
"upspeed": "Upload Speed",
"uploaded": "Uploaded"
}
},
"qbittorrentSortReverse": {
"label": "Reverse qBittorrent sort order"
},
}
},
"errors": {
"noColumns": "Select Columns in Items",
"noCommunications": "Can't load data from integration"
@@ -3226,12 +3195,6 @@
"option": {
"linksTargetNewTab": {
"label": "Open links in new tab"
},
"statusFilter": {
"label": "Statuses to show"
},
"recentDays": {
"label": "Show only recent requests"
}
},
"pending": {
-1
View File
@@ -70,7 +70,6 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition {
icon: TablerIcon;
queryKey?: QueryKey;
refetchInterval?: number | null;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
maxIntegrations?: number;
@@ -9,8 +9,6 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({
@@ -9,8 +9,6 @@ export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({
+10 -2
View File
@@ -184,7 +184,13 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const t = useScopedI18n("docker");
const isTiny = width <= 256;
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
const {
data,
refetch,
isFetching,
} = clientApi.docker.getContainers.useQuery(undefined, {
refetchInterval: 30_000,
});
const containers = data?.containers ?? [];
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
@@ -298,7 +304,9 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<Tooltip
label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<ActionIcon
size="sm"
variant="transparent"
@@ -91,8 +91,6 @@ export default function DownloadClientsWidget({
const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
integrationIds,
limitPerIntegration: options.limitPerIntegration,
qbittorrentSort: options.qbittorrentSort,
qbittorrentSortReverse: options.qbittorrentSortReverse,
});
const t = useScopedI18n("widget.downloads");
-33
View File
@@ -33,7 +33,6 @@ const columnsSort = columnsList.filter((column) =>
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
(factory) => ({
@@ -88,30 +87,6 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
validate: z.number().min(1),
withDescription: true,
}),
qbittorrentSort: factory.select({
defaultValue: "added_on",
options: [
"added_on",
"completion_on",
"dlspeed",
"eta",
"name",
"priority",
"progress",
"ratio",
"size",
"state",
"time_active",
"upspeed",
"uploaded",
].map((value) => ({
value,
label: (t) => t(`widget.downloads.option.qbittorrentSort.option.${value}`),
})),
}),
qbittorrentSortReverse: factory.switch({
defaultValue: false,
}),
}),
{
defaultSort: {
@@ -140,14 +115,6 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
qbittorrentSort: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
qbittorrentSortReverse: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
},
);
},
-1
View File
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(() => ({}));
},
@@ -8,7 +8,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
(factory) => ({
@@ -8,8 +8,6 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto,
queryKey: [["widget", "immich"]],
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
createOptions() {
@@ -5,8 +5,6 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", {
icon: IconGraphFilled,
queryKey: [["widget", "immich"]],
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
createOptions() {
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch,
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({
@@ -5,8 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", {
icon: IconTicket,
queryKey: [["widget", "mediaRelease"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
layout: factory.select({
@@ -38,11 +38,6 @@ export default function MediaServerWidget({
}: WidgetComponentProps<"mediaRequests-requestList">) {
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
? options.statusFilter
: ["pending", "approved", "declined", "failed", "completed"],
recentDays: options.recentDays,
});
if (!mediaRequests) return <WidgetEmptyState />;
@@ -1,5 +1,4 @@
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
@@ -7,8 +6,6 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion,
createOptions() {
@@ -16,17 +13,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
linksTargetNewTab: factory.switch({
defaultValue: true,
}),
statusFilter: factory.multiSelect({
defaultValue: [...mediaRequestStatusValues],
options: mediaRequestStatusValues.map((value) => ({
value,
label: (t) => t(`widget.mediaRequests-requestList.status.${value}`),
})),
}),
recentDays: factory.number({
validate: z.number().min(0).max(365),
defaultValue: 0,
}),
}));
},
contextActions: ({ integrationIds }) => [
+26 -108
View File
@@ -2,16 +2,8 @@
import type { ReactNode } from "react";
import { Fragment, useMemo } from "react";
import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core";
import {
IconDeviceTv,
IconHeadphones,
IconMovie,
IconPlayerPause,
IconVideo,
IconWifi,
IconWorld,
} from "@tabler/icons-react";
import { Avatar, Divider, Flex, Group, Stack, Text, Title } from "@mantine/core";
import { IconDeviceTv, IconHeadphones, IconMovie, IconVideo } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
@@ -36,25 +28,23 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
() => [
{
accessorKey: "user.username",
header: t("items.user"),
size: 160,
accessorKey: "sessionName",
header: t("items.name"),
Cell: ({ row }) => (
<Group gap="xs" wrap="nowrap">
<Avatar size={28} src={row.original.user.profilePictureUrl} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.original.user.username}
</Text>
<Text
size="10px"
c="dimmed"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{row.original.sessionName}
</Text>
</Stack>
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{row.original.sessionName}
</Text>
),
},
{
accessorKey: "user.username",
header: t("items.user"),
Cell: ({ row }) => (
<Group gap="xs">
<Avatar size={20} src={row.original.user.profilePictureUrl} />
<Text size="xs">{row.original.user.username}</Text>
</Group>
),
},
@@ -63,85 +53,17 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
header: t("items.currentlyPlaying"),
Cell: ({ row }) => {
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
if (!row.original.currentlyPlaying) return null;
const isPaused = currentlyPlaying.playback.state === "paused";
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
const { positionMs, durationMs } = currentlyPlaying.playback;
const progressPercent =
positionMs !== null && durationMs !== null && durationMs > 0
? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null;
const remainingMinutes =
positionMs !== null && durationMs !== null
? Math.max(0, Math.round((durationMs - positionMs) / 60_000))
: null;
const Icon = mediaTypeIconMap[row.original.currentlyPlaying.type];
return (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
<Icon
size={16}
color={isPaused ? "var(--mantine-color-yellow-6)" : undefined}
style={{ flexShrink: 0 }}
/>
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
{currentlyPlaying.name}
</Text>
{isPaused && (
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
{t("items.paused")}
</Text>
)}
{!isPaused && remainingMinutes !== null && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{t("items.remaining", { minutes: remainingMinutes.toString() })}
</Text>
)}
</Group>
{progressPercent !== null && (
<Progress
value={progressPercent}
size={4}
color={isPaused ? "yellow" : "green"}
style={{ backgroundColor: "rgba(255, 255, 255, 0.15)" }}
/>
)}
</Stack>
);
},
},
{
id: "status",
header: t("items.status"),
size: 110,
Cell: ({ row }) => {
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const isTranscoding = Boolean(
currentlyPlaying.metadata?.transcoding.target.videoCodec ??
currentlyPlaying.metadata?.transcoding.target.audioCodec ??
currentlyPlaying.metadata?.transcoding.container,
);
return (
<Stack gap={4} align="flex-start">
<Badge size="xs" variant="light" color={isTranscoding ? "orange" : "green"}>
{isTranscoding ? t("items.transcoding") : t("items.directPlay")}
</Badge>
{currentlyPlaying.location && (
<Group gap={4} align="center">
{currentlyPlaying.location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />}
<Text size="10px" c="dimmed" tt="uppercase">
{currentlyPlaying.location}
</Text>
</Group>
)}
</Stack>
<Group gap="xs" align="center">
<Icon size={16} />
<Text size="xs" lineClamp={1}>
{row.original.currentlyPlaying.name}
</Text>
</Group>
);
},
},
@@ -185,7 +107,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
initialState: {
density: "xs",
columnPinning: {
right: ["currentlyPlaying", "status"],
right: ["currentlyPlaying"],
},
},
mantineTableHeadProps: {
@@ -222,10 +144,6 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
);
},
py: 4,
style: {
overflowX: "hidden",
overflowY: "visible",
},
}),
});
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
@@ -11,7 +11,6 @@ export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform,
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
defaultView: factory.select({
@@ -6,8 +6,6 @@ import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
icon: IconBrandMinecraft,
queryKey: [["widget", "minecraft"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text({ defaultValue: "" }),
@@ -7,8 +7,6 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("networkControllerStatus", {
icon: IconTopologyFull,
queryKey: [["widget", "networkController"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
content: factory.select({
@@ -7,8 +7,6 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", {
icon: IconTopologyFull,
queryKey: [["widget", "networkController"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from(() => ({}));
},
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
icon: IconMessage,
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
hideLogos: factory.switch({ defaultValue: false }),
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
icon: IconFileText,
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"],
integrationsRequired: true,
createOptions() {
-1
View File
@@ -14,7 +14,6 @@ const relativeDateSchema = z
export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket,
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
newReleaseWithin: factory.text({
-1
View File
@@ -12,7 +12,6 @@ import { optionsBuilder } from "../options";
*/
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss,
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
feedUrls: factory.multiText({
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat,
refetchInterval: null,
createOptions() {
return optionsBuilder.from(
(factory) => ({
-1
View File
@@ -13,7 +13,6 @@ const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
icon: IconBuildingBank,
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
stock: factory.text({
@@ -6,8 +6,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
icon: IconServer2,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
createOptions() {
return optionsBuilder.from(
@@ -14,8 +14,6 @@ const labelDisplayModeOptions = {
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
icon: IconGraphFilled,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
createOptions() {
return optionsBuilder.from((factory) => ({
-1
View File
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("tracearr", {
icon: IconActivityHeartbeat,
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
showStreams: factory.switch({
-1
View File
@@ -8,7 +8,6 @@ export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth
export const { definition, componentLoader } = createWidgetDefinition("umami", {
icon: IconChartBar,
refetchInterval: null,
supportedIntegrations: ["umami"],
createOptions() {
return optionsBuilder.from(
-1
View File
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("vpn", {
icon: IconShieldLock,
refetchInterval: null,
createOptions() {
return optionsBuilder.from(() => ({}));
},
+575 -375
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -18,7 +18,7 @@ catalog:
"@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0
"@homarr/node-unifi": ^2.6.0
"@immich/sdk": ^3.0.2
"@immich/sdk": ^3.0.1
"@jellyfin/sdk": ^0.13.0
"@kubernetes/client-node": ^1.4.0
"@modelcontextprotocol/sdk": 1.29.0
@@ -93,7 +93,7 @@ catalog:
"@types/video.js": ^7.3.58
"@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14
posthog-js: ^1.399.1
posthog-js: ^1.399.0
posthog-node: ^5.40.0
"@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.10
@@ -139,7 +139,7 @@ catalog:
jsonpath-plus: ^10.4.0
ldapts: 8.1.8
lodash.clonedeep: ^4.5.0
lucide-react: ^1.24.0
lucide-react: ^1.23.0
"mantine-contextmenu": ^9.3.1
mantine-datatable: ^9.3.1
mantine-react-table: npm:mantine-react-table-open@9.0.3
@@ -177,7 +177,7 @@ catalog:
tsdav: ^2.3.0
tsx: 4.20.4
turbo: ^2.10.4
typescript: "npm:@typescript/typescript6@^6.0.2"
typescript: ^7.0.2
vite: ^8.1.4
undici: 7.28.0
use-deep-compare-effect: ^1.8.1