Compare commits

..
107 changed files with 331 additions and 7908 deletions

No files matched your search

+90 -7
View File
@@ -60,7 +60,7 @@ jobs:
TURBO_PLATFORM: linux/amd64 TURBO_PLATFORM: linux/amd64
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
if: "!env.ACT" if: '!env.ACT'
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@@ -81,6 +81,9 @@ jobs:
- name: Copy environment defaults - name: Copy environment defaults
run: cp .env.example .env run: cp .env.example .env
- name: Check formatting in affected workspaces
run: pnpm turbo run format --affected --continue -- --check
- name: Lint, typecheck, and build affected workspaces - name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
@@ -90,8 +93,12 @@ jobs:
- name: Run unit tests - name: Run unit tests
run: pnpm test run: pnpm test
- name: Report coverage
if: always() && !env.ACT
uses: davelosert/vitest-coverage-report-action@v2
container: container:
name: Container and E2E name: Container, E2E, and memory
needs: fast-gate needs: fast-gate
if: | if: |
github.event_name == 'merge_group' || github.event_name == 'merge_group' ||
@@ -104,7 +111,7 @@ jobs:
packages: write packages: write
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
if: "!env.ACT" if: '!env.ACT'
with: with:
persist-credentials: false persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
@@ -113,11 +120,11 @@ jobs:
uses: ./tooling/github/setup uses: ./tooling/github/setup
- name: Set up Docker Buildx - name: Set up Docker Buildx
if: "!env.ACT" if: '!env.ACT'
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: Build container once - name: Build container once
if: "!env.ACT" if: '!env.ACT'
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
context: . context: .
@@ -158,7 +165,7 @@ jobs:
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" . --tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers - name: Cache Playwright browsers
if: "!env.ACT" if: '!env.ACT'
id: playwright-cache id: playwright-cache
uses: actions/cache@v6 uses: actions/cache@v6
with: with:
@@ -184,6 +191,82 @@ jobs:
path: e2e/test-results/ path: e2e/test-results/
retention-days: 7 retention-days: 7
- name: Run memory regression test
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
run: |
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
-e NO_EXTERNAL_CONNECTION=true \
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
homarr-ci
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
URL="http://127.0.0.1:${PORT}"
healthy=false
for i in $(seq 1 30); do
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
warmup_ok=0
for _ in $(seq 1 5); do
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
sleep 2
done
if [ "$warmup_ok" -eq 0 ]; then
echo "ERROR: All warmup requests failed"
docker logs "$CONTAINER_NAME" --tail 50
exit 1
fi
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
echo "ERROR: Container died before memory measurement"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
/MiB/ { gsub(/MiB/, ""); print $1; next }
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
{ print $1 / 1048576 }
')
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
- name: Clean up memory test container
if: always()
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
- name: Determine preview tag - name: Determine preview tag
id: preview id: preview
if: | if: |
@@ -233,7 +316,7 @@ jobs:
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; } run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
- name: Delete GHCR package version - name: Delete GHCR package version
if: "!env.ACT" if: '!env.ACT'
uses: actions/github-script@v8 uses: actions/github-script@v8
with: with:
script: | script: |
-2
View File
@@ -1,5 +1,3 @@
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10) ## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06) ## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
+3
View File
@@ -23,6 +23,9 @@
"react-hooks/exhaustive-deps": "warn", "react-hooks/exhaustive-deps": "warn",
"eslint/no-unused-expressions": "warn" "eslint/no-unused-expressions": "warn"
}, },
"options": {
"reportUnusedDisableDirectives": "off"
},
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"], "ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
"settings": { "settings": {
"react": { "version": "19" } "react": { "version": "19" }
+2 -1
View File
@@ -50,11 +50,12 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
<code>user.read</code>,{" "} <code>user.read</code>,{" "}
<code>user.update</code>,{" "} <code>user.update</code>,{" "}
<code>asset.read</code>,{" "} <code>asset.read</code>,{" "}
<code>asset.view</code>,{" "}
<code>asset.download</code>,{" "} <code>asset.download</code>,{" "}
<code>asset.statistics</code>,{" "} <code>asset.statistics</code>,{" "}
<code>server.statistics</code>,{" "} <code>server.statistics</code>,{" "}
<code>server.about</code>. <code>server.about</code>.
<br />
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
</span> </span>
), ),
@@ -1,6 +1,6 @@
--- ---
title: 'Immich Album Carousel' title: 'Immich Album Carousel'
description: 'Shows a slideshow of pictures from an Immich album or your full library' description: 'Shows a slideshow of your album pictures from Immich'
hide_title: true hide_title: true
--- ---
@@ -37,3 +37,4 @@ import screen from './img/screen.png';
### Configuration ### Configuration
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} /> <WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
@@ -4,16 +4,15 @@ import { IconPhoto } from "@tabler/icons-react";
export const immichAlbumCarouselWidget: WidgetDefinition = { export const immichAlbumCarouselWidget: WidgetDefinition = {
icon: IconPhoto, icon: IconPhoto,
name: "Immich Album", name: "Immich Album",
description: "Shows a slideshow of pictures from an Immich album or your full library", description: "Shows a slideshow of your album pictures from Immich",
path: "../../widgets/immich-album-carousel", path: "../../widgets/immich-album-carousel",
configuration: { configuration: {
items: [ items: [
{ {
name: "Album", name: "Album",
description: description: "Select the Immich album to show in the slideshow.",
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
values: { type: "string" }, values: { type: "string" },
defaultValue: "All photos", defaultValue: "",
}, },
{ {
name: "Interval in seconds", name: "Interval in seconds",
@@ -1,44 +1,41 @@
--- ---
title: "Media Request List" title: 'Media Request List'
description: "See a list of all media requests from your integration" description: 'See a list of all media requests from your integration'
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { IconAd } from "@tabler/icons-react"; import { IconAd } from "@tabler/icons-react";
import Tabs from "@theme/Tabs"; import Tabs from '@theme/Tabs';
import TabItem from "@theme/TabItem"; import TabItem from '@theme/TabItem';
import { mediaRequestListWidget } from "."; import { mediaRequestListWidget } from '.';
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
import { overseerrIntegration } from "@site/docs/integrations/overseerr"; 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. 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 ### Screenshots
import mediaRequestList from "./img/list.png"; import mediaRequestList from './img/list.png';
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} /> <img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations <WidgetIntegrations items={[{
items={[ integration: jellyseerrIntegration,
{ }, {
integration: jellyseerrIntegration, integration: overseerrIntegration,
}, }]} />
{
integration: overseerrIntegration,
},
]}
/>
### Interactions ### Interactions
@@ -50,5 +47,4 @@ import mediaRequestList from "./img/list.png";
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={mediaRequestListWidget.configuration} /> <WidgetConfig configuration={mediaRequestListWidget.configuration} />
@@ -14,18 +14,6 @@ export const mediaRequestListWidget: WidgetDefinition = {
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", 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",
},
], ],
}, },
}; };
@@ -2,7 +2,6 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useState } from "react"; import { useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
@@ -34,7 +33,6 @@ import {
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications"; import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets";
import { createWidgetQueryPersister } from "./query-cache-persister"; import { createWidgetQueryPersister } from "./query-cache-persister";
@@ -92,13 +90,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}, },
}); });
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); 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; return client;
}); });
@@ -16,10 +16,7 @@ export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params; const params = await props.params;
const session = await auth(); const session = await auth();
if ( if (!session?.user.permissions.includes("app-modify-all")) {
!session?.user.permissions.includes("board-modify-all") ||
!session?.user.permissions.includes("app-modify-all")
) {
notFound(); notFound();
} }
const app = await api.app.byId({ id: params.id }); const app = await api.app.byId({ id: params.id });
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
export default async function AppNewPage() { export default async function AppNewPage() {
const session = await auth(); const session = await auth();
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) { if (!session?.user.permissions.includes("app-create")) {
notFound(); notFound();
} }
@@ -1,5 +1,5 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { notFound, redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react"; import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -33,9 +33,6 @@ export default async function AppsPage(props: AppsPageProps) {
if (!session) { if (!session) {
redirect("/auth/login"); redirect("/auth/login");
} }
if (!session.user.permissions.includes("board-modify-all")) {
notFound();
}
const searchParams = searchParamsSchema.parse(await props.searchParams); const searchParams = searchParamsSchema.parse(await props.searchParams);
@@ -61,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox, icon: IconBox,
href: "/manage/apps", href: "/manage/apps",
label: t("items.apps"), label: t("items.apps"),
hidden: !session?.user.permissions.includes("board-modify-all"), hidden: !session,
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
}, },
@@ -193,10 +193,6 @@ export function DockerTable({ initialData }: DockerTableProps) {
positionToolbarAlertBanner: "top", positionToolbarAlertBanner: "top",
enableTableFooter: false, enableTableFooter: false,
enableBottomToolbar: false, enableBottomToolbar: false,
// MRT's built-in full-screen mode does not stretch the table to fill the
// viewport, so with few containers it renders as a thin strip (#6230). Disable
// it, matching the other management tables (users, invites).
enableFullScreenToggle: false,
positionGlobalFilter: "right", positionGlobalFilter: "right",
mantineSearchTextInputProps: { mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(containers.length) }), placeholder: tDocker("table.search", { count: String(containers.length) }),
@@ -31,7 +31,6 @@ import {
userChangeHomeBoardsSchema, userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema, userChangeSearchPreferencesSchema,
userEditProfileSchema, userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema, userFirstDayOfWeekSchema,
userPingIconsEnabledSchema, userPingIconsEnabledSchema,
} from "@homarr/validation/user"; } from "@homarr/validation/user";
@@ -52,7 +51,6 @@ const userGeneralSettingsSchema = z.object({
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled, ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek, firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled, pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
}); });
type FormValues = z.infer<typeof userGeneralSettingsSchema>; type FormValues = z.infer<typeof userGeneralSettingsSchema>;
@@ -80,7 +78,6 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
ddgBangsEnabled: user.ddgBangs, ddgBangsEnabled: user.ddgBangs,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek, firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled, pingIconsEnabled: user.pingIconsEnabled,
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
}); });
export const UserGeneralSettingsForm = ({ export const UserGeneralSettingsForm = ({
@@ -98,7 +95,6 @@ export const UserGeneralSettingsForm = ({
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation(); const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation(); const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation(); const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
const initialValues = buildInitialValues(user); const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues); const initialValuesRef = useRef(initialValues);
@@ -118,7 +114,6 @@ export const UserGeneralSettingsForm = ({
changeSearchPreferencesMutation, changeSearchPreferencesMutation,
changeFirstDayOfWeekMutation, changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation, changePingIconsEnabledMutation,
changeEnableRightClickOnWidgetsMutation,
]; ];
const isPending = mutations.some((m) => m.isPending); const isPending = mutations.some((m) => m.isPending);
@@ -174,14 +169,6 @@ export const UserGeneralSettingsForm = ({
action: () => action: () =>
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }), changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
}, },
{
when: changed("enableRightClickOnWidgets"),
action: () =>
changeEnableRightClickOnWidgetsMutation.mutateAsync({
id: user.id,
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
}),
},
]; ];
const promises = saveActions.filter((s) => s.when).map((s) => s.action()); const promises = saveActions.filter((s) => s.when).map((s) => s.action());
@@ -318,11 +305,6 @@ export const UserGeneralSettingsForm = ({
label={t("user.field.pingIconsEnabled.label")} label={t("user.field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })} {...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/> />
<Switch
label={t("user.field.enableRightClickOnWidgets.label")}
description={t("user.field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack> </Stack>
</Card> </Card>
</SimpleGrid> </SimpleGrid>
@@ -1,25 +1,19 @@
import { notFound } from "next/navigation";
import { ImageProxy } from "@homarr/image-proxy"; import { ImageProxy } from "@homarr/image-proxy";
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => { export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
const { id } = await props.params; const { id } = await props.params;
const imageProxy = new ImageProxy(); const imageProxy = new ImageProxy();
const result = await imageProxy.forwardImageAsync(id); const image = await imageProxy.forwardImageAsync(id);
if (!image) {
if ("error" in result) { notFound();
const status = result.error === "upstream-error" ? result.statusCode : result.error === "fetch-error" ? 502 : 404;
return new Response(null, {
status,
headers: {
"X-Homarr-Image-Proxy-Error": result.error,
},
});
} }
return new Response(result.image, { return new Response(image, {
headers: { headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour "Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
...(result.contentType ? { "Content-Type": result.contentType } : {}),
}, },
}); });
}; };
@@ -1,17 +1,9 @@
"use client"; "use client";
import type { MutableRefObject, ReactNode } from "react"; import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core"; import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } 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 { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets"; import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals"; import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -48,7 +38,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const tMenu = useScopedI18n("item.menu.label"); const tMenu = useScopedI18n("item.menu.label");
const t = useI18n(); const t = useI18n();
const settings = useSettings(); const settings = useSettings();
const isRightClickEnabled = settings.enableRightClickOnWidgets;
const { openModal } = useModalAction(WidgetEditModal); const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal); const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
@@ -169,7 +158,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
); );
if (!session) return <>{children}</>; if (!session) return <>{children}</>;
if (!isRightClickEnabled) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
@@ -244,14 +232,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
onClick={handleRefetch} onClick={handleRefetch}
disabled={isWidgetFetching} disabled={isWidgetFetching}
> >
<Group justify="space-between" wrap="nowrap" gap="sm"> <Group justify="space-between" wrap="nowrap">
{tMenu("refresh")} {tMenu("refresh")}
<WidgetQueryStatus <Text size="xs" c="dimmed">
queryClient={queryClient} <WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
queryKey={widgetQueryKey} </Text>
isFetching={isWidgetFetching}
t={t}
/>
</Group> </Group>
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
@@ -288,65 +273,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
); );
}; };
interface WidgetQueryStatusProps { const WidgetCacheAge = ({
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient; queryClient: QueryClient;
queryKey: QueryKey; queryKey: QueryKey;
isFetching: boolean; 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) => { if (isFetching) return <Loader size={12} />;
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) { const timestamps = queryClient
return ( .getQueryCache()
<Group gap={4} wrap="nowrap"> .findAll({ queryKey })
<Loader size={12} /> .map((q) => q.state.dataUpdatedAt)
<Text size="xs" c="dimmed"> .filter(Boolean);
{t("item.menu.status.loading")} if (timestamps.length === 0) return null;
</Text>
</Group>
);
}
if (queries.length === 0) { const latest = Math.max(...timestamps);
return ( const seconds = Math.floor((Date.now() - latest) / 1000);
<Text size="xs" c="dimmed"> if (seconds < 5) return "just now";
{t("item.menu.status.idle")} if (seconds < 60) return `${seconds}s ago`;
</Text> return `${Math.floor(seconds / 60)}m ago`;
);
}
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>
);
}; };
+2 -2
View File
@@ -6,7 +6,7 @@
}, },
"engines": { "engines": {
"node": ">=24.18.0", "node": ">=24.18.0",
"pnpm": ">=11.13.1" "pnpm": ">=11.11.0"
}, },
"packageManager": "pnpm@11.13.1" "packageManager": "pnpm@11.11.0"
} }
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "1.71.0", "version": "1.70.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
@@ -69,7 +69,7 @@
}, },
"engines": { "engines": {
"node": ">=24.18.0", "node": ">=24.18.0",
"pnpm": ">=11.13.1" "pnpm": ">=11.11.0"
}, },
"packageManager": "pnpm@11.13.1" "packageManager": "pnpm@11.11.0"
} }
+1 -2
View File
@@ -18,8 +18,7 @@ import { AppAccessControl } from "./app/app-access-control";
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg"; const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
getPaginated: permissionRequiredProcedure getPaginated: protectedProcedure
.requiresPermission("board-modify-all")
.input(paginatedSchema) .input(paginatedSchema)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() })) .output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({ .meta({
+1 -1
View File
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
}); });
} }
if (ctx.session?.user.permissions.includes("board-modify-all")) { if (ctx.session?.user) {
statistics.push({ statistics.push({
titleKey: "app", titleKey: "app",
subtitleKey: "resources", subtitleKey: "resources",
@@ -3,7 +3,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, asc, eq, like } from "@homarr/db"; import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema"; import { searchEngines, users } from "@homarr/db/schema";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
@@ -130,15 +130,9 @@ export const searchEngineRouter = createTRPCRouter({
return null; return null;
}), }),
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => { search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({ return await ctx.db.query.searchEngines.findMany({
// Public dashboards have no session: restrict anonymous users to generic where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
// (non-integration) engines so custom search engines work there too (#4132),
// while integration-backed engines stay available only when signed in.
where: and(
like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
),
with: { with: {
integration: { integration: {
columns: { columns: {
-27
View File
@@ -76,33 +76,6 @@ describe("all should return all apps", () => {
}); });
}); });
describe("getPaginated should require board modification access", () => {
test("should throw FORBIDDEN without board-modify-all", async () => {
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createDefaultSession(),
});
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
});
test("should return apps with board-modify-all", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["board-modify-all"]),
});
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).resolves.toMatchObject({
totalCount: 1,
items: [{ id: "1" }],
});
});
});
describe("byId should return an app by id", () => { describe("byId should return an app by id", () => {
test("should return an app by id when canUserSeeAppAsync returns true", async () => { test("should return an app by id when canUserSeeAppAsync returns true", async () => {
// Arrange // Arrange
-77
View File
@@ -357,83 +357,6 @@ describe("delete should delete user", () => {
}); });
}); });
describe("changeEnableRightClickOnWidgets should toggle the right-click preference", () => {
test("non-admin can toggle their own preference", async () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await db.insert(users).values({
id: defaultOwnerId,
name: "owner",
});
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: false });
const updated = await db.query.users.findFirst({
where: eq(users.id, defaultOwnerId),
columns: { enableRightClickOnWidgets: true },
});
expect(updated?.enableRightClickOnWidgets).toBe(false);
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: true });
const restored = await db.query.users.findFirst({
where: eq(users.id, defaultOwnerId),
columns: { enableRightClickOnWidgets: true },
});
expect(restored?.enableRightClickOnWidgets).toBe(true);
});
test("non-admin cannot toggle another user's preference", async () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const otherUserId = createId();
await db.insert(users).values({ id: defaultOwnerId });
await db.insert(users).values({ id: otherUserId, name: "other" });
await expect(
caller.changeEnableRightClickOnWidgets({ id: otherUserId, enableRightClickOnWidgets: false }),
).rejects.toThrow("User not found");
const other = await db.query.users.findFirst({
where: eq(users.id, otherUserId),
columns: { enableRightClickOnWidgets: true },
});
expect(other?.enableRightClickOnWidgets).toBe(true);
});
test("admin can toggle another user's preference", async () => {
const db = createDb();
const adminSession = createSession(["admin"]);
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: adminSession,
});
const targetUserId = createId();
await db.insert(users).values({ id: defaultOwnerId });
await db.insert(users).values({ id: targetUserId, name: "target" });
await caller.changeEnableRightClickOnWidgets({ id: targetUserId, enableRightClickOnWidgets: false });
const target = await db.query.users.findFirst({
where: eq(users.id, targetUserId),
columns: { enableRightClickOnWidgets: true },
});
expect(target?.enableRightClickOnWidgets).toBe(false);
});
});
const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => { const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
await db.insert(onboarding).values({ await db.insert(onboarding).values({
id: createId(), id: createId(),
-29
View File
@@ -23,7 +23,6 @@ import {
userCreateSchema, userCreateSchema,
userDdgBangsSchema, userDdgBangsSchema,
userEditProfileSchema, userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema, userFirstDayOfWeekSchema,
userInitSchema, userInitSchema,
userPingIconsEnabledSchema, userPingIconsEnabledSchema,
@@ -346,7 +345,6 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true, mobileHomeBoardId: true,
firstDayOfWeek: true, firstDayOfWeek: true,
pingIconsEnabled: true, pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true, defaultSearchEngineId: true,
openSearchInNewTab: true, openSearchInNewTab: true,
ddgBangs: true, ddgBangs: true,
@@ -383,7 +381,6 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true, mobileHomeBoardId: true,
firstDayOfWeek: true, firstDayOfWeek: true,
pingIconsEnabled: true, pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true, defaultSearchEngineId: true,
openSearchInNewTab: true, openSearchInNewTab: true,
ddgBangs: true, ddgBangs: true,
@@ -654,32 +651,6 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, ctx.session.user.id));
}), }),
changeEnableRightClickOnWidgets: protectedProcedure
.meta({
openapi: {
method: "PATCH",
path: "/api/users/right-click-widgets",
tags: ["users"],
protect: true,
},
})
.input(convertIntersectionToZodObject(userEnableRightClickOnWidgetsSchema.and(byIdSchema)))
.output(z.void())
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
})
.where(eq(users.id, input.id));
}),
changePingIconsEnabled: protectedProcedure changePingIconsEnabled: protectedProcedure
.input(userPingIconsEnabledSchema.and(byIdSchema)) .input(userPingIconsEnabledSchema.and(byIdSchema))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
+1 -1
View File
@@ -25,7 +25,7 @@ export const immichRouter = createTRPCRouter({
.concat(createImmichIntegrationMiddleware("query")) .concat(createImmichIntegrationMiddleware("query"))
.input( .input(
z.object({ z.object({
albumId: z.string().optional(), albumId: z.string(),
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -3,10 +3,7 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
mediaRequestListInputSchema,
mediaRequestListRequestHandler,
} from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -19,14 +16,13 @@ export const mediaRequestsRouter = createTRPCRouter({
mcp: { mcp: {
enabled: true, enabled: true,
description: 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"))) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema) .query(async ({ ctx }) => {
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { 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 { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
}); });
return results return results
+4 -7
View File
@@ -3,7 +3,6 @@ import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server"; import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db"; import { eq } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema"; import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions"; import { releaseProviderKinds } from "@homarr/definitions";
@@ -12,8 +11,6 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access"; import { throwIfActionForbiddenAsync } from "../board/board-access";
const logger = createLogger({ module: "releasesRouter" });
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => { const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined; if (!versionFilter) return undefined;
@@ -74,13 +71,13 @@ export const releasesRouter = createTRPCRouter({
for (const secret of secrets) { for (const secret of secrets) {
try { try {
tokensByProvider.set(secret.kind, decryptSecret(secret.value)); tokensByProvider.set(secret.kind, decryptSecret(secret.value));
} catch (error) { } catch {
logger.warn("Failed to decrypt widget secret", { itemId: input.itemId, kind: secret.kind, error }); // Skip corrupt secrets
} }
} }
} }
} catch (error) { } catch {
logger.warn("Failed to load release widget tokens", { itemId: input.itemId, error }); // Access denied or table not yet migrated -- proceed without tokens
} }
} }
@@ -1 +0,0 @@
ALTER TABLE `user` ADD `enable_right_click_on_widgets` boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -302,13 +302,6 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0042_add_widget_secret", "tag": "0042_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1783852800000,
"tag": "0043_add_right_click_preference",
"breakpoints": true
} }
] ]
} }
@@ -1 +0,0 @@
ALTER TABLE "user" ADD COLUMN "enable_right_click_on_widgets" boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -64,13 +64,6 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0008_add_widget_secret", "tag": "0008_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1783786677702,
"tag": "0009_add_right_click_preference",
"breakpoints": true
} }
] ]
} }
@@ -1 +0,0 @@
ALTER TABLE `user` ADD `enable_right_click_on_widgets` integer DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -288,13 +288,6 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0040_add_widget_secret", "tag": "0040_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1783786658943,
"tag": "0041_add_right_click_preference",
"breakpoints": true
} }
] ]
} }
-1
View File
@@ -86,7 +86,6 @@ export const users = mysqlTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(), colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(), pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(), completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(), completedBoardTour: boolean().default(false).notNull(),
}); });
-1
View File
@@ -85,7 +85,6 @@ export const users = pgTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(), colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(), pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(), completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(), completedBoardTour: boolean().default(false).notNull(),
}); });
-1
View File
@@ -68,7 +68,6 @@ export const users = sqliteTable("user", {
colorScheme: text().$type<ColorScheme>().default("dark").notNull(), colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(), pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
completedManageTour: int({ mode: "boolean" }).default(false).notNull(), completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(), completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
}); });
+1 -1
View File
@@ -37,5 +37,5 @@ describe("Mysql Migration", () => {
connection.end(); connection.end();
await mysqlContainer.stop(); await mysqlContainer.stop();
}, 120_000); }, 40_000);
}); });
+1
View File
@@ -467,6 +467,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png", iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"], category: ["archiving"],
documentationUrl: null, documentationUrl: null,
defaultUrl: "http://localhost:8001",
}, },
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: { mock: {
+8 -31
View File
@@ -19,12 +19,6 @@ const createUrlByIdChannel = (id: string) =>
headers: `${string}.${string}`; headers: `${string}.${string}`;
}>(`image-proxy:url:${id}`); }>(`image-proxy:url:${id}`);
export type ForwardImageResult =
| { image: ArrayBuffer; contentType: string | null }
| { error: "not-found" }
| { error: "upstream-error"; statusCode: number }
| { error: "fetch-error" };
export class ImageProxy { export class ImageProxy {
private static hmacKey: Buffer<ArrayBuffer> | null = null; private static hmacKey: Buffer<ArrayBuffer> | null = null;
@@ -53,34 +47,17 @@ export class ImageProxy {
return this.createImageUrl(id); return this.createImageUrl(id);
} }
public async forwardImageAsync(id: string): Promise<ForwardImageResult> { public async forwardImageAsync(id: string): Promise<ArrayBuffer | null> {
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id); const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
if (!urlAndHeaders) { if (!urlAndHeaders) {
return { error: "not-found" }; return null;
} }
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
const proxyUrl = this.createImageUrl(id); const proxyUrl = this.createImageUrl(id);
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
try {
response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
} catch (error) {
logger.error(
new ErrorWithMetadata(
"Failed to connect to image source",
{
id,
url: this.redactUrl(urlAndHeaders.url),
headers: this.redactHeaders(urlAndHeaders.headers),
proxyUrl,
},
{ cause: error },
),
);
return { error: "fetch-error" };
}
if (!response.ok) { if (!response.ok) {
logger.error( logger.error(
new ErrorWithMetadata("Failed to fetch image", { new ErrorWithMetadata("Failed to fetch image", {
@@ -91,7 +68,7 @@ export class ImageProxy {
statusCode: response.status, statusCode: response.status,
}), }),
); );
return { error: "upstream-error", statusCode: response.status }; return null;
} }
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
@@ -103,7 +80,7 @@ export class ImageProxy {
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`, size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
}); });
return { image: arrayBuffer, contentType: response.headers.get("content-type") }; return arrayBuffer;
} }
private createImageUrl(id: string): string { private createImageUrl(id: string): string {
@@ -117,12 +117,6 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "", albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null, metadata: null,
}; };
} }
@@ -1,14 +1,10 @@
import { import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAlbumInfo, getAlbumInfo,
getAllAlbums, getAllAlbums,
getMyUser, getMyUser,
getServerStatistics, getServerStatistics,
init, init,
searchAssets, searchAssets,
searchRandom,
searchUsers, searchUsers,
} from "@immich/sdk"; } from "@immich/sdk";
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk"; import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
@@ -46,7 +42,6 @@ export interface ImmichAsset {
} }
const logger = createLogger({ module: "immich-integration" }); const logger = createLogger({ module: "immich-integration" });
const randomPhotoCount = 50;
export class ImmichIntegration extends Integration { export class ImmichIntegration extends Integration {
constructor(integration: IntegrationInput) { constructor(integration: IntegrationInput) {
@@ -67,36 +62,36 @@ export class ImmichIntegration extends Integration {
}; };
} }
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> { public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
this.initClient(); this.initClient();
const requestOptions = this.getRequestOptions(); const requestOptions = this.getRequestOptions();
if (!albumId) {
const assets = await searchRandom(
{
randomSearchDto: {
size: randomPhotoCount,
type: AssetTypeEnum.Image,
visibility: AssetVisibility.Timeline,
},
},
requestOptions,
);
return {
id: "",
albumName: "",
assets: await this.createAssetsAsync(assets),
};
}
const [album, albumAssets] = await Promise.all([ const [album, albumAssets] = await Promise.all([
getAlbumInfo({ id: albumId }, requestOptions), getAlbumInfo({ id: albumId }, requestOptions),
this.searchAlbumAssetsAsync(albumId), this.searchAlbumAssetsAsync(albumId),
]); ]);
const imageProxy = new ImageProxy();
return { return {
albumName: album.albumName, albumName: album.albumName,
assets: await this.createAssetsAsync(albumAssets), assets: await Promise.all(
albumAssets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
this.url(`/api/assets/${asset.id}/original`).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
),
id: album.id, id: album.id,
}; };
} }
@@ -138,32 +133,6 @@ export class ImmichIntegration extends Integration {
return assets; return assets;
} }
private async createAssetsAsync(assets: AssetResponseDto[]): Promise<ImmichAsset[]> {
const imageProxy = new ImageProxy();
return await Promise.all(
assets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
);
}
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) { private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
return { return {
// Undici and node types are not the same // Undici and node types are not the same
@@ -13,12 +13,6 @@ export interface StreamSession {
episodeName?: string | null; episodeName?: string | null;
albumName?: string | null; albumName?: string | null;
episodeCount?: number | null; episodeCount?: number | null;
playback: {
state: "playing" | "paused" | "buffering" | null;
positionMs: number | null;
durationMs: number | null;
};
location: "lan" | "wan" | null;
metadata: { metadata: {
video: { video: {
resolution: { 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 { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../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]) @HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({ private readonly jellyfin: Jellyfin = new Jellyfin({
@@ -54,9 +50,6 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) { if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
currentlyPlaying = { currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
@@ -64,12 +57,6 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "", albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing",
positionMs,
durationMs,
},
location: null,
metadata: { metadata: {
video: { video: {
resolution: resolution:
@@ -13,8 +13,6 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -29,8 +27,6 @@ const sessions: StreamSession[] = [
episodeName: "Ozymandias", episodeName: "Ozymandias",
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -45,8 +41,6 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -61,8 +55,6 @@ const sessions: StreamSession[] = [
episodeName: "The We We Are", episodeName: "The We We Are",
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -77,8 +69,6 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: "Random Access Memories", albumName: "Random Access Memories",
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -93,8 +83,6 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -153,9 +153,9 @@ export class OverseerrIntegration
); );
} else if (pendingResults.length > 0) requests = pendingResults; } else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults; 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> => { requests.map(async (request): Promise<MediaRequest> => {
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type); 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 { 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") { if (type === "tv") {
const series = (await response.json()) as TvInformation; const series = (await response.json()) as TvInformation;
return { return {
@@ -3,54 +3,6 @@ interface MediaContainer {
Track?: Session[]; 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 { interface Session {
User?: { User?: {
$: { $: {
@@ -63,20 +15,13 @@ interface Session {
$: { $: {
product: string; product: string;
title: string; title: string;
address?: string;
state?: string;
local?: string;
}; };
}[]; }[];
Session?: { Session?: {
$: { $: {
id: string; id: string;
bandwidth?: string;
location?: string;
}; };
}[]; }[];
TranscodeSession?: TranscodeSession[];
Media?: Media[];
$: { $: {
grandparentTitle?: string; grandparentTitle?: string;
parentTitle?: string; parentTitle?: string;
@@ -84,8 +29,6 @@ interface Session {
index?: number; index?: number;
type: string; type: string;
live?: string; live?: string;
viewOffset?: string;
duration?: string;
}; };
} }
@@ -17,32 +17,6 @@ import type { PlexResponse } from "./interface";
const logger = createLogger({ module: "plexIntegration" }); 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 { export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> { public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey"); 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 userElement = mediaElement.User ? mediaElement.User[0] : undefined;
const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined; const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined;
const sessionElement = mediaElement.Session ? mediaElement.Session[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) { if (!playerElement) {
return undefined; 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 { return {
sessionId: sessionElement?.$.id ?? "unknown", sessionId: sessionElement?.$.id ?? "unknown",
sessionName: `${playerElement.$.product} (${playerElement.$.title})`, sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
@@ -102,30 +63,7 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
episodeName: mediaElement.$.title ?? null, episodeName: mediaElement.$.title ?? null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null, albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: mediaElement.$.index ?? null, episodeCount: mediaElement.$.index ?? null,
playback: { metadata: null,
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,
},
},
},
}, },
}; };
}) })
@@ -23,8 +23,6 @@ type OptionMapping = {
const optionMapping: OptionMapping = { const optionMapping: OptionMapping = {
"mediaRequests-requestList": { "mediaRequests-requestList": {
linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab, linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab,
statusFilter: () => undefined,
recentDays: () => undefined,
}, },
"mediaRequests-requestStats": {}, "mediaRequests-requestStats": {},
bookmarks: { bookmarks: {
+17 -4
View File
@@ -300,7 +300,10 @@ async function getContainersWithStatsAsync() {
const stats = await instance const stats = await instance
.getContainer(container.Id) .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( .catch(
() => () =>
({ ({
@@ -336,12 +339,22 @@ export function calculateCpuUsage(stats: ContainerStats): number {
} }
const numberOfCpus = stats.cpu_stats.online_cpus; 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 0;
} }
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100; return (cpuDelta / systemDelta) * numberOfCpus * 100;
} }
export function calculateMemoryUsage(stats: ContainerStats): number { export function calculateMemoryUsage(stats: ContainerStats): number {
+1 -1
View File
@@ -33,7 +33,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
export const immichAlbumRequestHandler = createIntegrationRequestHandler< export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum, ImmichAlbum,
IntegrationKindByCategory<"photoService">, IntegrationKindByCategory<"photoService">,
{ albumId?: string } { albumId: string }
>({ >({
async requestAsync(integration, input) { async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration); 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>> { interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>; requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number; cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
} }
export const createIntegrationRequestHandler = < export const createIntegrationRequestHandler = <
@@ -33,7 +32,6 @@ export const createIntegrationRequestHandler = <
return options.requestAsync(integration, input.options); return options.requestAsync(integration, input.options);
}, },
cacheTtlMs: options.cacheTtlMs, cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
}); });
return { return {
@@ -1,7 +1,6 @@
interface Options<TData, TInput extends Record<string, unknown>> { interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>; requestAsync: (input: TInput) => Promise<TData>;
cacheTtlMs?: number; cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
} }
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number }; 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) { if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp }; return { data: cached.data, timestamp: cached.timestamp };
} }
if (cached) cache.delete(key);
const existing = inflight.get(key); const existing = inflight.get(key);
if (existing) return existing; if (existing) return existing;
@@ -55,9 +55,6 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
}) })
.catch((err) => { .catch((err) => {
inflight.delete(key); inflight.delete(key);
if (options.fallbackToStaleOnError && cached) {
return cached;
}
throw err; throw err;
}); });
@@ -1,44 +1,16 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; 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"; 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< export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
MediaRequest[], MediaRequest[],
IntegrationKindByCategory<"mediaRequest">, IntegrationKindByCategory<"mediaRequest">,
MediaRequestListInput Record<string, never>
>({ >({
async requestAsync(integration, input) { async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
const requests = await integrationInstance.getRequestsAsync(); return 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;
});
}, },
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
}); });
@@ -16,6 +16,4 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
users: await integrationInstance.getUsersAsync(), users: await integrationInstance.getUsersAsync(),
}; };
}, },
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
}); });
@@ -134,33 +134,7 @@ const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
baseUrl, baseUrl,
auth: token, auth: token,
request: { fetch: fetchWithTrustedCertificatesAsync }, request: { fetch: fetchWithTrustedCertificatesAsync },
throttle: { throttle: { enabled: true },
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;
},
},
userAgent, userAgent,
}); });
@@ -745,19 +719,6 @@ const linuxServerIOReleasesSchema = z.object({
}), }),
}); });
// Parse a github.com project URL (as returned by the LSIO registry) into an
// "owner/repo" identifier, or null if it is not a github.com repository URL.
const parseGithubRepo = (url: string): string | null => {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") return null;
const [owner, repo] = parsed.pathname.replace(/^\/+/, "").split("/");
return owner && repo ? `${owner}/${repo}` : null;
} catch {
return null;
}
};
const getLinuxServerIOReleaseAsync = async ( const getLinuxServerIOReleaseAsync = async (
baseUrl: string, baseUrl: string,
identifier: string, identifier: string,
@@ -787,29 +748,6 @@ const getLinuxServerIOReleaseAsync = async (
error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` }, error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` },
}; };
// LSIO's /api/v1/images only exposes the single latest version, so a version
// filter can never surface an older release (#4351). When the image has a GitHub
// repository, delegate to the GitHub release provider to fetch the real release
// history and apply the filter, while keeping LSIO's own image metadata. Any
// GitHub failure (no repo, no releases, rate limit) falls back to the LSIO latest.
const githubRepo = parseGithubRepo(release.github_url);
if (githubRepo) {
const githubResult = await getGithubReleaseAsync(getReleaseProviderDefaultUrl("github"), githubRepo, versionRegex);
if (githubResult.success) {
return {
success: true,
data: {
...githubResult.data,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
starsCount: release.stars,
createdAt: release.initial_date ?? githubResult.data.createdAt,
},
};
}
}
const regex = compileVersionRegex(versionRegex); const regex = compileVersionRegex(versionRegex);
if (versionRegex && !regex) { if (versionRegex && !regex) {
return { return {
@@ -75,6 +75,26 @@ describe("calculateCpuUsage", () => {
// (500 / 100000) * 2 * 100 = 1 // (500 / 100000) * 2 * 100 = 1
expect(calculateCpuUsage(stats)).toBe(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", () => { describe("calculateMemoryUsage", () => {
+1 -4
View File
@@ -9,7 +9,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { latitude: number; longitude: number }) { async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => { const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync( return await fetchWithTrustedCertificatesAsync(
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean&current_weather=true&timezone=auto`, `https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max&current_weather=true&timezone=auto`,
{ signal }, { signal },
); );
}); });
@@ -27,7 +27,6 @@ export const weatherRequestHandler = createWidgetRequestHandler({
sunset: weather.daily.sunset[index], sunset: weather.daily.sunset[index],
maxWindSpeed: weather.daily.wind_speed_10m_max[index], maxWindSpeed: weather.daily.wind_speed_10m_max[index],
maxWindGusts: weather.daily.wind_gusts_10m_max[index], maxWindGusts: weather.daily.wind_gusts_10m_max[index],
humidity: weather.daily.relative_humidity_2m_mean[index],
}; };
}), }),
} satisfies Weather; } satisfies Weather;
@@ -49,7 +48,6 @@ const atLocationOutput = z.object({
sunset: z.array(z.string()), sunset: z.array(z.string()),
wind_speed_10m_max: z.array(z.number()), wind_speed_10m_max: z.array(z.number()),
wind_gusts_10m_max: z.array(z.number()), wind_gusts_10m_max: z.array(z.number()),
relative_humidity_2m_mean: z.array(z.number()),
}), }),
}); });
@@ -68,6 +66,5 @@ export interface Weather {
sunset: string | undefined; sunset: string | undefined;
maxWindSpeed: number | undefined; maxWindSpeed: number | undefined;
maxWindGusts: number | undefined; maxWindGusts: number | undefined;
humidity: number | undefined;
}[]; }[];
} }
-3
View File
@@ -10,7 +10,6 @@ export type SettingsContextProps = Pick<
| "openSearchInNewTab" | "openSearchInNewTab"
| "ddgBangs" | "ddgBangs"
| "pingIconsEnabled" | "pingIconsEnabled"
| "enableRightClickOnWidgets"
> & > &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> & Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["user"], "enableGravatar">; Pick<ServerSettings["user"], "enableGravatar">;
@@ -33,7 +32,6 @@ export type UserSettings = Pick<
| "openSearchInNewTab" | "openSearchInNewTab"
| "ddgBangs" | "ddgBangs"
| "pingIconsEnabled" | "pingIconsEnabled"
| "enableRightClickOnWidgets"
>; >;
export const createSettings = ({ export const createSettings = ({
@@ -50,7 +48,6 @@ export const createSettings = ({
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId, homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId, mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false, pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
enableStatusByDefault: serverSettings.board.enableStatusByDefault, enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus, forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar, enableGravatar: serverSettings.user.enableGravatar,
@@ -56,13 +56,6 @@ export const userPreferenceDefinitions = [
mutationGroup: "pingIconsEnabled", mutationGroup: "pingIconsEnabled",
aliases: ["ping", "icon", "status"], aliases: ["ping", "icon", "status"],
}, },
{
key: "enableRightClickOnWidgets",
kind: "boolean",
guest: false,
mutationGroup: "enableRightClickOnWidgets",
aliases: ["right click", "context menu", "tile", "widget menu"],
},
{ {
key: "fullPreferencesPage", key: "fullPreferencesPage",
kind: "link", kind: "link",
@@ -17,10 +17,6 @@ export const appIntegrationBoardMode = {
return groups; return groups;
} }
return groups.concat( return groups.concat([appsSearchGroup, integrationsSearchGroup]);
session.user.permissions.includes("board-modify-all")
? [appsSearchGroup, integrationsSearchGroup]
: [integrationsSearchGroup],
);
}, },
} satisfies SearchMode; } satisfies SearchMode;
@@ -87,8 +87,7 @@ export const globalCommandGroup = createGroup<Command>({
icon: IconBox, icon: IconBox,
name: tOption("newApp.label"), name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })), useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
hidden: hidden: !session?.user.permissions.includes("app-create"),
!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create"),
}, },
{ {
commandKey: "newIntegration", commandKey: "newIntegration",
@@ -76,7 +76,7 @@ export const pagesSearchGroup = createGroup<{
icon: IconBox, icon: IconBox,
path: "/manage/apps", path: "/manage/apps",
name: t("manageApp.label"), name: t("manageApp.label"),
hidden: !session?.user.permissions.includes("board-modify-all"), hidden: !session,
}, },
{ {
icon: IconPlug, icon: IconPlug,
@@ -3,7 +3,6 @@
import { import {
IconActivity, IconActivity,
IconCalendarWeek, IconCalendarWeek,
IconClick,
IconDeviceDesktop, IconDeviceDesktop,
IconExternalLink, IconExternalLink,
IconHome, IconHome,
@@ -47,7 +46,6 @@ export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
homeBoardId: IconHome, homeBoardId: IconHome,
mobileHomeBoardId: IconLayoutBoard, mobileHomeBoardId: IconLayoutBoard,
pingIconsEnabled: IconActivity, pingIconsEnabled: IconActivity,
enableRightClickOnWidgets: IconClick,
fullPreferencesPage: IconSettings, fullPreferencesPage: IconSettings,
}; };
@@ -93,7 +93,6 @@ export const useUserPreferences = () => {
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh }); const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh }); const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh }); const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
const rightClickMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation({ onSuccess: refresh });
const getEffective = (key: UserPreferenceKey): unknown => const getEffective = (key: UserPreferenceKey): unknown =>
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps]; key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
@@ -117,8 +116,6 @@ export const useUserPreferences = () => {
}), }),
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }), firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }), pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
enableRightClickOnWidgets: (uid, _key, value) =>
rightClickMutation.mutateAsync({ id: uid, enableRightClickOnWidgets: value as boolean }),
}; };
const groupPending: Record<string, boolean> = { const groupPending: Record<string, boolean> = {
@@ -126,7 +123,6 @@ export const useUserPreferences = () => {
homeBoards: homeBoardsMutation.isPending, homeBoards: homeBoardsMutation.isPending,
firstDayOfWeek: firstDayMutation.isPending, firstDayOfWeek: firstDayMutation.isPending,
pingIconsEnabled: pingMutation.isPending, pingIconsEnabled: pingMutation.isPending,
enableRightClickOnWidgets: rightClickMutation.isPending,
}; };
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => { const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
+1 -7
View File
@@ -1598,16 +1598,10 @@
}, },
"menu": { "menu": {
"label": { "label": {
"settings": "", "settings": "Settings",
"refresh": "", "refresh": "",
"options": "", "options": "",
"actions": "" "actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
} }
}, },
"create": { "create": {
+5 -35
View File
@@ -198,10 +198,6 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Use icons for pings" "label": "Use icons for pings"
}, },
"enableRightClickOnWidgets": {
"label": "Enable right-click on widgets",
"description": "Show Homarr's context menu when right-clicking a widget. Disable to use the browser's default menu (e.g. \"Open link in new tab\")."
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "Default search engine" "label": "Default search engine"
}, },
@@ -1626,12 +1622,6 @@
"refresh": "Refresh", "refresh": "Refresh",
"options": "Options", "options": "Options",
"actions": "Actions" "actions": "Actions"
},
"status": {
"success": "OK",
"error": "Error",
"loading": "Loading",
"idle": "Idle"
} }
}, },
"create": { "create": {
@@ -2257,8 +2247,7 @@
"sunrise": "Sunrise", "sunrise": "Sunrise",
"sunset": "Sunset", "sunset": "Sunset",
"maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}", "maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}",
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}", "maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}"
"humidity": "Humidity: {humidity}%"
}, },
"kind": { "kind": {
"clear": "Clear", "clear": "Clear",
@@ -2722,11 +2711,6 @@
"user": "User", "user": "User",
"name": "Name", "name": "Name",
"id": "Id", "id": "Id",
"status": "Status",
"directPlay": "Direct play",
"transcoding": "Transcoding",
"paused": "Paused",
"remaining": "{minutes} min left",
"metadata": { "metadata": {
"title": "Stats for nerds", "title": "Stats for nerds",
"video": { "video": {
@@ -3007,11 +2991,10 @@
}, },
"immich-albumCarousel": { "immich-albumCarousel": {
"name": "Immich Album", "name": "Immich Album",
"allPhotos": "All photos",
"option": { "option": {
"albumId": { "albumId": {
"label": "Album", "label": "Album",
"description": "Select an Immich album or All photos. An empty selection also shows random photos from your full library." "description": "Select the Immich album to show in the slideshow."
}, },
"rotationIntervalSeconds": { "rotationIntervalSeconds": {
"label": "Interval in seconds", "label": "Interval in seconds",
@@ -3026,8 +3009,9 @@
"description": "Show photos in a random order instead of always starting with the same album order." "description": "Show photos in a random order instead of always starting with the same album order."
} }
}, },
"description": "Slideshow of pictures from an Immich album or your full library", "description": "Slideshow of pictures in your Immich album",
"noPhotos": "No photos found" "noAlbumSelected": "No album selected, edit widget and choose an album",
"noPhotos": "No photos in this album"
}, },
"downloads": { "downloads": {
"name": "Download Client", "name": "Download Client",
@@ -3211,12 +3195,6 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "Open links in new tab" "label": "Open links in new tab"
},
"statusFilter": {
"label": "Statuses to show"
},
"recentDays": {
"label": "Show only recent requests"
} }
}, },
"pending": { "pending": {
@@ -5958,14 +5936,6 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "Enable right-click on widgets",
"children": {
"detail": {
"title": "Enable right-click on widgets"
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "Open full preferences page" "label": "Open full preferences page"
} }
-4
View File
@@ -130,10 +130,6 @@ export const userPingIconsEnabledSchema = z.object({
pingIconsEnabled: z.boolean(), pingIconsEnabled: z.boolean(),
}); });
export const userEnableRightClickOnWidgetsSchema = z.object({
enableRightClickOnWidgets: z.boolean(),
});
export const userDdgBangsSchema = z.object({ export const userDdgBangsSchema = z.object({
ddgBangs: z.boolean(), ddgBangs: z.boolean(),
}); });
-1
View File
@@ -70,7 +70,6 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition { export interface WidgetDefinition {
icon: TablerIcon; icon: TablerIcon;
queryKey?: QueryKey; queryKey?: QueryKey;
refetchInterval?: number | null;
supportedIntegrations?: IntegrationKind[]; supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean; integrationsRequired?: boolean;
maxIntegrations?: number; maxIntegrations?: number;
@@ -9,8 +9,6 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad, icon: IconDeviceGamepad,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({ showToggleAllButtons: factory.switch({
@@ -9,8 +9,6 @@ export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd, icon: IconAd,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({ usePiHoleColors: factory.switch({
+10 -2
View File
@@ -184,7 +184,13 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const t = useScopedI18n("docker"); const t = useScopedI18n("docker");
const isTiny = width <= 256; 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 containers = data?.containers ?? [];
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]); const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp); const relativeTime = useTimeAgo(timestamp);
@@ -298,7 +304,9 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
{t("table.totalMemory", { memory: formatBytes(totals.memory) })} {t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text> </Text>
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}> <Tooltip
label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<ActionIcon <ActionIcon
size="sm" size="sm"
variant="transparent" variant="transparent"
-1
View File
@@ -33,7 +33,6 @@ const columnsSort = columnsList.filter((column) =>
export const { definition, componentLoader } = createWidgetDefinition("downloads", { export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload, icon: IconDownload,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
(factory) => ({ (factory) => ({
-1
View File
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", { export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall, icon: IconWall,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from(() => ({})); return optionsBuilder.from(() => ({}));
}, },
@@ -8,7 +8,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", { export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor, icon: IconHeartRateMonitor,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
(factory) => ({ (factory) => ({
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state"; import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import classes from "./component.module.css"; import classes from "./component.module.css";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export default function ImmichAlbumCarouselWidget({ export default function ImmichAlbumCarouselWidget({
integrationIds, integrationIds,
@@ -22,9 +21,9 @@ export default function ImmichAlbumCarouselWidget({
const { data: album } = clientApi.widget.immich.getAlbum.useQuery( const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
{ {
integrationId: integrationIds[0] ?? "", integrationId: integrationIds[0] ?? "",
albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined, albumId: options.albumId ?? "",
}, },
{ enabled: integrationIds.length > 0 }, { enabled: Boolean(options.albumId) },
); );
const photoAssets = useMemo(() => { const photoAssets = useMemo(() => {
@@ -36,6 +35,10 @@ export default function ImmichAlbumCarouselWidget({
setCurrentPhotoIndex(0); setCurrentPhotoIndex(0);
}, [photoAssets]); }, [photoAssets]);
if (!options.albumId) {
return <NoAlbumSelected />;
}
if (!album) return <WidgetEmptyState />; if (!album) return <WidgetEmptyState />;
if (album.assets.length === 0) { if (album.assets.length === 0) {
@@ -114,6 +117,20 @@ function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, sho
); );
} }
function NoAlbumSelected() {
const t = useI18n();
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertCircle size={32} />
<Text size="sm" fw={500}>
{t("widget.immich-albumCarousel.noAlbumSelected")}
</Text>
</Stack>
</Center>
);
}
function NoPhotosInAlbum() { function NoPhotosInAlbum() {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -1 +0,0 @@
export const ALL_PHOTOS_ALBUM_ID = "all";
@@ -2,26 +2,20 @@ import { IconPhoto } from "@tabler/icons-react";
import z from "zod"; import z from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createWidgetDefinition } from "../../definition"; import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options"; import { optionsBuilder } from "../../options";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", { export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto, icon: IconPhoto,
queryKey: [["widget", "immich"]],
refetchInterval: null,
supportedIntegrations: ["immich"], supportedIntegrations: ["immich"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({ albumId: factory.integrationSelect({
defaultValue: ALL_PHOTOS_ALBUM_ID,
withDescription: true, withDescription: true,
clearable: true, clearable: true,
useOptions: (integrationIds: string[]) => { useOptions: (integrationIds: string[]) => {
const t = useScopedI18n("widget.immich-albumCarousel");
const { const {
data = [], data = [],
isPending, isPending,
@@ -30,14 +24,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
{ integrationId: integrationIds[0] ?? "" }, { integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 }, { enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
); );
return { return { data: data.map((album) => ({ value: album.id, label: album.albumName })), isPending, isError };
data: [
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
...data.map((album) => ({ value: album.id, label: album.albumName })),
],
isPending,
isError,
};
}, },
}), }),
rotationIntervalSeconds: factory.number({ rotationIntervalSeconds: factory.number({
@@ -5,8 +5,6 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", { export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", {
icon: IconGraphFilled, icon: IconGraphFilled,
queryKey: [["widget", "immich"]],
refetchInterval: null,
supportedIntegrations: ["immich"], supportedIntegrations: ["immich"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", { export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch, icon: IconReportSearch,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({ openIndexerSiteInNewTab: factory.switch({
@@ -5,8 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", { export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", {
icon: IconTicket, icon: IconTicket,
queryKey: [["widget", "mediaRelease"]],
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
layout: factory.select({ layout: factory.select({
@@ -38,11 +38,6 @@ export default function MediaServerWidget({
}: WidgetComponentProps<"mediaRequests-requestList">) { }: WidgetComponentProps<"mediaRequests-requestList">) {
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds, integrationIds,
statuses:
options.statusFilter.length > 0
? options.statusFilter
: ["pending", "approved", "declined", "failed", "completed"],
recentDays: options.recentDays,
}); });
if (!mediaRequests) return <WidgetEmptyState />; if (!mediaRequests) return <WidgetEmptyState />;
@@ -1,5 +1,4 @@
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react"; import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight"; import { openMediaRequestSearch } from "@homarr/spotlight";
@@ -7,8 +6,6 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition"; import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options"; import { optionsBuilder } from "../../options";
const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", { export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion, icon: IconZoomQuestion,
createOptions() { createOptions() {
@@ -16,17 +13,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
linksTargetNewTab: factory.switch({ linksTargetNewTab: factory.switch({
defaultValue: true, 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 }) => [ contextActions: ({ integrationIds }) => [
+26 -108
View File
@@ -2,16 +2,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Fragment, useMemo } from "react"; import { Fragment, useMemo } from "react";
import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core"; import { Avatar, Divider, Flex, Group, Stack, Text, Title } from "@mantine/core";
import { import { IconDeviceTv, IconHeadphones, IconMovie, IconVideo } from "@tabler/icons-react";
IconDeviceTv,
IconHeadphones,
IconMovie,
IconPlayerPause,
IconVideo,
IconWifi,
IconWorld,
} from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table"; import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } 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>[]>( const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
() => [ () => [
{ {
accessorKey: "user.username", accessorKey: "sessionName",
header: t("items.user"), header: t("items.name"),
size: 160,
Cell: ({ row }) => ( Cell: ({ row }) => (
<Group gap="xs" wrap="nowrap"> <Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
<Avatar size={28} src={row.original.user.profilePictureUrl} /> {row.original.sessionName}
<Stack gap={0} style={{ minWidth: 0 }}> </Text>
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> ),
{row.original.user.username} },
</Text> {
<Text accessorKey: "user.username",
size="10px" header: t("items.user"),
c="dimmed"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} Cell: ({ row }) => (
> <Group gap="xs">
{row.original.sessionName} <Avatar size={20} src={row.original.user.profilePictureUrl} />
</Text> <Text size="xs">{row.original.user.username}</Text>
</Stack>
</Group> </Group>
), ),
}, },
@@ -63,85 +53,17 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
header: t("items.currentlyPlaying"), header: t("items.currentlyPlaying"),
Cell: ({ row }) => { Cell: ({ row }) => {
const currentlyPlaying = row.original.currentlyPlaying; if (!row.original.currentlyPlaying) return null;
if (!currentlyPlaying) return null;
const playback = currentlyPlaying.playback; const Icon = mediaTypeIconMap[row.original.currentlyPlaying.type];
const isPaused = playback?.state === "paused";
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
const positionMs = playback?.positionMs ?? null;
const durationMs = playback?.durationMs ?? null;
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;
return ( return (
<Stack gap={4} style={{ minWidth: 0 }}> <Group gap="xs" align="center">
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}> <Icon size={16} />
<Icon <Text size="xs" lineClamp={1}>
size={16} {row.original.currentlyPlaying.name}
color={isPaused ? "var(--mantine-color-yellow-6)" : undefined} </Text>
style={{ flexShrink: 0 }} </Group>
/>
<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>
); );
}, },
}, },
@@ -185,7 +107,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
initialState: { initialState: {
density: "xs", density: "xs",
columnPinning: { columnPinning: {
right: ["currentlyPlaying", "status"], right: ["currentlyPlaying"],
}, },
}, },
mantineTableHeadProps: { mantineTableHeadProps: {
@@ -222,10 +144,6 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
); );
}, },
py: 4, py: 4,
style: {
overflowX: "hidden",
overflowY: "visible",
},
}), }),
}); });
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", { export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo, icon: IconVideo,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }), 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", { export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform, icon: IconTransform,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
defaultView: factory.select({ defaultView: factory.select({
@@ -6,8 +6,6 @@ import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", { export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
icon: IconBrandMinecraft, icon: IconBrandMinecraft,
queryKey: [["widget", "minecraft"]],
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
title: factory.text({ defaultValue: "" }), title: factory.text({ defaultValue: "" }),
@@ -7,8 +7,6 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("networkControllerStatus", { export const { definition, componentLoader } = createWidgetDefinition("networkControllerStatus", {
icon: IconTopologyFull, icon: IconTopologyFull,
queryKey: [["widget", "networkController"]],
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
content: factory.select({ content: factory.select({
@@ -7,8 +7,6 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", { export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", {
icon: IconTopologyFull, icon: IconTopologyFull,
queryKey: [["widget", "networkController"]],
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from(() => ({})); return optionsBuilder.from(() => ({}));
}, },
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("notifications", { export const { componentLoader, definition } = createWidgetDefinition("notifications", {
icon: IconMessage, icon: IconMessage,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
hideLogos: factory.switch({ defaultValue: false }), hideLogos: factory.switch({ defaultValue: false }),
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", { export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
icon: IconFileText, icon: IconFileText,
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"], supportedIntegrations: ["paperlessNgx"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
-1
View File
@@ -14,7 +14,6 @@ const relativeDateSchema = z
export const { definition, componentLoader } = createWidgetDefinition("releases", { export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket, icon: IconRocket,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
newReleaseWithin: factory.text({ newReleaseWithin: factory.text({
-1
View File
@@ -12,7 +12,6 @@ import { optionsBuilder } from "../options";
*/ */
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", { export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss, icon: IconRss,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
feedUrls: factory.multiText({ feedUrls: factory.multiText({
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", { export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat, icon: IconSpeedboat,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
(factory) => ({ (factory) => ({
-1
View File
@@ -13,7 +13,6 @@ const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", { export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
icon: IconBuildingBank, icon: IconBuildingBank,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
stock: factory.text({ stock: factory.text({
@@ -6,8 +6,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", { export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
icon: IconServer2, icon: IconServer2,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"], supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
@@ -14,8 +14,6 @@ const labelDisplayModeOptions = {
export const { definition, componentLoader } = createWidgetDefinition("systemResources", { export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
icon: IconGraphFilled, icon: IconGraphFilled,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"], supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more