Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49120a7b59 | ||
|
|
ca592cccb8 | ||
|
|
4547fb2f79 | ||
|
|
181850f77f | ||
|
|
00a65bfc58 | ||
|
|
9bb0c0f811 | ||
|
|
cf0e469465 | ||
|
|
8e7fa782b3 | ||
|
|
97652802f0 | ||
|
|
1706289124 | ||
|
|
dd23de7aaa | ||
|
|
f1602f67d4 | ||
|
|
c03cd97d3a | ||
|
|
a8e9a074d3 | ||
|
|
1e9d3e0656 | ||
|
|
01e0f3612d | ||
|
|
0015a6d88d | ||
|
|
77dc317da2 | ||
|
|
6aeda3faaf | ||
|
|
ca358d9f8b | ||
|
|
03e0683892 | ||
|
|
6ac4c6a7b9 | ||
|
|
a0e0bf65ff | ||
|
|
bc018e1fe0 | ||
|
|
5e39c8813a | ||
|
|
06719cdef0 | ||
|
|
5144e14da4 | ||
|
|
8528d5068b | ||
|
|
c6c4428f7d | ||
|
|
7e4cb0201b | ||
|
|
7887ce12c0 | ||
|
|
2d450239ac | ||
|
|
bf4ccd8cb3 | ||
|
|
c473e80243 | ||
|
|
bf55bcce92 | ||
|
|
49248cedaf |
No files matched your search
@@ -23,7 +23,6 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
TURBO_PLATFORM: linux/amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -81,9 +81,6 @@ jobs:
|
||||
- name: Copy environment defaults
|
||||
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
|
||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
|
||||
|
||||
@@ -93,12 +90,8 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Report coverage
|
||||
if: always() && !env.ACT
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
|
||||
container:
|
||||
name: Container, E2E, and memory
|
||||
name: Container and E2E
|
||||
needs: fast-gate
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -111,7 +104,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
@@ -120,11 +113,11 @@ jobs:
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build container once
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -165,7 +158,7 @@ jobs:
|
||||
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
@@ -191,82 +184,6 @@ jobs:
|
||||
path: e2e/test-results/
|
||||
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
|
||||
id: preview
|
||||
if: |
|
||||
@@ -316,7 +233,7 @@ jobs:
|
||||
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
|
||||
|
||||
- name: Delete GHCR package version
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
## [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.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"eslint/no-unused-expressions": "warn"
|
||||
},
|
||||
"options": {
|
||||
"reportUnusedDisableDirectives": "off"
|
||||
},
|
||||
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
|
||||
"settings": {
|
||||
"react": { "version": "19" }
|
||||
|
||||
@@ -127,6 +127,8 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
|
||||
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
|
||||
|
||||
To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
Example Setup
|
||||
@@ -204,6 +206,7 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
|
||||
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
|
||||
| ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic |
|
||||
|
||||
### Permission System
|
||||
|
||||
@@ -50,12 +50,11 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
|
||||
<code>user.read</code>,{" "}
|
||||
<code>user.update</code>,{" "}
|
||||
<code>asset.read</code>,{" "}
|
||||
<code>asset.view</code>,{" "}
|
||||
<code>asset.download</code>,{" "}
|
||||
<code>asset.statistics</code>,{" "}
|
||||
<code>server.statistics</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>
|
||||
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Immich Album Carousel'
|
||||
description: 'Shows a slideshow of your album pictures from Immich'
|
||||
description: 'Shows a slideshow of pictures from an Immich album or your full library'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
@@ -37,4 +37,3 @@ import screen from './img/screen.png';
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
|
||||
|
||||
@@ -4,15 +4,16 @@ import { IconPhoto } from "@tabler/icons-react";
|
||||
export const immichAlbumCarouselWidget: WidgetDefinition = {
|
||||
icon: IconPhoto,
|
||||
name: "Immich Album",
|
||||
description: "Shows a slideshow of your album pictures from Immich",
|
||||
description: "Shows a slideshow of pictures from an Immich album or your full library",
|
||||
path: "../../widgets/immich-album-carousel",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Album",
|
||||
description: "Select the Immich album to show in the slideshow.",
|
||||
description:
|
||||
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
|
||||
values: { type: "string" },
|
||||
defaultValue: "",
|
||||
defaultValue: "All photos",
|
||||
},
|
||||
{
|
||||
name: "Interval in seconds",
|
||||
|
||||
@@ -1,41 +1,44 @@
|
||||
---
|
||||
title: 'Media Request List'
|
||||
description: 'See a list of all media requests from your integration'
|
||||
title: "Media Request List"
|
||||
description: "See a list of all media requests from your integration"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { IconAd } from "@tabler/icons-react";
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import { mediaRequestListWidget } from '.';
|
||||
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
|
||||
import { overseerrIntegration } from '@site/docs/integrations/overseerr';
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { mediaRequestListWidget } from ".";
|
||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={mediaRequestListWidget}
|
||||
categories={['Media requests']}
|
||||
/>
|
||||
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
||||
|
||||
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
|
||||
|
||||
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import mediaRequestList from './img/list.png';
|
||||
import mediaRequestList from "./img/list.png";
|
||||
|
||||
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: jellyseerrIntegration,
|
||||
}, {
|
||||
integration: overseerrIntegration,
|
||||
}]} />
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: jellyseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Interactions
|
||||
|
||||
@@ -47,4 +50,5 @@ import mediaRequestList from './img/list.png';
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={mediaRequestListWidget.configuration} />
|
||||
@@ -14,6 +14,18 @@ export const mediaRequestListWidget: WidgetDefinition = {
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Statuses to show",
|
||||
description: "Only requests with these statuses will be shown. Defaults to all statuses.",
|
||||
values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'",
|
||||
defaultValue: "All statuses",
|
||||
},
|
||||
{
|
||||
name: "Show only recent requests",
|
||||
description: "Limit to requests created within this many days. Set to 0 to disable the time filter.",
|
||||
values: "0-365",
|
||||
defaultValue: "0",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -43,7 +43,7 @@
|
||||
"random-word-slugs": "^0.1.7",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"swiper": "^14.0.2",
|
||||
"swiper": "^14.0.5",
|
||||
"tailwindcss": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -93,10 +93,11 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
});
|
||||
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
|
||||
for (const { definition } of Object.values(widgetImports)) {
|
||||
const def = definition as { refetchIntervalSeconds?: number; queryKey?: QueryKey; kind: string };
|
||||
if (!def.refetchIntervalSeconds) continue;
|
||||
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
|
||||
if (def.refetchInterval === undefined) continue;
|
||||
const key = def.queryKey ?? [["widget", def.kind]];
|
||||
client.setQueryDefaults(key, { refetchInterval: def.refetchIntervalSeconds * 1000 });
|
||||
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
|
||||
client.setQueryDefaults(key, { refetchInterval: interval });
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
@@ -16,7 +16,10 @@ export default async function AppEditPage(props: AppEditPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||
if (
|
||||
!session?.user.permissions.includes("board-modify-all") ||
|
||||
!session?.user.permissions.includes("app-modify-all")
|
||||
) {
|
||||
notFound();
|
||||
}
|
||||
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() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("app-create")) {
|
||||
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -33,6 +33,9 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
if (!session.user.permissions.includes("board-modify-all")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
hidden: !session,
|
||||
hidden: !session?.user.permissions.includes("board-modify-all"),
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
|
||||
@@ -193,6 +193,10 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
positionToolbarAlertBanner: "top",
|
||||
enableTableFooter: 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",
|
||||
mantineSearchTextInputProps: {
|
||||
placeholder: tDocker("table.search", { count: String(containers.length) }),
|
||||
|
||||
+18
@@ -31,6 +31,7 @@ import {
|
||||
userChangeHomeBoardsSchema,
|
||||
userChangeSearchPreferencesSchema,
|
||||
userEditProfileSchema,
|
||||
userEnableRightClickOnWidgetsSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userPingIconsEnabledSchema,
|
||||
} from "@homarr/validation/user";
|
||||
@@ -51,6 +52,7 @@ const userGeneralSettingsSchema = z.object({
|
||||
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
|
||||
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
||||
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
||||
@@ -78,6 +80,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
|
||||
ddgBangsEnabled: user.ddgBangs,
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
pingIconsEnabled: user.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
|
||||
});
|
||||
|
||||
export const UserGeneralSettingsForm = ({
|
||||
@@ -95,6 +98,7 @@ export const UserGeneralSettingsForm = ({
|
||||
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
|
||||
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
||||
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
||||
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
|
||||
|
||||
const initialValues = buildInitialValues(user);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
@@ -114,6 +118,7 @@ export const UserGeneralSettingsForm = ({
|
||||
changeSearchPreferencesMutation,
|
||||
changeFirstDayOfWeekMutation,
|
||||
changePingIconsEnabledMutation,
|
||||
changeEnableRightClickOnWidgetsMutation,
|
||||
];
|
||||
const isPending = mutations.some((m) => m.isPending);
|
||||
|
||||
@@ -169,6 +174,14 @@ export const UserGeneralSettingsForm = ({
|
||||
action: () =>
|
||||
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());
|
||||
@@ -305,6 +318,11 @@ export const UserGeneralSettingsForm = ({
|
||||
label={t("user.field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.enableRightClickOnWidgets.label")}
|
||||
description={t("user.field.enableRightClickOnWidgets.description")}
|
||||
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ImageProxy } from "@homarr/image-proxy";
|
||||
|
||||
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await props.params;
|
||||
|
||||
const imageProxy = new ImageProxy();
|
||||
const image = await imageProxy.forwardImageAsync(id);
|
||||
if (!image) {
|
||||
notFound();
|
||||
const result = await imageProxy.forwardImageAsync(id);
|
||||
|
||||
if ("error" in result) {
|
||||
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(image, {
|
||||
return new Response(result.image, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
|
||||
...(result.contentType ? { "Content-Type": result.contentType } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
|
||||
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconCopy,
|
||||
IconLayoutKanban,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -11,10 +19,12 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
@@ -38,6 +48,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
const tMenu = useScopedI18n("item.menu.label");
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -158,6 +169,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
|
||||
if (!session) return <>{children}</>;
|
||||
if (!isRightClickEnabled) return <>{children}</>;
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||
|
||||
@@ -232,11 +244,14 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
{tMenu("refresh")}
|
||||
<Text size="xs" c="dimmed">
|
||||
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
|
||||
</Text>
|
||||
<WidgetQueryStatus
|
||||
queryClient={queryClient}
|
||||
queryKey={widgetQueryKey}
|
||||
isFetching={isWidgetFetching}
|
||||
t={t}
|
||||
/>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
@@ -273,34 +288,65 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetCacheAge = ({
|
||||
queryClient,
|
||||
queryKey,
|
||||
isFetching,
|
||||
}: {
|
||||
interface WidgetQueryStatusProps {
|
||||
queryClient: QueryClient;
|
||||
queryKey: QueryKey;
|
||||
isFetching: boolean;
|
||||
}) => {
|
||||
// 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);
|
||||
}, []);
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
if (isFetching) return <Loader size={12} />;
|
||||
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
|
||||
const queries = queryClient.getQueryCache().findAll({ queryKey });
|
||||
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
|
||||
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
||||
const ageLabel = useTimeAgo(new Date(latest));
|
||||
|
||||
const timestamps = queryClient
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey })
|
||||
.map((q) => q.state.dataUpdatedAt)
|
||||
.filter(Boolean);
|
||||
if (timestamps.length === 0) return null;
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Loader size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.loading")}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const latest = Math.max(...timestamps);
|
||||
const seconds = Math.floor((Date.now() - latest) / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (queries.length === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.idle")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const hasError = queries.some((q) => q.state.status === "error");
|
||||
|
||||
if (hasError) {
|
||||
const errorQuery = queries.find((q) => q.state.status === "error");
|
||||
const errorMessage =
|
||||
errorQuery?.state.error instanceof Error
|
||||
? errorQuery.state.error.message
|
||||
: String(errorQuery?.state.error ?? "Unknown error");
|
||||
|
||||
return (
|
||||
<Tooltip label={errorMessage} multiline position="left" w={250}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" c="red.6">
|
||||
{t("item.menu.status.error")} · {ageLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.success")} · {ageLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.10.0"
|
||||
"pnpm": ">=11.13.1"
|
||||
},
|
||||
"packageManager": "pnpm@11.10.0"
|
||||
"packageManager": "pnpm@11.13.1"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.70.0",
|
||||
"version": "1.71.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -69,7 +69,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.10.0"
|
||||
"pnpm": ">=11.13.1"
|
||||
},
|
||||
"packageManager": "pnpm@11.10.0"
|
||||
"packageManager": "pnpm@11.13.1"
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import { AppAccessControl } from "./app/app-access-control";
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("board-modify-all")
|
||||
.input(paginatedSchema)
|
||||
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
||||
.meta({
|
||||
|
||||
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user) {
|
||||
if (ctx.session?.user.permissions.includes("board-modify-all")) {
|
||||
statistics.push({
|
||||
titleKey: "app",
|
||||
subtitleKey: "resources",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { asc, eq, like } from "@homarr/db";
|
||||
import { and, asc, eq, like } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { searchEngines, users } from "@homarr/db/schema";
|
||||
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
|
||||
@@ -130,9 +130,15 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
|
||||
return null;
|
||||
}),
|
||||
search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.searchEngines.findMany({
|
||||
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
|
||||
// Public dashboards have no session: restrict anonymous users to generic
|
||||
// (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: {
|
||||
integration: {
|
||||
columns: {
|
||||
|
||||
@@ -76,6 +76,33 @@ 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", () => {
|
||||
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -357,6 +357,83 @@ 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) => {
|
||||
await db.insert(onboarding).values({
|
||||
id: createId(),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
userCreateSchema,
|
||||
userDdgBangsSchema,
|
||||
userEditProfileSchema,
|
||||
userEnableRightClickOnWidgetsSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userInitSchema,
|
||||
userPingIconsEnabledSchema,
|
||||
@@ -345,6 +346,7 @@ export const userRouter = createTRPCRouter({
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -381,6 +383,7 @@ export const userRouter = createTRPCRouter({
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -651,6 +654,32 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.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
|
||||
.input(userPingIconsEnabledSchema.and(byIdSchema))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -25,7 +25,7 @@ export const immichRouter = createTRPCRouter({
|
||||
.concat(createImmichIntegrationMiddleware("query"))
|
||||
.input(
|
||||
z.object({
|
||||
albumId: z.string(),
|
||||
albumId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { z } from "zod/v4";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||
import {
|
||||
mediaRequestListInputSchema,
|
||||
mediaRequestListRequestHandler,
|
||||
} from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
@@ -16,13 +19,14 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"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)",
|
||||
"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).",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.query(async ({ ctx }) => {
|
||||
.input(mediaRequestListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync();
|
||||
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
|
||||
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
|
||||
});
|
||||
return results
|
||||
|
||||
@@ -3,6 +3,7 @@ import { escapeForRegEx } from "@tiptap/react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, items, widgetSecrets } from "@homarr/db/schema";
|
||||
import { releaseProviderKinds } from "@homarr/definitions";
|
||||
@@ -11,6 +12,8 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
|
||||
const logger = createLogger({ module: "releasesRouter" });
|
||||
|
||||
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
|
||||
if (!versionFilter) return undefined;
|
||||
|
||||
@@ -71,13 +74,13 @@ export const releasesRouter = createTRPCRouter({
|
||||
for (const secret of secrets) {
|
||||
try {
|
||||
tokensByProvider.set(secret.kind, decryptSecret(secret.value));
|
||||
} catch {
|
||||
// Skip corrupt secrets
|
||||
} catch (error) {
|
||||
logger.warn("Failed to decrypt widget secret", { itemId: input.itemId, kind: secret.kind, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Access denied or table not yet migrated -- proceed without tokens
|
||||
} catch (error) {
|
||||
logger.warn("Failed to load release widget tokens", { itemId: input.itemId, error });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,20 +6,24 @@ import { and, eq } from "@homarr/db";
|
||||
import { accounts, sessions, users } from "@homarr/db/schema";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
|
||||
export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
|
||||
export const createAdapter = (
|
||||
db: Database,
|
||||
provider: SupportedAuthProvider | "unknown",
|
||||
enableDangerousCredentialsLinking = false,
|
||||
): Adapter => {
|
||||
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
|
||||
|
||||
return {
|
||||
...drizzleAdapter,
|
||||
// We override the default implementation as we want to have a provider
|
||||
// flag in the user instead of the account to not intermingle users from different providers
|
||||
// Keep users isolated by their primary provider. The explicit credentials-linking
|
||||
// opt-in is the only exception and keeps credentials as the primary provider.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getUserByEmail: async (email) => {
|
||||
if (provider === "unknown") {
|
||||
throw new Error("Unable to get user by email for unknown provider");
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
let user = await db.query.users.findFirst({
|
||||
where: and(eq(users.email, email), eq(users.provider, provider)),
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -30,6 +34,22 @@ export const createAdapter = (db: Database, provider: SupportedAuthProvider | "u
|
||||
},
|
||||
});
|
||||
|
||||
if (!user && provider === "oidc" && enableDangerousCredentialsLinking) {
|
||||
// Credentials accounts do not have an email-verification flow, so their
|
||||
// Auth.js emailVerified field is always null. The OIDC profile's strict
|
||||
// email_verified check is the security boundary for this opt-in.
|
||||
user = await db.query.users.findFirst({
|
||||
where: and(eq(users.email, email), eq(users.provider, "credentials")),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const createConfiguration = (
|
||||
headers: ReadonlyHeaders | null,
|
||||
useSecureCookies: boolean,
|
||||
) => {
|
||||
const adapter = createAdapter(db, provider);
|
||||
const adapter = createAdapter(db, provider, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING);
|
||||
return NextAuth({
|
||||
logger: {
|
||||
error: (error) => {
|
||||
|
||||
@@ -46,6 +46,7 @@ export const env = createEnv({
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
|
||||
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: createBooleanSchema(false),
|
||||
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: z
|
||||
.enum(["client_secret_basic", "client_secret_post", "client_secret_jwt", "none"])
|
||||
.default("client_secret_basic"),
|
||||
|
||||
@@ -24,6 +24,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
name: true,
|
||||
image: true,
|
||||
colorScheme: true,
|
||||
provider: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,6 +34,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
// Groups from oidc provider are provided from the profile, it's not typed.
|
||||
if (
|
||||
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
|
||||
dbUser.provider === "oidc" &&
|
||||
profile &&
|
||||
groupsKey in profile &&
|
||||
Array.isArray(profile[groupsKey])
|
||||
@@ -57,7 +59,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
});
|
||||
}
|
||||
|
||||
if (profile) {
|
||||
if (profile && dbUser.provider === "oidc") {
|
||||
const profileUsername = extractProfileName(profile);
|
||||
if (!profileUsername) {
|
||||
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
|
||||
|
||||
import { env } from "../../env";
|
||||
import { createRedirectUri } from "../../redirect";
|
||||
import { assertVerifiedEmailForCredentialsLinking } from "./verified-email";
|
||||
|
||||
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
|
||||
id: "oidc",
|
||||
@@ -18,7 +19,8 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
|
||||
token_endpoint_auth_method: env.AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD,
|
||||
},
|
||||
issuer: env.AUTH_OIDC_ISSUER,
|
||||
allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING,
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING || env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: env.AUTH_OIDC_SCOPE_OVERWRITE,
|
||||
@@ -55,6 +57,7 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
|
||||
if (!profile.sub) {
|
||||
throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`);
|
||||
}
|
||||
assertVerifiedEmailForCredentialsLinking(profile, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING);
|
||||
const name = extractProfileName(profile);
|
||||
if (!name) {
|
||||
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const assertVerifiedEmailForCredentialsLinking = (
|
||||
profile: { email?: unknown; email_verified?: unknown },
|
||||
isCredentialsLinkingEnabled: boolean,
|
||||
) => {
|
||||
if (
|
||||
isCredentialsLinkingEnabled &&
|
||||
(profile.email_verified !== true || typeof profile.email !== "string" || profile.email.trim().length === 0)
|
||||
) {
|
||||
throw new Error("OIDC provider did not return a verified email while credentials linking is enabled");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { OidcProvider } from "../oidc/oidc-provider";
|
||||
import { assertVerifiedEmailForCredentialsLinking } from "../oidc/verified-email";
|
||||
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
AUTH_OIDC_CLIENT_ID: "client-id",
|
||||
AUTH_OIDC_CLIENT_NAME: "OIDC",
|
||||
AUTH_OIDC_CLIENT_SECRET: "client-secret",
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: false,
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: false,
|
||||
AUTH_OIDC_FORCE_USERINFO: false,
|
||||
AUTH_OIDC_ISSUER: "https://example.com",
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: undefined,
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: "openid email profile",
|
||||
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: "client_secret_basic" as const,
|
||||
}));
|
||||
|
||||
vi.mock("../../env", () => ({ env: mockEnv }));
|
||||
vi.mock("next-auth", () => ({ customFetch: Symbol("customFetch") }));
|
||||
|
||||
afterEach(() => {
|
||||
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = false;
|
||||
});
|
||||
|
||||
describe("assertVerifiedEmailForCredentialsLinking", () => {
|
||||
test("allows a strictly verified OIDC email when credentials linking is enabled", () => {
|
||||
expect(() =>
|
||||
assertVerifiedEmailForCredentialsLinking({ email: "test@example.com", email_verified: true }, true),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ email: "test@example.com", email_verified: false },
|
||||
{ email: "test@example.com", email_verified: "true" },
|
||||
{ email_verified: true },
|
||||
{ email: null, email_verified: true },
|
||||
{ email: " ", email_verified: true },
|
||||
{},
|
||||
])("rejects an OIDC profile without a strictly verified email when credentials linking is enabled", (profile) => {
|
||||
expect(() => assertVerifiedEmailForCredentialsLinking(profile, true)).toThrow(
|
||||
"OIDC provider did not return a verified email while credentials linking is enabled",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not require the claim when credentials linking is disabled", () => {
|
||||
expect(() => assertVerifiedEmailForCredentialsLinking({}, false)).not.toThrow();
|
||||
});
|
||||
|
||||
test("OIDC provider rejects an unverified profile when credentials linking is enabled", () => {
|
||||
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = true;
|
||||
const provider = OidcProvider(null);
|
||||
expect(provider.allowDangerousEmailAccountLinking).toBe(true);
|
||||
const mapProfile = provider.profile;
|
||||
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
|
||||
|
||||
expect(() =>
|
||||
mapProfile(
|
||||
{
|
||||
sub: "subject",
|
||||
preferred_username: "test",
|
||||
email: "test@example.com",
|
||||
email_verified: false,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toThrow("OIDC provider did not return a verified email while credentials linking is enabled");
|
||||
});
|
||||
|
||||
test("OIDC provider accepts an unverified profile when credentials linking is disabled", () => {
|
||||
const provider = OidcProvider(null);
|
||||
const mapProfile = provider.profile;
|
||||
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
|
||||
|
||||
expect(() =>
|
||||
mapProfile(
|
||||
{
|
||||
sub: "subject",
|
||||
preferred_username: "test",
|
||||
email: "test@example.com",
|
||||
email_verified: false,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { users } from "@homarr/db/schema";
|
||||
import { eq } from "@homarr/db";
|
||||
import { accounts, apiKeys, groupMembers, groups, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { createAdapter } from "../adapter";
|
||||
@@ -64,4 +65,91 @@ describe("createAdapter should create drizzle adapter", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Unable to get user by email for unknown provider");
|
||||
});
|
||||
|
||||
test("getUserByEmail should return a credentials user for oidc when credentials linking is enabled", async () => {
|
||||
const db = createDb();
|
||||
const adapter = createAdapter(db, "oidc", true);
|
||||
const email = "test@example.com";
|
||||
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
|
||||
|
||||
const user = await adapter.getUserByEmail?.(email);
|
||||
|
||||
expect(user?.id).toBe("credentials-user");
|
||||
});
|
||||
|
||||
test("getUserByEmail should not return credentials users for non-oidc providers", async () => {
|
||||
const db = createDb();
|
||||
const adapter = createAdapter(db, "ldap", true);
|
||||
const email = "test@example.com";
|
||||
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
|
||||
|
||||
const user = await adapter.getUserByEmail?.(email);
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
test("getUserByEmail should prefer an existing oidc user over a credentials user", async () => {
|
||||
const db = createDb();
|
||||
const adapter = createAdapter(db, "oidc", true);
|
||||
const email = "test@example.com";
|
||||
await db.insert(users).values([
|
||||
{ id: "credentials-user", name: "credentials", email, provider: "credentials" },
|
||||
{ id: "oidc-user", name: "oidc", email, provider: "oidc" },
|
||||
]);
|
||||
|
||||
const user = await adapter.getUserByEmail?.(email);
|
||||
|
||||
expect(user?.id).toBe("oidc-user");
|
||||
});
|
||||
|
||||
test("linkAccount should preserve the credentials user and their data", async () => {
|
||||
const db = createDb();
|
||||
const adapter = createAdapter(db, "oidc", true);
|
||||
const email = "test@example.com";
|
||||
await db.insert(users).values({
|
||||
id: "credentials-user",
|
||||
name: "test",
|
||||
email,
|
||||
password: "hashed-password",
|
||||
provider: "credentials",
|
||||
colorScheme: "light",
|
||||
completedBoardTour: true,
|
||||
});
|
||||
await db.insert(groups).values({ id: "group", name: "Test group", position: 1 });
|
||||
await db.insert(groupMembers).values({ groupId: "group", userId: "credentials-user" });
|
||||
await db.insert(apiKeys).values({ id: "api-key", apiKey: "secret", userId: "credentials-user" });
|
||||
|
||||
const user = await adapter.getUserByEmail?.(email);
|
||||
if (!user) throw new Error("Expected credentials user to be found for OIDC linking");
|
||||
await adapter.linkAccount?.({
|
||||
userId: user.id,
|
||||
type: "oidc",
|
||||
provider: "oidc",
|
||||
providerAccountId: "oidc-subject",
|
||||
});
|
||||
|
||||
const linkedUser = await db.query.users.findFirst({ where: eq(users.id, "credentials-user") });
|
||||
const account = await db.query.accounts.findFirst({ where: eq(accounts.providerAccountId, "oidc-subject") });
|
||||
const membership = await db.query.groupMembers.findFirst({
|
||||
where: eq(groupMembers.userId, "credentials-user"),
|
||||
});
|
||||
const apiKey = await db.query.apiKeys.findFirst({ where: eq(apiKeys.userId, "credentials-user") });
|
||||
const userByAccount = await adapter.getUserByAccount?.({
|
||||
provider: "oidc",
|
||||
providerAccountId: "oidc-subject",
|
||||
});
|
||||
const allUsers = await db.query.users.findMany();
|
||||
expect(linkedUser).toMatchObject({
|
||||
id: "credentials-user",
|
||||
password: "hashed-password",
|
||||
provider: "credentials",
|
||||
colorScheme: "light",
|
||||
completedBoardTour: true,
|
||||
});
|
||||
expect(account?.userId).toBe("credentials-user");
|
||||
expect(membership?.groupId).toBe("group");
|
||||
expect(apiKey?.id).toBe("api-key");
|
||||
expect(userByAccount?.id).toBe("credentials-user");
|
||||
expect(allUsers).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -135,7 +135,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
test("should add missing group membership", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db);
|
||||
await createUserAsync(db, "oidc");
|
||||
await createGroupAsync(db);
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
test("should remove group membership", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db);
|
||||
await createUserAsync(db, "oidc");
|
||||
await createGroupAsync(db);
|
||||
await db.insert(groupMembers).values({
|
||||
userId: "1",
|
||||
@@ -179,7 +179,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
test("should not remove group membership for everyone group", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db);
|
||||
await createUserAsync(db, "oidc");
|
||||
await createGroupAsync(db, everyoneGroup);
|
||||
await db.insert(groupMembers).values({
|
||||
userId: "1",
|
||||
@@ -204,7 +204,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
// Arrange
|
||||
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = true;
|
||||
const db = createDb();
|
||||
await createUserAsync(db);
|
||||
await createUserAsync(db, "oidc");
|
||||
await createGroupAsync(db);
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
@@ -229,10 +229,10 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
["ldap" as const, { name: "test-new" }, undefined],
|
||||
["oidc" as const, { name: "test" }, { preferred_username: "test-new" }],
|
||||
["oidc" as const, { name: "test" }, { preferred_username: "test@example.com", name: "test-new" }],
|
||||
])("signInEventHandler should update username for %s provider", async (_provider, user, profile) => {
|
||||
])("signInEventHandler should update username for %s provider", async (provider, user, profile) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db);
|
||||
await createUserAsync(db, provider);
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
// Act
|
||||
@@ -251,6 +251,32 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
});
|
||||
expect(dbUser?.name).toBe("test-new");
|
||||
});
|
||||
test("signInEventHandler should preserve credentials-owned data when the user signs in with linked oidc", async () => {
|
||||
const db = createDb();
|
||||
await createUserAsync(db, "credentials", "local-avatar");
|
||||
await createGroupAsync(db);
|
||||
await db.insert(groupMembers).values({ userId: "1", groupId: "1" });
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
await eventHandler?.({
|
||||
user: { id: "1", name: "test" },
|
||||
profile: {
|
||||
preferred_username: "oidc-name",
|
||||
picture: "oidc-avatar",
|
||||
someRandomGroupsKey: [],
|
||||
},
|
||||
account: null,
|
||||
});
|
||||
|
||||
const dbUser = await db.query.users.findFirst({ where: eq(users.id, "1") });
|
||||
const membership = await db.query.groupMembers.findFirst({ where: eq(groupMembers.userId, "1") });
|
||||
expect(dbUser).toMatchObject({
|
||||
name: "test",
|
||||
image: "local-avatar",
|
||||
provider: "credentials",
|
||||
});
|
||||
expect(membership?.groupId).toBe("1");
|
||||
});
|
||||
test("signInEventHandler should set color-scheme cookie", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
@@ -275,11 +301,17 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const createUserAsync = async (db: Database) =>
|
||||
const createUserAsync = async (
|
||||
db: Database,
|
||||
provider: "credentials" | "ldap" | "oidc" = "credentials",
|
||||
image?: string,
|
||||
) =>
|
||||
await db.insert(users).values({
|
||||
id: "1",
|
||||
name: "test",
|
||||
colorScheme: "dark",
|
||||
image,
|
||||
provider,
|
||||
});
|
||||
|
||||
const createGroupAsync = async (db: Database, name = "test") =>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
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,6 +302,13 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0042_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "5",
|
||||
"when": 1783852800000,
|
||||
"tag": "0043_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
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,6 +64,13 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0008_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1783786677702,
|
||||
"tag": "0009_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
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,6 +288,13 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0040_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1783786658943,
|
||||
"tag": "0041_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export const users = mysqlTable("user", {
|
||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean().default(false).notNull(),
|
||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||
completedManageTour: boolean().default(false).notNull(),
|
||||
completedBoardTour: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -85,6 +85,7 @@ export const users = pgTable("user", {
|
||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean().default(false).notNull(),
|
||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||
completedManageTour: boolean().default(false).notNull(),
|
||||
completedBoardTour: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ export const users = sqliteTable("user", {
|
||||
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
|
||||
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
|
||||
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -37,5 +37,5 @@ describe("Mysql Migration", () => {
|
||||
|
||||
connection.end();
|
||||
await mysqlContainer.stop();
|
||||
}, 40_000);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -467,7 +467,6 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
|
||||
category: ["archiving"],
|
||||
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)
|
||||
mock: {
|
||||
|
||||
@@ -19,6 +19,12 @@ const createUrlByIdChannel = (id: string) =>
|
||||
headers: `${string}.${string}`;
|
||||
}>(`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 {
|
||||
private static hmacKey: Buffer<ArrayBuffer> | null = null;
|
||||
|
||||
@@ -47,17 +53,34 @@ export class ImageProxy {
|
||||
return this.createImageUrl(id);
|
||||
}
|
||||
|
||||
public async forwardImageAsync(id: string): Promise<ArrayBuffer | null> {
|
||||
public async forwardImageAsync(id: string): Promise<ForwardImageResult> {
|
||||
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
|
||||
if (!urlAndHeaders) {
|
||||
return null;
|
||||
return { error: "not-found" };
|
||||
}
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
|
||||
headers: urlAndHeaders.headers ?? {},
|
||||
});
|
||||
|
||||
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) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata("Failed to fetch image", {
|
||||
@@ -68,7 +91,7 @@ export class ImageProxy {
|
||||
statusCode: response.status,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
return { error: "upstream-error", statusCode: response.status };
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
@@ -80,7 +103,7 @@ export class ImageProxy {
|
||||
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
|
||||
});
|
||||
|
||||
return arrayBuffer;
|
||||
return { image: arrayBuffer, contentType: response.headers.get("content-type") };
|
||||
}
|
||||
|
||||
private createImageUrl(id: string): string {
|
||||
|
||||
@@ -117,6 +117,12 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
state: null,
|
||||
positionMs: null,
|
||||
durationMs: null,
|
||||
},
|
||||
location: null,
|
||||
metadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
AssetMediaSize,
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
getAlbumInfo,
|
||||
getAllAlbums,
|
||||
getMyUser,
|
||||
getServerStatistics,
|
||||
init,
|
||||
searchAssets,
|
||||
searchRandom,
|
||||
searchUsers,
|
||||
} from "@immich/sdk";
|
||||
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
|
||||
@@ -42,6 +46,7 @@ export interface ImmichAsset {
|
||||
}
|
||||
|
||||
const logger = createLogger({ module: "immich-integration" });
|
||||
const randomPhotoCount = 50;
|
||||
|
||||
export class ImmichIntegration extends Integration {
|
||||
constructor(integration: IntegrationInput) {
|
||||
@@ -62,36 +67,36 @@ export class ImmichIntegration extends Integration {
|
||||
};
|
||||
}
|
||||
|
||||
public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
|
||||
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> {
|
||||
this.initClient();
|
||||
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([
|
||||
getAlbumInfo({ id: albumId }, requestOptions),
|
||||
this.searchAlbumAssetsAsync(albumId),
|
||||
]);
|
||||
const imageProxy = new ImageProxy();
|
||||
return {
|
||||
albumName: album.albumName,
|
||||
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,
|
||||
};
|
||||
}),
|
||||
),
|
||||
assets: await this.createAssetsAsync(albumAssets),
|
||||
id: album.id,
|
||||
};
|
||||
}
|
||||
@@ -133,6 +138,32 @@ export class ImmichIntegration extends Integration {
|
||||
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) {
|
||||
return {
|
||||
// Undici and node types are not the same
|
||||
|
||||
@@ -13,6 +13,12 @@ export interface StreamSession {
|
||||
episodeName?: string | null;
|
||||
albumName?: string | null;
|
||||
episodeCount?: number | null;
|
||||
playback: {
|
||||
state: "playing" | "paused" | "buffering" | null;
|
||||
positionMs: number | null;
|
||||
durationMs: number | null;
|
||||
};
|
||||
location: "lan" | "wan" | null;
|
||||
metadata: {
|
||||
video: {
|
||||
resolution: {
|
||||
|
||||
@@ -17,6 +17,10 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
|
||||
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
|
||||
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
|
||||
|
||||
function ticksToMs(ticks: number | null | undefined): number | null {
|
||||
return ticks ? Math.round(ticks / 10_000) : null;
|
||||
}
|
||||
|
||||
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
||||
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||
@@ -50,6 +54,9 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||
|
||||
if (sessionInfo.NowPlayingItem) {
|
||||
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
|
||||
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
|
||||
|
||||
currentlyPlaying = {
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
@@ -57,6 +64,12 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing",
|
||||
positionMs,
|
||||
durationMs,
|
||||
},
|
||||
location: null,
|
||||
metadata: {
|
||||
video: {
|
||||
resolution:
|
||||
|
||||
@@ -13,6 +13,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -27,6 +29,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: "Ozymandias",
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -41,6 +45,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -55,6 +61,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: "The We We Are",
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -69,6 +77,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: "Random Access Memories",
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -83,6 +93,8 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -153,9 +153,9 @@ export class OverseerrIntegration
|
||||
);
|
||||
} else if (pendingResults.length > 0) requests = pendingResults;
|
||||
else if (allResults.length > 0) requests = allResults;
|
||||
else return Promise.all([]);
|
||||
else return [];
|
||||
|
||||
return await Promise.all(
|
||||
const settled = await Promise.allSettled(
|
||||
requests.map(async (request): Promise<MediaRequest> => {
|
||||
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
|
||||
|
||||
@@ -184,6 +184,10 @@ export class OverseerrIntegration
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return settled
|
||||
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
|
||||
.map((result) => result.value);
|
||||
}
|
||||
|
||||
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
|
||||
@@ -301,6 +305,10 @@ export class OverseerrIntegration
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (type === "tv") {
|
||||
const series = (await response.json()) as TvInformation;
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,54 @@ interface MediaContainer {
|
||||
Track?: Session[];
|
||||
}
|
||||
|
||||
interface Stream {
|
||||
$: {
|
||||
streamType: string;
|
||||
codec?: string;
|
||||
language?: string;
|
||||
selected?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
frameRate?: string;
|
||||
channels?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Part {
|
||||
Stream?: Stream[];
|
||||
$: {
|
||||
container?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Media {
|
||||
Part?: Part[];
|
||||
$: {
|
||||
videoResolution?: string;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
bitrate?: string;
|
||||
container?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TranscodeSession {
|
||||
$: {
|
||||
throttled?: string;
|
||||
progress?: string;
|
||||
speed?: string;
|
||||
videoDecision?: string;
|
||||
audioDecision?: string;
|
||||
sourceVideoCodec?: string;
|
||||
sourceAudioCodec?: string;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
container?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Session {
|
||||
User?: {
|
||||
$: {
|
||||
@@ -15,13 +63,20 @@ interface Session {
|
||||
$: {
|
||||
product: string;
|
||||
title: string;
|
||||
address?: string;
|
||||
state?: string;
|
||||
local?: string;
|
||||
};
|
||||
}[];
|
||||
Session?: {
|
||||
$: {
|
||||
id: string;
|
||||
bandwidth?: string;
|
||||
location?: string;
|
||||
};
|
||||
}[];
|
||||
TranscodeSession?: TranscodeSession[];
|
||||
Media?: Media[];
|
||||
$: {
|
||||
grandparentTitle?: string;
|
||||
parentTitle?: string;
|
||||
@@ -29,6 +84,8 @@ interface Session {
|
||||
index?: number;
|
||||
type: string;
|
||||
live?: string;
|
||||
viewOffset?: string;
|
||||
duration?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,32 @@ import type { PlexResponse } from "./interface";
|
||||
|
||||
const logger = createLogger({ module: "plexIntegration" });
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function parseLocation(value: string | undefined): "lan" | "wan" | null {
|
||||
return value === "lan" || value === "wan" ? value : null;
|
||||
}
|
||||
|
||||
function parsePlaybackState(value: string | undefined): "playing" | "paused" | "buffering" | null {
|
||||
return value === "playing" || value === "paused" || value === "buffering" ? value : null;
|
||||
}
|
||||
|
||||
function parseResolution(
|
||||
width: string | undefined,
|
||||
height: string | undefined,
|
||||
): { width: number; height: number } | null {
|
||||
const parsedWidth = parseOptionalNumber(width);
|
||||
const parsedHeight = parseOptionalNumber(height);
|
||||
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
|
||||
}
|
||||
|
||||
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
const token = super.getSecretValue("apiKey");
|
||||
@@ -43,11 +69,24 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
const userElement = mediaElement.User ? mediaElement.User[0] : undefined;
|
||||
const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined;
|
||||
const sessionElement = mediaElement.Session ? mediaElement.Session[0] : undefined;
|
||||
const transcodeElement = mediaElement.TranscodeSession ? mediaElement.TranscodeSession[0] : undefined;
|
||||
const mediaInfoElement = mediaElement.Media ? mediaElement.Media[0] : undefined;
|
||||
|
||||
if (!playerElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const positionMs = parseOptionalNumber(mediaElement.$.viewOffset);
|
||||
const durationMs = parseOptionalNumber(mediaElement.$.duration);
|
||||
|
||||
const playbackState = parsePlaybackState(playerElement.$.state);
|
||||
|
||||
const location = parseLocation(sessionElement?.$.location);
|
||||
|
||||
const streams = mediaInfoElement?.Part?.[0]?.Stream ?? [];
|
||||
const videoStream = streams.find((stream) => stream.$.streamType === "1");
|
||||
const audioStream = streams.find((stream) => stream.$.streamType === "2");
|
||||
|
||||
return {
|
||||
sessionId: sessionElement?.$.id ?? "unknown",
|
||||
sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
|
||||
@@ -63,7 +102,30 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
episodeName: mediaElement.$.title ?? null,
|
||||
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
||||
episodeCount: mediaElement.$.index ?? null,
|
||||
metadata: null,
|
||||
playback: {
|
||||
state: playbackState,
|
||||
positionMs,
|
||||
durationMs,
|
||||
},
|
||||
location,
|
||||
metadata: {
|
||||
video: {
|
||||
resolution: parseResolution(videoStream?.$.width, videoStream?.$.height),
|
||||
frameRate: parseOptionalNumber(videoStream?.$.frameRate),
|
||||
},
|
||||
audio: {
|
||||
channelCount: parseOptionalNumber(audioStream?.$.channels),
|
||||
codec: audioStream?.$.codec ?? mediaInfoElement?.$.audioCodec ?? null,
|
||||
},
|
||||
transcoding: {
|
||||
container: transcodeElement?.$.container ?? null,
|
||||
resolution: parseResolution(transcodeElement?.$.width, transcodeElement?.$.height),
|
||||
target: {
|
||||
audioCodec: transcodeElement?.$.audioCodec ?? null,
|
||||
videoCodec: transcodeElement?.$.videoCodec ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
@@ -23,6 +23,8 @@ type OptionMapping = {
|
||||
const optionMapping: OptionMapping = {
|
||||
"mediaRequests-requestList": {
|
||||
linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab,
|
||||
statusFilter: () => undefined,
|
||||
recentDays: () => undefined,
|
||||
},
|
||||
"mediaRequests-requestStats": {},
|
||||
bookmarks: {
|
||||
|
||||
@@ -300,10 +300,7 @@ async function getContainersWithStatsAsync() {
|
||||
|
||||
const stats = await instance
|
||||
.getContainer(container.Id)
|
||||
// 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 })
|
||||
.stats({ stream: false, "one-shot": true })
|
||||
.catch(
|
||||
() =>
|
||||
({
|
||||
@@ -339,22 +336,12 @@ export function calculateCpuUsage(stats: ContainerStats): number {
|
||||
}
|
||||
|
||||
const numberOfCpus = stats.cpu_stats.online_cpus;
|
||||
|
||||
// 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) {
|
||||
const usage = stats.cpu_stats.system_cpu_usage;
|
||||
if (!usage || usage === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (cpuDelta / systemDelta) * numberOfCpus * 100;
|
||||
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
|
||||
}
|
||||
|
||||
export function calculateMemoryUsage(stats: ContainerStats): number {
|
||||
|
||||
@@ -33,7 +33,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
|
||||
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
|
||||
ImmichAlbum,
|
||||
IntegrationKindByCategory<"photoService">,
|
||||
{ albumId: string }
|
||||
{ albumId?: string }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
|
||||
@@ -13,6 +13,7 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
|
||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
}
|
||||
|
||||
export const createIntegrationRequestHandler = <
|
||||
@@ -32,6 +33,7 @@ export const createIntegrationRequestHandler = <
|
||||
return options.requestAsync(integration, input.options);
|
||||
},
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
}
|
||||
|
||||
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
|
||||
@@ -35,7 +36,6 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return { data: cached.data, timestamp: cached.timestamp };
|
||||
}
|
||||
if (cached) cache.delete(key);
|
||||
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
@@ -55,6 +55,9 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
})
|
||||
.catch((err) => {
|
||||
inflight.delete(key);
|
||||
if (options.fallbackToStaleOnError && cached) {
|
||||
return cached;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { MediaRequest } from "@homarr/integrations/types";
|
||||
import type { MediaRequest, MediaRequestStatus } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
const mediaRequestStatusValues = [
|
||||
"pending",
|
||||
"approved",
|
||||
"declined",
|
||||
"failed",
|
||||
"completed",
|
||||
] as const satisfies readonly MediaRequestStatus[];
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const mediaRequestListInputSchema = z.object({
|
||||
statuses: z.array(z.enum(mediaRequestStatusValues)).nonempty(),
|
||||
recentDays: z.number().min(0).max(365),
|
||||
});
|
||||
|
||||
export type MediaRequestListInput = z.infer<typeof mediaRequestListInputSchema>;
|
||||
|
||||
export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
|
||||
MediaRequest[],
|
||||
IntegrationKindByCategory<"mediaRequest">,
|
||||
Record<string, never>
|
||||
MediaRequestListInput
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getRequestsAsync();
|
||||
const requests = await integrationInstance.getRequestsAsync();
|
||||
const cutoff = input.recentDays > 0 ? Date.now() - input.recentDays * MS_PER_DAY : null;
|
||||
const statusSet = new Set<MediaRequestStatus>(input.statuses);
|
||||
return requests.filter((request) => {
|
||||
if (!statusSet.has(request.status)) return false;
|
||||
if (cutoff !== null && request.createdAt.getTime() < cutoff) return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
cacheTtlMs: 60_000,
|
||||
fallbackToStaleOnError: true,
|
||||
});
|
||||
@@ -16,4 +16,6 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
|
||||
users: await integrationInstance.getUsersAsync(),
|
||||
};
|
||||
},
|
||||
cacheTtlMs: 60_000,
|
||||
fallbackToStaleOnError: true,
|
||||
});
|
||||
@@ -134,7 +134,33 @@ const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
|
||||
baseUrl,
|
||||
auth: token,
|
||||
request: { fetch: fetchWithTrustedCertificatesAsync },
|
||||
throttle: { enabled: true },
|
||||
throttle: {
|
||||
enabled: true,
|
||||
onRateLimit: (
|
||||
retryAfter: number,
|
||||
options: { url?: string; method?: string },
|
||||
_octokit: unknown,
|
||||
retryCount: number,
|
||||
) => {
|
||||
logger.warn(`GitHub rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
|
||||
url: options.url,
|
||||
method: options.method,
|
||||
});
|
||||
return retryCount < 1;
|
||||
},
|
||||
onSecondaryRateLimit: (
|
||||
retryAfter: number,
|
||||
options: { url?: string; method?: string },
|
||||
_octokit: unknown,
|
||||
retryCount: number,
|
||||
) => {
|
||||
logger.warn(`GitHub secondary rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
|
||||
url: options.url,
|
||||
method: options.method,
|
||||
});
|
||||
return retryCount < 1;
|
||||
},
|
||||
},
|
||||
userAgent,
|
||||
});
|
||||
|
||||
@@ -719,6 +745,19 @@ 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 (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
@@ -748,6 +787,29 @@ const getLinuxServerIOReleaseAsync = async (
|
||||
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);
|
||||
if (versionRegex && !regex) {
|
||||
return {
|
||||
|
||||
@@ -75,26 +75,6 @@ describe("calculateCpuUsage", () => {
|
||||
// (500 / 100000) * 2 * 100 = 1
|
||||
expect(calculateCpuUsage(stats)).toBe(1);
|
||||
});
|
||||
|
||||
test("should use the delta against precpu_stats for the current usage", () => {
|
||||
const stats = createStats({
|
||||
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_002_000 }, system_cpu_usage: 20_000 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
|
||||
});
|
||||
// Despite a large lifetime total_usage, only the recent delta counts:
|
||||
// (2000 / 10000) * 4 * 100 = 80
|
||||
expect(calculateCpuUsage(stats)).toBe(80);
|
||||
});
|
||||
|
||||
test("should report 0 for a container that was busy over its lifetime but is idle now", () => {
|
||||
const stats = createStats({
|
||||
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 20_000 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
|
||||
});
|
||||
// No CPU delta since the last reading -> current usage is 0, even though the
|
||||
// lifetime average would be high. This is the regression the fix addresses.
|
||||
expect(calculateCpuUsage(stats)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateMemoryUsage", () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||
const res = await withTimeoutAsync(async (signal) => {
|
||||
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¤t_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,relative_humidity_2m_mean¤t_weather=true&timezone=auto`,
|
||||
{ signal },
|
||||
);
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
|
||||
sunset: weather.daily.sunset[index],
|
||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
|
||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
|
||||
humidity: weather.daily.relative_humidity_2m_mean[index],
|
||||
};
|
||||
}),
|
||||
} satisfies Weather;
|
||||
@@ -48,6 +49,7 @@ const atLocationOutput = z.object({
|
||||
sunset: z.array(z.string()),
|
||||
wind_speed_10m_max: z.array(z.number()),
|
||||
wind_gusts_10m_max: z.array(z.number()),
|
||||
relative_humidity_2m_mean: z.array(z.number()),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -66,5 +68,6 @@ export interface Weather {
|
||||
sunset: string | undefined;
|
||||
maxWindSpeed: number | undefined;
|
||||
maxWindGusts: number | undefined;
|
||||
humidity: number | undefined;
|
||||
}[];
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type SettingsContextProps = Pick<
|
||||
| "openSearchInNewTab"
|
||||
| "ddgBangs"
|
||||
| "pingIconsEnabled"
|
||||
| "enableRightClickOnWidgets"
|
||||
> &
|
||||
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
|
||||
Pick<ServerSettings["user"], "enableGravatar">;
|
||||
@@ -32,6 +33,7 @@ export type UserSettings = Pick<
|
||||
| "openSearchInNewTab"
|
||||
| "ddgBangs"
|
||||
| "pingIconsEnabled"
|
||||
| "enableRightClickOnWidgets"
|
||||
>;
|
||||
|
||||
export const createSettings = ({
|
||||
@@ -48,6 +50,7 @@ export const createSettings = ({
|
||||
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
|
||||
pingIconsEnabled: user?.pingIconsEnabled ?? false,
|
||||
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
enableGravatar: serverSettings.user.enableGravatar,
|
||||
|
||||
@@ -56,6 +56,13 @@ export const userPreferenceDefinitions = [
|
||||
mutationGroup: "pingIconsEnabled",
|
||||
aliases: ["ping", "icon", "status"],
|
||||
},
|
||||
{
|
||||
key: "enableRightClickOnWidgets",
|
||||
kind: "boolean",
|
||||
guest: false,
|
||||
mutationGroup: "enableRightClickOnWidgets",
|
||||
aliases: ["right click", "context menu", "tile", "widget menu"],
|
||||
},
|
||||
{
|
||||
key: "fullPreferencesPage",
|
||||
kind: "link",
|
||||
|
||||
@@ -17,6 +17,10 @@ export const appIntegrationBoardMode = {
|
||||
return groups;
|
||||
}
|
||||
|
||||
return groups.concat([appsSearchGroup, integrationsSearchGroup]);
|
||||
return groups.concat(
|
||||
session.user.permissions.includes("board-modify-all")
|
||||
? [appsSearchGroup, integrationsSearchGroup]
|
||||
: [integrationsSearchGroup],
|
||||
);
|
||||
},
|
||||
} satisfies SearchMode;
|
||||
@@ -87,7 +87,8 @@ export const globalCommandGroup = createGroup<Command>({
|
||||
icon: IconBox,
|
||||
name: tOption("newApp.label"),
|
||||
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
hidden:
|
||||
!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create"),
|
||||
},
|
||||
{
|
||||
commandKey: "newIntegration",
|
||||
|
||||
@@ -76,7 +76,7 @@ export const pagesSearchGroup = createGroup<{
|
||||
icon: IconBox,
|
||||
path: "/manage/apps",
|
||||
name: t("manageApp.label"),
|
||||
hidden: !session,
|
||||
hidden: !session?.user.permissions.includes("board-modify-all"),
|
||||
},
|
||||
{
|
||||
icon: IconPlug,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
IconActivity,
|
||||
IconCalendarWeek,
|
||||
IconClick,
|
||||
IconDeviceDesktop,
|
||||
IconExternalLink,
|
||||
IconHome,
|
||||
@@ -46,6 +47,7 @@ export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
|
||||
homeBoardId: IconHome,
|
||||
mobileHomeBoardId: IconLayoutBoard,
|
||||
pingIconsEnabled: IconActivity,
|
||||
enableRightClickOnWidgets: IconClick,
|
||||
fullPreferencesPage: IconSettings,
|
||||
};
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ export const useUserPreferences = () => {
|
||||
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
|
||||
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
|
||||
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
|
||||
const rightClickMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation({ onSuccess: refresh });
|
||||
|
||||
const getEffective = (key: UserPreferenceKey): unknown =>
|
||||
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
|
||||
@@ -116,6 +117,8 @@ export const useUserPreferences = () => {
|
||||
}),
|
||||
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
|
||||
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> = {
|
||||
@@ -123,6 +126,7 @@ export const useUserPreferences = () => {
|
||||
homeBoards: homeBoardsMutation.isPending,
|
||||
firstDayOfWeek: firstDayMutation.isPending,
|
||||
pingIconsEnabled: pingMutation.isPending,
|
||||
enableRightClickOnWidgets: rightClickMutation.isPending,
|
||||
};
|
||||
|
||||
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
|
||||
|
||||
@@ -1598,10 +1598,16 @@
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"settings": "Settings",
|
||||
"settings": "",
|
||||
"refresh": "",
|
||||
"options": "",
|
||||
"actions": ""
|
||||
},
|
||||
"status": {
|
||||
"success": "",
|
||||
"error": "",
|
||||
"loading": "",
|
||||
"idle": ""
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
|
||||
@@ -198,6 +198,10 @@
|
||||
"pingIconsEnabled": {
|
||||
"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": {
|
||||
"label": "Default search engine"
|
||||
},
|
||||
@@ -1622,6 +1626,12 @@
|
||||
"refresh": "Refresh",
|
||||
"options": "Options",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"status": {
|
||||
"success": "OK",
|
||||
"error": "Error",
|
||||
"loading": "Loading",
|
||||
"idle": "Idle"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -2247,7 +2257,8 @@
|
||||
"sunrise": "Sunrise",
|
||||
"sunset": "Sunset",
|
||||
"maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}"
|
||||
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}",
|
||||
"humidity": "Humidity: {humidity}%"
|
||||
},
|
||||
"kind": {
|
||||
"clear": "Clear",
|
||||
@@ -2711,6 +2722,11 @@
|
||||
"user": "User",
|
||||
"name": "Name",
|
||||
"id": "Id",
|
||||
"status": "Status",
|
||||
"directPlay": "Direct play",
|
||||
"transcoding": "Transcoding",
|
||||
"paused": "Paused",
|
||||
"remaining": "{minutes} min left",
|
||||
"metadata": {
|
||||
"title": "Stats for nerds",
|
||||
"video": {
|
||||
@@ -2991,10 +3007,11 @@
|
||||
},
|
||||
"immich-albumCarousel": {
|
||||
"name": "Immich Album",
|
||||
"allPhotos": "All photos",
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "Album",
|
||||
"description": "Select the Immich album to show in the slideshow."
|
||||
"description": "Select an Immich album or All photos. An empty selection also shows random photos from your full library."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Interval in seconds",
|
||||
@@ -3009,9 +3026,8 @@
|
||||
"description": "Show photos in a random order instead of always starting with the same album order."
|
||||
}
|
||||
},
|
||||
"description": "Slideshow of pictures in your Immich album",
|
||||
"noAlbumSelected": "No album selected, edit widget and choose an album",
|
||||
"noPhotos": "No photos in this album"
|
||||
"description": "Slideshow of pictures from an Immich album or your full library",
|
||||
"noPhotos": "No photos found"
|
||||
},
|
||||
"downloads": {
|
||||
"name": "Download Client",
|
||||
@@ -3195,6 +3211,12 @@
|
||||
"option": {
|
||||
"linksTargetNewTab": {
|
||||
"label": "Open links in new tab"
|
||||
},
|
||||
"statusFilter": {
|
||||
"label": "Statuses to show"
|
||||
},
|
||||
"recentDays": {
|
||||
"label": "Show only recent requests"
|
||||
}
|
||||
},
|
||||
"pending": {
|
||||
@@ -5936,6 +5958,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableRightClickOnWidgets": {
|
||||
"label": "Enable right-click on widgets",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Enable right-click on widgets"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fullPreferencesPage": {
|
||||
"label": "Open full preferences page"
|
||||
}
|
||||
|
||||
@@ -130,6 +130,10 @@ export const userPingIconsEnabledSchema = z.object({
|
||||
pingIconsEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const userEnableRightClickOnWidgetsSchema = z.object({
|
||||
enableRightClickOnWidgets: z.boolean(),
|
||||
});
|
||||
|
||||
export const userDdgBangsSchema = z.object({
|
||||
ddgBangs: z.boolean(),
|
||||
});
|
||||
@@ -5,8 +5,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
|
||||
icon: IconNotes,
|
||||
queryKey: [["widget", "anchorNotes"]],
|
||||
refetchIntervalSeconds: 15,
|
||||
supportedIntegrations: ["anchor"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
|
||||
icon: IconArchive,
|
||||
refetchIntervalSeconds: 300,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showBroadcastMessage: factory.switch({
|
||||
|
||||
@@ -14,7 +14,6 @@ const hideUnlessAudiobookshelf = {
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
|
||||
icon: IconHeadphones,
|
||||
refetchIntervalSeconds: 600,
|
||||
supportedIntegrations: ["navidrome", "audiobookshelf"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
|
||||
icon: IconCalendar,
|
||||
refetchIntervalSeconds: 60,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
releaseType: factory.multiSelect({
|
||||
|
||||
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
|
||||
icon: IconCloud,
|
||||
refetchIntervalSeconds: 30,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showServers: factory.switch({
|
||||
|
||||
@@ -70,7 +70,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
|
||||
export interface WidgetDefinition {
|
||||
icon: TablerIcon;
|
||||
queryKey?: QueryKey;
|
||||
refetchIntervalSeconds?: number;
|
||||
refetchInterval?: number | null;
|
||||
supportedIntegrations?: IntegrationKind[];
|
||||
integrationsRequired?: boolean;
|
||||
maxIntegrations?: number;
|
||||
|
||||
@@ -10,7 +10,7 @@ export const widgetKind = "dnsHoleControls";
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
icon: IconDeviceGamepad,
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchIntervalSeconds: 5,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showToggleAllButtons: factory.switch({
|
||||
|
||||
@@ -10,7 +10,7 @@ export const widgetKind = "dnsHoleSummary";
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
icon: IconAd,
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchIntervalSeconds: 5,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
usePiHoleColors: factory.switch({
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
|
||||
const t = useScopedI18n("docker");
|
||||
const isTiny = width <= 256;
|
||||
|
||||
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {});
|
||||
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
|
||||
const containers = data?.containers ?? [];
|
||||
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
|
||||
const relativeTime = useTimeAgo(timestamp);
|
||||
|
||||
@@ -26,7 +26,6 @@ const columnTranslationKeyMap = {
|
||||
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
|
||||
icon: IconBrandDocker,
|
||||
queryKey: [["docker", "getContainers"]],
|
||||
refetchIntervalSeconds: 20,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
columns: factory.multiSelect({
|
||||
|
||||
@@ -33,7 +33,7 @@ const columnsSort = columnsList.filter((column) =>
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
||||
icon: IconDownload,
|
||||
refetchIntervalSeconds: 5,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
|
||||
icon: IconWall,
|
||||
refetchIntervalSeconds: 5,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(() => ({}));
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
|
||||
icon: IconHeartRateMonitor,
|
||||
refetchIntervalSeconds: 5,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import classes from "./component.module.css";
|
||||
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
||||
|
||||
export default function ImmichAlbumCarouselWidget({
|
||||
integrationIds,
|
||||
@@ -21,9 +22,9 @@ export default function ImmichAlbumCarouselWidget({
|
||||
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
|
||||
{
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
albumId: options.albumId ?? "",
|
||||
albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined,
|
||||
},
|
||||
{ enabled: Boolean(options.albumId) },
|
||||
{ enabled: integrationIds.length > 0 },
|
||||
);
|
||||
|
||||
const photoAssets = useMemo(() => {
|
||||
@@ -35,10 +36,6 @@ export default function ImmichAlbumCarouselWidget({
|
||||
setCurrentPhotoIndex(0);
|
||||
}, [photoAssets]);
|
||||
|
||||
if (!options.albumId) {
|
||||
return <NoAlbumSelected />;
|
||||
}
|
||||
|
||||
if (!album) return <WidgetEmptyState />;
|
||||
|
||||
if (album.assets.length === 0) {
|
||||
@@ -117,20 +114,6 @@ 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() {
|
||||
const t = useI18n();
|
||||
return (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ALL_PHOTOS_ALBUM_ID = "all";
|
||||
@@ -2,22 +2,26 @@ import { IconPhoto } from "@tabler/icons-react";
|
||||
import z from "zod";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
|
||||
icon: IconPhoto,
|
||||
queryKey: [["widget", "immich"]],
|
||||
refetchIntervalSeconds: 900,
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["immich"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
albumId: factory.integrationSelect({
|
||||
defaultValue: ALL_PHOTOS_ALBUM_ID,
|
||||
withDescription: true,
|
||||
clearable: true,
|
||||
useOptions: (integrationIds: string[]) => {
|
||||
const t = useScopedI18n("widget.immich-albumCarousel");
|
||||
const {
|
||||
data = [],
|
||||
isPending,
|
||||
@@ -26,7 +30,14 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
|
||||
);
|
||||
return { data: data.map((album) => ({ value: album.id, label: album.albumName })), isPending, isError };
|
||||
return {
|
||||
data: [
|
||||
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
|
||||
...data.map((album) => ({ value: album.id, label: album.albumName })),
|
||||
],
|
||||
isPending,
|
||||
isError,
|
||||
};
|
||||
},
|
||||
}),
|
||||
rotationIntervalSeconds: factory.number({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../../options";
|
||||
export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", {
|
||||
icon: IconGraphFilled,
|
||||
queryKey: [["widget", "immich"]],
|
||||
refetchIntervalSeconds: 900,
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["immich"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
|
||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user