Compare commits

...
Author SHA1 Message Date
manuel-rw 514bfdcf87 style: auto-fix lint and format 2026-07-01 18:53:07 +02:00
Manuel 5ec38d6136 feat: add pushover integration 2026-07-01 18:53:07 +02:00
homarr-renovate[bot] dd2eb3aa66 chore(deps): update dependency posthog-js to ^1.395.0 (#6150)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-01 15:51:52 +00:00
homarr-renovate[bot] aec6bb18c5 chore(deps): update dependency @scalar/api-reference-react to ^0.9.49 (#6146)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-01 12:21:19 +00:00
Thomas f9da71a19b fix: quick wins — calendar, about, glances, admin UX, releases, iframe, notes (#6116) 2026-07-01 13:57:39 +02:00
homarr-renovate[bot] b83c8b6546 chore(deps): update dependency posthog-js to ^1.393.5 (#6135)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 21:53:10 +00:00
homarr-renovate[bot] 0e4189f693 chore(deps): update dependency dockerode to ^5.0.1 (#6130)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 20:09:36 +00:00
homarr-renovate[bot] 1cf010e9fb chore(deps): update node.js to v24.18.0 (#6107)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 18:11:58 +00:00
Thomasandgithub-actions[bot] 7e58858353 feat(widgets): right-click context menu with auto-generated toggle actions (#6126)
* docs(helm): Update Helm chart documentation

* feat(widgets): add right-click context menu with auto-generated toggle actions

- Add WidgetContextMenu component using Mantine 9.3 Menu.ContextMenu
- Extend WidgetDefinition with contextActions API for widget-specific actions
- Auto-generate context menu toggle items for switch-type widget options
- Add 'Search' context action to media-requests widgets (list + stats)
- Keep existing dots-button menu for discoverability in edit mode
- Add widgetStateRef to WidgetComponentProps for client-side-only state
- Add skipContextMenu opt-out on switch option definitions

* style: auto-fix lint and format

* fix(widgets): address CodeRabbit review — badge in context menu target, edit-only pending gate

- Move title badge inside WidgetContextMenu wrapper so right-clicking the title overlay also triggers the context menu
- Remove full-menu hide during integration loading; only disable 'Edit' action while isPending
- Add isPending to allActions useMemo dependency array

* style: auto-fix lint and format

* fix(widgets): gate context menu behind authenticated session

- Only show right-click context menu for logged-in users
- Unauthenticated users see plain children without menu wrapper
- Matches existing BoardItemMenu session gating pattern

* style: auto-fix lint and format

* chore: revert unrelated sitemap change from auto-fix

* style: auto-fix lint and format

* fix(context-menu): persist widget option changes to server

Toggle switches and edit modal changes from the context menu now call
saveBoard mutation to persist to DB. Previously only the client cache
was updated, losing changes on reload. Removes unnecessary invalidation
that caused cascading refetch loops.

* style: auto-fix lint and format

* fix: use explicit React type imports instead of global namespace

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-30 18:10:08 +02:00
9ed55d57dd Main (#6138)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-30 17:52:37 +02:00
47 changed files with 986 additions and 257 deletions

No files matched your search

+62
View File
@@ -163,3 +163,65 @@ jobs:
run: cp .env.example .env
- name: Build
run: pnpm turbo build --affected
memory-regression:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Docker image
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-memory-test
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Start container and measure memory
run: |
docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test
# Wait for container to be healthy (up to 60s)
healthy=false
for i in $(seq 1 30); do
if curl -sf http://localhost:17575/api/health 2>/dev/null; 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 homarr-mem-test --tail 50
exit 1
fi
# Warm up: hit the dashboard page a few times
for i in $(seq 1 5); do
curl -sf http://localhost:17575/ > /dev/null 2>&1 || true
sleep 2
done
# Measure RSS after warmup
MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||')
echo "Raw memory: $MEM_KB"
# Convert to numeric MB (handles GiB and MiB suffixes)
MEM_NUM=$(echo "$MEM_KB" | awk '{
if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 }
else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 }
else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 }
else { print $1 / 1048576 }
}')
echo "Memory usage: ${MEM_NUM} MB"
MAX_MB=700
if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then
echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold"
docker logs homarr-mem-test --tail 50
exit 1
fi
echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB"
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.17.0
node-version: 24.18.0
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
+1 -1
View File
@@ -1,2 +1,2 @@
[tools]
node = "24.17.0"
node = "24.18.0"
+1 -1
View File
@@ -1 +1 @@
24.17.0
24.18.0
+1 -1
View File
@@ -1,7 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"oxc.oxc-vscode",
"yoavbls.pretty-ts-errors",
"million.million-lint",
"lokalise.i18n-ally"
+7
View File
@@ -1,3 +1,10 @@
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
### Bug Fixes
* make source maps opt-in to restore v1.67.0 memory baseline ([57ef0af](https://github.com/homarr-labs/homarr/commit/57ef0af4a252ea39727caeba7e13587dabc6254e)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
* revert production source maps to restore v1.67.0 memory baseline ([60a69e3](https://github.com/homarr-labs/homarr/commit/60a69e31efc7168b8f4309daf75954d7730d883b)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
## [1.68.0](https://github.com/homarr-labs/homarr/compare/v1.67.0...v1.68.0) (2026-06-26)
### Features
+2 -15
View File
@@ -1,4 +1,4 @@
FROM node:24.17.0-alpine AS base
FROM node:24.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -20,17 +20,6 @@ ARG DISABLE_REDIS_LOGS='true'
RUN corepack enable pnpm && pnpm build
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
# See https://github.com/homarr-labs/homarr/issues/5891
RUN cd apps/nextjs/.next/server && \
find . -name '*.map' | while IFS= read -r map; do \
dest="../standalone/apps/nextjs/.next/server/$map"; \
mkdir -p "$(dirname "$dest")"; \
cp "$map" "$dest"; \
done
FROM base AS runner
WORKDIR /app
@@ -53,6 +42,7 @@ RUN mkdir -p /var/cache/nginx && \
COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
@@ -75,9 +65,6 @@ ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
ENV NODE_OPTIONS='--enable-source-maps'
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"]
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.19.0](https://img.shields.io/badge/Version-8.19.0-informational?style=flat)
![Version: 8.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.60.0](https://img.shields.io/badge/AppVersion-v1.60.0-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -383,6 +383,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. |
| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). |
| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql |
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server |
| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups |
| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups |
@@ -395,7 +396,6 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user search filter (& based) |
| env.AUTH_LDAP_USER_MAIL_ATTRIBUTE | string | `"mail"` | Attribute used for mail field |
| env.AUTH_LOGOUT_REDIRECT_URL | string | `nil` | URL to redirect to after clicking logging out. |
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_OIDC_AUTO_LOGIN | string | `"false"` | Automatically redirect to OIDC login |
| env.AUTH_OIDC_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) |
| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim |
@@ -0,0 +1,42 @@
---
title: 'Pushover'
description: "Pushover is a service for receiving real-time push notifications on your Android, iPhone, iPad, and Desktop devices."
hide_title: true
---
import { notificationsWidget } from '@site/docs/widgets/notifications';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { pushoverIntegration } from '.';
<IntegrationHeader
integration={pushoverIntegration}
categories={['Notifications', 'Messaging']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: notificationsWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: ['apiKey', 'userKey'],
steps: [
{
title: 'API Token (Application Key)',
description: 'Register your application on the [Pushover website](https://pushover.net/apps) and copy the API token/application key.'
},
{
title: 'User Key',
description: 'Your user key is viewable on the [Pushover dashboard](https://pushover.net/) when logged in. It is a 30-character string (e.g. `uQiRzpo4DXghDmr9QzzfQu27cmVRsG`).'
}
]
}]} />
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const pushoverIntegration: IntegrationDefinition = {
name: "Pushover",
description: "Pushover is a service for receiving real-time push notifications on your devices.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pushover.svg",
path: "../../integrations/pushover",
};
@@ -15,6 +15,7 @@ import { notificationsWidget } from '.';
import { ntfyIntegration } from '@site/docs/integrations/ntfy';
import { nextcloudIntegration } from '@site/docs/integrations/nextcloud';
import { gotifyIntegration } from '@site/docs/integrations/gotify';
import { pushoverIntegration } from '@site/docs/integrations/pushover';
<WidgetHeader
widget={notificationsWidget}
@@ -35,7 +36,8 @@ import notificationsScreenshot from './img/notifications.png';
items={[
{ integration: ntfyIntegration },
{ integration: nextcloudIntegration },
{ integration: gotifyIntegration }
{ integration: gotifyIntegration },
{ integration: pushoverIntegration }
]}
/>
-4
View File
@@ -31,8 +31,6 @@ const nextConfig: NextConfig = {
},
output: "standalone",
reactStrictMode: true,
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
productionBrowserSourceMaps: true,
// react compiler breaks mantine-react-table, so disabled for now
//reactCompiler: true,
/** We already do typechecking as separate tasks in CI */
@@ -43,8 +41,6 @@ const nextConfig: NextConfig = {
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
serverSourceMaps: true,
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
@@ -10,6 +10,7 @@ import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
@@ -79,17 +80,20 @@ export default async function ApiPage() {
const toolGroups = getMcpToolGroups();
return (
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
<>
<DynamicBreadcrumb />
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
</>
);
}
@@ -6,6 +6,7 @@ import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
@@ -31,10 +32,13 @@ export default async function BackupPage() {
const t = await getScopedI18n("management.page.tool.backup");
return (
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
<>
<DynamicBreadcrumb />
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
</>
);
}
@@ -7,6 +7,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NavigationLink } from "./_navigation";
interface LayoutProps {
@@ -24,6 +25,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
return (
<ManageContainer size="xl">
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, group.name]])} />
<Grid>
<GridCol span={12}>
<Group justify="space-between" align="center">
@@ -1,3 +1,5 @@
import type { MutableRefObject } from "react";
import { useRef } from "react";
import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -16,6 +18,7 @@ import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
interface BoardItemContentProps {
item: SectionItem;
@@ -30,30 +33,33 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
return (
<>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} />
</Card>
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
</WidgetContextMenu>
{item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
@@ -83,6 +89,7 @@ interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
@@ -0,0 +1,250 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { Group, Menu, Switch } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react";
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 { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
interface WidgetContextMenuProps {
item: SectionItem;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
children: ReactNode;
}
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const settings = useSettings();
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, settings, item.options],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
}, [currentDefinition, settings]);
const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>;
if (typeof def.contextActions !== "function") return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions = (def.contextActions as any)({
options,
setOptions: (partial: Record<string, unknown>) => {
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
},
integrationIds: item.integrationIds,
context: { isEditMode, boardId: board.id, itemId: item.id },
widgetStateRef,
});
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
const persistBoard = useCallback(
(updatedItems: typeof board.items) => {
saveBoard({ ...board, items: updatedItems });
},
[board, saveBoard],
);
const openEditModal = useCallback(() => {
openModal(
{
kind: item.kind,
value: {
advancedOptions: item.advancedOptions,
options: item.options,
integrationIds: item.integrationIds,
},
onSuccessfulEdit: (editResult) => {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
if (!isEditMode) {
persistBoard(
board.items.map((boardItem) =>
boardItem.id !== item.id
? boardItem
: {
...boardItem,
options: editResult.options,
advancedOptions: editResult.advancedOptions,
integrationIds: editResult.integrationIds,
},
),
);
}
},
integrationData: (integrationData ?? []).filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
);
}, [
openModal,
item,
updateItemOptions,
updateItemAdvancedOptions,
updateItemIntegrations,
integrationData,
currentDefinition,
settings,
isEditMode,
persistBoard,
board,
]);
const handleToggle = useCallback(
(key: string) => (checked: boolean) => {
const newOptions = { ...options, [key]: checked };
updateItemOptions({ itemId: item.id, newOptions });
persistBoard(
board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
);
},
[options, item.id, updateItemOptions, persistBoard, board],
);
if (!session) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
return (
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Group wrap="nowrap">
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
</Group>
</Menu.Item>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{String(translateIfNecessary(t, action.label))}
</Menu.Item>
);
})}
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconLayoutKanban size={16} />}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
</>
)}
<>
{!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
disabled={isPending}
>
{tMenu("settings")}
</Menu.Item>
</>
{isEditMode && (
<>
<Menu.Divider />
<Menu.Item
closeMenuOnClick
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
>
{tItem("action.remove")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
);
};
@@ -10,3 +10,8 @@
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.itemCard:global(.iframe-wrapper) {
background-color: transparent !important;
border-color: transparent !important;
}
@@ -14,6 +14,8 @@ interface DynamicBreadcrumbProps {
nonInteractable?: string[];
}
const categorySegments = new Set(["tools"]);
/**
* Breadcrumb is client side rendered. Elements are automatically
* calculated and translated using dynamic keys.
@@ -60,8 +62,11 @@ export const DynamicBreadcrumb = ({
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
const mappedValue = dynamicMappings?.get(pathnamePart);
const isNonInteractable =
nonInteractable?.includes(pathnamePart) === true || categorySegments.has(pathnamePart);
if (mappedValue) {
if (nonInteractable?.includes(pathnamePart)) {
if (isNonInteractable) {
return <Text key={href}>{mappedValue}</Text>;
}
return (
@@ -71,7 +76,7 @@ export const DynamicBreadcrumb = ({
);
}
if (nonInteractable?.includes(pathnamePart)) {
if (isNonInteractable) {
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
}
+15 -7
View File
@@ -35,14 +35,22 @@ export const getDependenciesAsync = async (): Promise<PackageJsonDependencies> =
type DependencyCatalog = Map<string, string>;
const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const workspaceConfigContent = await fsPromises.readFile(
path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"),
"utf-8",
);
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml");
return new Map(Object.entries(parseResult.catalog ?? {}));
try {
const workspaceConfigContent = await fsPromises.readFile(workspaceConfigPath, "utf-8");
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
return new Map(Object.entries(parseResult.catalog ?? {}));
} catch (error) {
// The workspace config is absent in Docker (only the runner stage is shipped),
// so a missing file is expected. Surface any other failure (e.g. malformed YAML).
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return new Map();
}
throw error;
}
};
const workspaceConfigCatalogSchema = z.object({
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.17.0-trixie AS base
FROM node:24.18.0-trixie AS base
WORKDIR /app
COPY package.json .
COPY pnpm-lock.yaml .
+1 -1
View File
@@ -5,7 +5,7 @@
"better-sqlite3": "^12.10.0"
},
"engines": {
"node": ">=24.17.0",
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
},
"packageManager": "pnpm@11.9.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.68.0",
"version": "1.68.1",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -61,7 +61,7 @@
"vitest": "catalog:"
},
"engines": {
"node": ">=24.17.0",
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
},
"packageManager": "pnpm@11.9.0",
@@ -326,6 +326,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/quay"
| "/docs/integrations/ntfy"
| "/docs/integrations/gotify"
| "/docs/integrations/pushover"
| "/docs/integrations/ical"
| "/docs/integrations/anchor"
| "/docs/integrations/truenas"
@@ -41,6 +41,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
quay: "quay",
ntfy: "ntfy",
gotify: "gotify",
pushover: "pushover",
ical: "ical",
anchor: "anchor",
truenas: "truenas",
+9
View File
@@ -18,6 +18,7 @@ export const integrationSecretKindObject = {
githubAppId: { isPublic: true, multiline: false },
githubInstallationId: { isPublic: true, multiline: false },
slug: { isPublic: true, multiline: false },
userKey: { isPublic: false, multiline: false },
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
@@ -362,6 +363,14 @@ export const integrationDefs = {
category: ["notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/gotify"),
},
pushover: {
name: "Pushover",
secretKinds: [["apiKey", "userKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pushover.svg",
category: ["notifications"],
defaultUrl: "https://api.pushover.net",
documentationUrl: createDocumentationLink("/docs/integrations/pushover"),
},
ical: {
name: "iCal",
secretKinds: [["url"]],
@@ -39,6 +39,7 @@ import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NPMIntegration } from "../npm/npm-integration";
import { GotifyIntegration } from "../gotify/gotify-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { PushoverIntegration } from "../pushover/pushover-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
@@ -125,6 +126,7 @@ export const integrationCreators = {
quay: QuayIntegration,
ntfy: NTFYIntegration,
gotify: GotifyIntegration,
pushover: PushoverIntegration,
mock: MockIntegration,
truenas: TrueNasIntegration,
unraid: UnraidIntegration,
@@ -35,7 +35,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
if (session == null) throw new Error("Session was unexpectitly null");
const stats = await this.getAllStatsAsync();
const [stats, cpuTemp] = await Promise.all([this.getAllStatsAsync(), this.getCpuTempAsync()]);
return {
cpuUtilization: stats.cpu.total,
@@ -58,7 +58,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
cpuModelName: stats.quicklook?.cpu_name ?? "Unknown",
loadAverage: null,
smart: [],
cpuTemp: undefined,
cpuTemp,
gpu: stats.gpu.map((gpu) => ({
gpuId: gpu.gpu_id,
name: gpu.name,
@@ -80,6 +80,23 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
return await response.text();
}
private async getCpuTempAsync(): Promise<number | undefined> {
// CPU temperature is optional data, so a transport error or payload drift on
// /api/4/sensors must not fail the whole getSystemInfoAsync() call.
try {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/sensors"));
if (!response.ok) {
return undefined;
}
const sensors = await sensorsSchema.parseAsync(await response.json());
return parseGlancesCpuTempFromSensors(sensors);
} catch {
return undefined;
}
}
private async getAllStatsAsync() {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/all"));
@@ -173,3 +190,33 @@ const allSchema = z.object({
)
.default([]),
});
const sensorsSchema = z.array(
z.object({
label: z.string(),
value: z.number(),
type: z.string().optional(),
}),
);
const cpuTempLabelPriority = ["CPU", "Package id 0"] as const;
export const parseGlancesCpuTempFromSensors = (sensors: z.infer<typeof sensorsSchema>): number | undefined => {
const temperatureSensors = sensors.filter(
(sensor) => sensor.type === "temperature_core" || sensor.type === "temperature",
);
for (const label of cpuTempLabelPriority) {
const match = temperatureSensors.find((sensor) => sensor.label === label);
if (match) {
return match.value;
}
}
const firstCore = temperatureSensors[0];
if (firstCore) {
return firstCore.value;
}
return undefined;
};
+1
View File
@@ -19,6 +19,7 @@ export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integratio
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
export { GotifyIntegration } from "./gotify/gotify-integration";
export { PushoverIntegration } from "./pushover/pushover-integration";
export { NTFYIntegration } from "./ntfy/ntfy-integration";
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
export { GlancesIntegration } from "./glances/glances-integration";
@@ -69,36 +69,24 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => {
const links: CalendarLink[] = [];
// titleSlug is only present when Lidarr returns it; guard so a missing value
// neither breaks the link nor drops the event.
if (event.artist.titleSlug) {
links.push({
href: this.externalUrl(`/artist/${event.artist.titleSlug}`).toString(),
name: "Lidarr",
logo: "/images/apps/lidarr.svg",
color: undefined,
isDark: true,
});
}
for (const link of event.artist.links) {
switch (link.name) {
case "vgmdb":
links.push({
href: link.url,
name: "VgmDB",
color: "#f5c518",
isDark: false,
logo: "/images/apps/vgmdb.svg",
});
break;
case "imdb":
links.push({
href: link.url,
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.png",
});
break;
case "last":
links.push({
href: link.url,
name: "LastFM",
color: "#cf222a",
isDark: false,
logo: "/images/apps/lastfm.svg",
});
break;
const externalLink = lidarrExternalCalendarLinks[link.name];
if (!externalLink) {
continue;
}
links.push({ ...externalLink, href: link.url });
}
return links;
@@ -109,7 +97,7 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
): z.infer<typeof lidarrCalendarEventSchema>["images"][number] | undefined => {
const flatImages = [...event.images];
const sortedImages = flatImages.sort(
const sortedImages = flatImages.toSorted(
(imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
);
@@ -149,6 +137,16 @@ const lidarrCalendarEventSchema = z.object({
title: z.string(),
overview: z.string().optional(),
images: lidarrCalendarEventImageSchema,
artist: z.object({ links: z.array(z.object({ url: z.string().url(), name: z.string() })), artistName: z.string() }),
artist: z.object({
links: z.array(z.object({ url: z.string().url(), name: z.string() })),
artistName: z.string(),
titleSlug: z.string().optional(),
}),
releaseDate: z.string().transform((value) => new Date(value)),
});
const lidarrExternalCalendarLinks: Record<string, Omit<CalendarLink, "href">> = {
vgmdb: { name: "VgmDB", color: "#f5c518", isDark: false, logo: "/images/apps/vgmdb.svg" },
imdb: { name: "IMDb", color: "#f5c518", isDark: false, logo: "/images/apps/imdb.png" },
last: { name: "LastFM", color: "#cf222a", isDark: false, logo: "/images/apps/lastfm.svg" },
};
@@ -0,0 +1,38 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { Integration } from "../base/integration";
import type { IntegrationTestingInput } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { Notification } from "../interfaces/notifications/notification-types";
import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration";
import { pushoverLimitsResponseSchema } from "./pushover-schema";
export class PushoverIntegration extends Integration implements INotificationsIntegration {
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const token = this.getSecretValue("apiKey");
const url = this.url("/1/apps/limits.json", { token });
const response = await input.fetchAsync(url);
if (!response.ok) throw new ResponseError(response);
const result = await pushoverLimitsResponseSchema.safeParseAsync(await response.json());
if (!result.success) throw new Error(`Failed to parse Pushover response: ${result.error.message}`);
if (result.data.status !== 1) throw new Error("Pushover API returned non-success status");
return { success: true };
}
public async getNotificationsAsync(): Promise<Notification[]> {
const token = this.getSecretValue("apiKey");
const url = this.url("/1/apps/limits.json", { token });
const response = await fetchWithTrustedCertificatesAsync(url);
if (!response.ok) throw new ResponseError(response);
const result = await pushoverLimitsResponseSchema.safeParseAsync(await response.json());
if (!result.success) throw new Error(`Failed to parse Pushover response: ${result.error.message}`);
return [];
}
}
@@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const pushoverLimitsResponseSchema = z.object({
status: z.number(),
request: z.string(),
limit: z.number().optional(),
remaining: z.number().optional(),
reset: z.number().optional(),
});
@@ -1,11 +1,11 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { GlancesIntegration } from "../src/glances/glances-integration";
import { GlancesIntegration, parseGlancesCpuTempFromSensors } from "../src/glances/glances-integration";
// ─── module mocks ────────────────────────────────────────────────────────────
@@ -64,6 +64,19 @@ function makeResponse(body: unknown) {
} as never;
}
function mockGlancesFetch(statsBody: unknown, sensorsBody: unknown) {
mockFetch.mockImplementation(((url: string | URL) => {
const urlString = url.toString();
if (urlString.endsWith("/api/4/all")) {
return Promise.resolve(makeResponse(statsBody));
}
if (urlString.endsWith("/api/4/sensors")) {
return Promise.resolve(makeResponse(sensorsBody));
}
return Promise.reject(new Error(`Unexpected fetch URL: ${urlString}`));
}) as typeof fetchWithTrustedCertificatesAsync);
}
const integrationInput = {
id: "test-glances",
name: "Glances",
@@ -75,8 +88,12 @@ const integrationInput = {
// ─── tests ───────────────────────────────────────────────────────────────────
describe("GlancesIntegration schema", () => {
beforeEach(() => {
mockFetch.mockReset();
});
test("parses stats when quicklook is present", async () => {
mockFetch.mockResolvedValueOnce(makeResponse({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }));
mockGlancesFetch({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }, []);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
@@ -86,7 +103,7 @@ describe("GlancesIntegration schema", () => {
});
test("returns Unknown cpuModelName when quicklook is absent (macOS scenario)", async () => {
mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
mockGlancesFetch(baseStats, []);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
@@ -95,9 +112,41 @@ describe("GlancesIntegration schema", () => {
});
test("does not throw a Zod parse error when quicklook is missing", async () => {
mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
mockGlancesFetch(baseStats, []);
const integration = new GlancesIntegration(integrationInput);
await expect(integration.getSystemInfoAsync()).resolves.not.toThrow();
});
test("reads CPU temperature from sensors", async () => {
mockGlancesFetch(baseStats, [
{ label: "CPU", unit: "C", value: 58, type: "temperature_core" },
{ label: "Core 0", unit: "C", value: 54, type: "temperature_core" },
]);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
expect(result.cpuTemp).toBe(58);
});
});
describe("parseGlancesCpuTempFromSensors", () => {
test("prefers CPU label over core sensors", () => {
const result = parseGlancesCpuTempFromSensors([
{ label: "Core 0", value: 54, type: "temperature_core" },
{ label: "CPU", value: 58, type: "temperature_core" },
]);
expect(result).toBe(58);
});
test("falls back to Package id 0", () => {
const result = parseGlancesCpuTempFromSensors([
{ label: "Core 0", value: 54, type: "temperature_core" },
{ label: "Package id 0", value: 62, type: "temperature_core" },
]);
expect(result).toBe(62);
});
});
@@ -0,0 +1,76 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { LidarrIntegration } from "../src/media-organizer/lidarr/lidarr-integration";
vi.mock("@homarr/db", async (importActual) => {
const actual = await importActual<typeof import("@homarr/db")>();
return { ...actual, db: createDb() };
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
};
});
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integrationInput = {
id: "test-lidarr",
name: "Lidarr",
url: "http://localhost:8686",
decryptedSecrets: [{ kind: "apiKey" as const, value: "test-key" }],
externalUrl: null,
};
describe("LidarrIntegration calendar", () => {
test("includes Lidarr artist link in calendar events", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{
title: "Test Album",
overview: "Overview",
images: [],
artist: {
artistName: "Test Artist",
titleSlug: "test-artist",
links: [{ name: "last", url: "https://last.fm/music/Test+Artist" }],
},
releaseDate: "2026-01-01T00:00:00Z",
},
]),
} as never);
const integration = new LidarrIntegration(integrationInput);
const events = await integration.getCalendarEventsAsync(new Date("2026-01-01"), new Date("2026-01-31"));
expect(events[0]?.links[0]).toMatchObject({
name: "Lidarr",
href: "http://localhost:8686/artist/test-artist",
logo: "/images/apps/lidarr.svg",
});
// The existing external artist link must survive the Lidarr-link prepend.
expect(events[0]?.links).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "LastFM",
href: "https://last.fm/music/Test+Artist",
logo: "/images/apps/lastfm.svg",
}),
]),
);
});
});
+4 -2
View File
@@ -1598,7 +1598,9 @@
},
"menu": {
"label": {
"settings": "Settings"
"settings": "Settings",
"options": "Options",
"actions": "Actions"
}
},
"create": {
@@ -4311,7 +4313,7 @@
"notStarted": "Not started"
},
"name": {
"management": "Management",
"management": "Management",
"dashboard": "Dashboard"
}
}
+2
View File
@@ -8,6 +8,8 @@
transition: scale 0.2s ease-in-out;
user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
touch-action: manipulation;
}
.appWithUrl:hover > .appIcon {
+1
View File
@@ -87,6 +87,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
{options.showTitle && (
+32 -1
View File
@@ -1,3 +1,4 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
@@ -9,7 +10,35 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
export interface WidgetContextMenuAction {
key: string;
label: stringOrTranslation;
icon?: TablerIcon;
onClick: () => void;
hidden?: boolean;
disabled?: boolean;
color?: string;
}
export interface WidgetContextMenuContext {
isEditMode: boolean;
boardId: string | undefined;
itemId: string | undefined;
}
export interface WidgetContextActionProps<
TKind extends WidgetKind,
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
> {
kind: TKind;
options: inferOptionsFromDefinition<TOptions>;
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
integrationIds: string[];
context: WidgetContextMenuContext;
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
}
const createWithDynamicImport =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
@@ -56,6 +85,7 @@ export interface WidgetDefinition {
}
>
>;
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
}
export interface WidgetProps<TKind extends WidgetKind> {
@@ -70,6 +100,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
};
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
+6 -1
View File
@@ -61,7 +61,12 @@ import * as video from "./video";
import * as weather from "./weather";
import * as customApi from "./custom-api";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type {
WidgetDefinition,
WidgetContextMenuAction,
WidgetContextActionProps,
WidgetOptionsSettings,
} from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
@@ -1,6 +1,7 @@
import { IconZoomQuestion } from "@tabler/icons-react";
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -14,5 +15,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
}),
}));
},
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));
@@ -1,6 +1,7 @@
import { IconChartBar } from "@tabler/icons-react";
import { IconChartBar, IconSearch } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
@@ -9,5 +10,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
createOptions() {
return {};
},
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));
+15 -1
View File
@@ -1,5 +1,6 @@
"use client";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActionIcon,
@@ -263,8 +264,21 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
setIsEditing(handleEditToggleCallback);
}, [setIsEditing, handleEditToggleCallback]);
const handleDoubleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!canChange || isEditing) return;
// Ignore double-clicks bubbling up from interactive controls (e.g. the
// edit/save ActionIcon), which would otherwise toggle edit mode twice.
if (event.target instanceof Element && event.target.closest("button, a")) {
return;
}
setIsEditing(handleEditToggleCallback);
},
[canChange, isEditing, setIsEditing, handleEditToggleCallback],
);
return (
<Box h="100%">
<Box h="100%" onDoubleClick={handleDoubleClick}>
<RichTextEditor
p={0}
mt={0}
+2
View File
@@ -13,6 +13,7 @@ import type { ReleasesRepository } from "./releases/releases-repository";
interface CommonInput<TType> {
defaultValue?: TType;
withDescription?: boolean;
skipContextMenu?: boolean;
}
interface TextInput extends CommonInput<string> {
@@ -91,6 +92,7 @@ const optionsFactory = {
type: "switch" as const,
defaultValue: input?.defaultValue ?? false,
withDescription: input?.withDescription ?? false,
skipContextMenu: input?.skipContextMenu ?? false,
}),
text: (input?: TextInput) => ({
type: "text" as const,
+2 -2
View File
@@ -247,7 +247,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
!hasError &&
formatter.relativeTime(repository.latestReleaseAt, {
now,
style: "narrow",
style: "long",
})}
</Text>
{hasError ? (
@@ -564,7 +564,7 @@ const ExpandedDisplay = ({
<Text className="releases-repository-expanded-createdAt-date" span fw={700}>
{formatter.relativeTime(repository.createdAt, {
now,
style: "narrow",
style: "long",
})}
</Text>
</Text>
+159 -129
View File
@@ -106,8 +106,8 @@ catalogs:
specifier: ^1.61.1
version: 1.61.1
'@scalar/api-reference-react':
specifier: ^0.9.48
version: 0.9.48
specifier: ^0.9.49
version: 0.9.49
'@semantic-release/changelog':
specifier: ^6.0.3
version: 6.0.3
@@ -337,8 +337,8 @@ catalogs:
specifier: ^0.2.9
version: 0.2.9
dockerode:
specifier: ^5.0.0
version: 5.0.0
specifier: ^5.0.1
version: 5.0.1
dotenv:
specifier: ^17.4.2
version: 17.4.2
@@ -445,8 +445,8 @@ catalogs:
specifier: ^1.18.0
version: 1.18.0
posthog-js:
specifier: ^1.393.4
version: 1.393.4
specifier: ^1.395.0
version: 1.395.0
posthog-node:
specifier: ^4.18.0
version: 4.18.0
@@ -705,7 +705,7 @@ importers:
version: 0.99.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@scalar/api-reference-react':
specifier: 'catalog:'
version: 0.9.48(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
version: 0.9.49(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@signalwire/docusaurus-plugin-llms-txt':
specifier: ^1.2.2
version: 1.2.2(@docusaurus/core@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@swc/helpers@0.5.15)(esbuild@0.28.1)(postcss@8.5.15))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(@rspack/core@1.7.11(@swc/helpers@0.5.15))(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))
@@ -916,7 +916,7 @@ importers:
version: 1.29.0(zod@4.4.3)
'@scalar/api-reference-react':
specifier: 'catalog:'
version: 0.9.48(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
version: 0.9.49(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
@@ -1012,7 +1012,7 @@ importers:
version: 1.18.0(postcss@8.5.15)
posthog-js:
specifier: 'catalog:'
version: 1.393.4
version: 1.395.0
prismjs:
specifier: 'catalog:'
version: 1.30.0
@@ -1771,7 +1771,7 @@ importers:
version: link:../core
dockerode:
specifier: 'catalog:'
version: 5.0.0
version: 5.0.1
devDependencies:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
@@ -6418,8 +6418,8 @@ packages:
'@polka/url@1.0.0-next.25':
resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
'@posthog/core@1.37.3':
resolution: {integrity: sha512-Dvw4CTlRVH4K5ag/B8YIHgG4E27w43MCUsXpn1iKQHIggIMN3Shq9whG4Omm3OvXPF+VikBTmpyMKUjckPxw+g==}
'@posthog/core@1.38.0':
resolution: {integrity: sha512-QLrJh0hMVpEJXHNyiJR9YhvIO5tzLDedw4UHdvB+ub4fXHMtHCA8H44qgl11HWR3ajpjxtJ8hs3HyOGVF3Zc1w==}
'@posthog/types@1.391.1':
resolution: {integrity: sha512-ASwd7Nf4pViqdYRYaNRyPYRVKWa1CcHUAUWR0XeQJLGdNnsWACBwe0sSieb/cHnKsRXjRwO/23KIY83lm/Ccpw==}
@@ -6707,46 +6707,50 @@ packages:
'@rvf/set-get@7.0.1':
resolution: {integrity: sha512-GkTSn9K1GrTYoTUqlUs36k6nJnzjQaFBTTEIqUYmzBcsGsoJM8xG7EAx2WLHWAA4QzFjcwWUSHQ3vM3Fbw50Tg==}
'@scalar/agent-chat@0.12.12':
resolution: {integrity: sha512-9PxyiNnSXuMDXcjPXgvFAPgfvUUsH/iS80Fpa0GGl3lO3ThsGDL2Lr+Y1UjxG1Tc2huGE/EDiUuT5osz9RwzMw==}
'@scalar/agent-chat@0.12.13':
resolution: {integrity: sha512-JPjwnOqIRb/TF+0bVNL7rg9SSjce3p0G++GF8uNvoGthixWuQxzo2pems4npZwe6Aw57icRTsZ3GBFgs9VmJvw==}
engines: {node: '>=22'}
'@scalar/api-client@3.11.0':
resolution: {integrity: sha512-6nxj8Saw/sfUM6xDxbMDfKKLDC933Vtig6m8hB0pSfXSlwXEvA5C9mUNO8ytodGPqBc/wphgFqTpfahOxp7R0w==}
'@scalar/api-client@3.12.0':
resolution: {integrity: sha512-NBZTMuqG9Qu+C0bUtMJPQEbGBLC5skXj55A/oDSzPwvTmWKV6I9Xc4wcrqVZMMidx315uC/WWyrB9WCUMgNi3Q==}
engines: {node: '>=22'}
'@scalar/api-reference-react@0.9.48':
resolution: {integrity: sha512-NmdQcmMDXIhKPIDRlA5m7cxGUAuiK42jOhaRsTT05TOcPXTF1lazcuN25o3ghevKtznIDqT+2/y/IYhbWS/UBw==}
'@scalar/api-reference-react@0.9.49':
resolution: {integrity: sha512-dtOrWZ++rFfsihNZKZ2pJ722bSkI3qp5CZKdiOY98PoJUWwPoSxT7C3NAn4NX59jQ0kuLhWDEELDpAcA779cvA==}
engines: {node: '>=22'}
peerDependencies:
react: ^18.0.0 || ^19.0.0
'@scalar/api-reference@1.61.0':
resolution: {integrity: sha512-r0U9Nqh3yV0BnU5mV2J/wDUcqwWorAUzXThHq0pbT09OzoKYO7asNB+d+bX5xhlLSvIuCMe5lAppQRfjlXzxWg==}
'@scalar/api-reference@1.62.0':
resolution: {integrity: sha512-QYPqMPfy4vyVLysHeSFZsD+717ua/gHIhYYu7UjqPzJUOVh2R+/9hINKKzvoP13lQWCQTmuzs2VEl8rQOIfDjw==}
engines: {node: '>=22'}
'@scalar/code-highlight@0.3.6':
resolution: {integrity: sha512-G/EtLF6a3IFGfIv5b7Uj5v/yXFY9+ktViVkfAkSoWJdHNBNk0S/Rb2OgRq24kp0D2TDXhLYF3E9OSHzquS7WqQ==}
'@scalar/blocks@0.1.0':
resolution: {integrity: sha512-oFnIa6dAkCt0N5yw5aqCABbP7r7YsI7ERNrGtlTDdrCteDRatMJg9y03LT+9Vhf2/K3VHWrdCZMruUdfU1uPzQ==}
engines: {node: '>=22'}
'@scalar/components@0.27.3':
resolution: {integrity: sha512-WiCMId4MFsbne1iy5N36Ej4fM+1TX6gg4DkbjWSNPTDtVWLMQ6VHdl46fxC6yOCtO59awJ7Ex6oKpbaXQsZiCA==}
'@scalar/code-highlight@0.4.0':
resolution: {integrity: sha512-ajUQ9oq5MwVHrXGze0SZVatgSXP/WbN4qgE0usYDFt1XuJ+O56TaBsCWb043r3Y9ZnHSnK9GHreVaLOxXsbf1A==}
engines: {node: '>=22'}
'@scalar/helpers@0.8.2':
resolution: {integrity: sha512-qNbqUjSB3S4Gr4A0oANcm5G1Ip+EqBxICYKhe9YzmnaBpbmW6shxqpiivApTvvuDf+uIhR3uMwWyVQbYcGLsxA==}
'@scalar/components@0.27.4':
resolution: {integrity: sha512-1sbsMYGJcWiQ+5GouI1hHCn1VrytHPhzhHhCAZTXxi1Tw4SzsPSQuACARehXca+w2p7RK9ayGRYQ+K5bX18SaQ==}
engines: {node: '>=22'}
'@scalar/helpers@0.9.0':
resolution: {integrity: sha512-M34CLRCttqC1bXthI/QSzQj0s5C6nrU2PFWf/vOT3RpycbiGDGQbqR+5RfFzpOIQvRqbHfNdcRbeiZBw+vCbkQ==}
engines: {node: '>=22'}
'@scalar/icons@0.7.3':
resolution: {integrity: sha512-5uSUvumj6yJEAZT7/MKpgrkNl76waDXUpu0kUBBFJ83GhZirBIK+Z9SktShEvJT0+rk/j9Zaer3BHoEhYwAEBQ==}
engines: {node: '>=22'}
'@scalar/json-magic@0.12.16':
resolution: {integrity: sha512-w8cDbZhHCzmIblWx92IVWoAXsbI4Fz3m++jiBANTSO1hgphF6UqEPQiCt3wnMPaxaanjMQxjS/iBk1UGXR2EGA==}
'@scalar/json-magic@0.12.17':
resolution: {integrity: sha512-Vw2nrUDIjhvMP6vxFtkiiFlabJ6SyTtfn1BsOxgnr1hIB+/rkngMguiDzl5em21VjyfFGIoADia+QWKM2hdcdA==}
engines: {node: '>=22'}
'@scalar/oas-utils@0.19.0':
resolution: {integrity: sha512-t9CBiH0we2rKlFaFJUt+dOAKYQPNhHdrrfX9Wx1Yfg9og1NM2H2BQSO7NoCaUN/+G86GNWIm+fr5R8iCM+/psw==}
'@scalar/oas-utils@0.19.1':
resolution: {integrity: sha512-FflVWiFDm0s4a+FqKJ0uYsVhDzbAtVyXSyUWsGe9BGPPQSXYYvVEVp9H2ir4xueaw4TZb1cDEZbteHLx7h7vqg==}
engines: {node: '>=22'}
'@scalar/openapi-types@0.9.1':
@@ -6757,27 +6761,27 @@ packages:
resolution: {integrity: sha512-D5b0rGLLZgmkO9mdW2j/ND1KBlH1u3RCpr87HPxv9P9ZSr6PtM5iLqFOJq0ACiaHjY2mikCrxgDmnUEhTzRpHQ==}
engines: {node: '>=22'}
'@scalar/schemas@0.6.0':
resolution: {integrity: sha512-Oacaq7RTLhe3vWOoNc8tZufqthetu1VjWkdrGMMYoVSyMBQpvDI48gwBBQO6+iySeNpk9y8NHaGxqhHeoLbDiQ==}
'@scalar/schemas@0.7.0':
resolution: {integrity: sha512-Roj0e7S29yTbGojwdx/b8C1H5arVN4h/VSPrtQ5vb9NT2ZAbrfPQVmpQYrF845rKOWX7kSzIvyBwrKNEMIAkxA==}
engines: {node: '>=22'}
'@scalar/sidebar@0.9.24':
resolution: {integrity: sha512-whfvictmBQrtdNANLzDe4cXNlhj3p1RUtSbwiSERf5TSYCQwU5J2JAG2zUPUJhNdpNUzg1wdQnE5Q3lp+MAcHQ==}
'@scalar/sidebar@0.9.25':
resolution: {integrity: sha512-RoMKTvCo801kj5J59Myqhc6YYtvjlXGNvP7qbj/TY2hfQ4XS5dH5S7t8JKJje+78MLC2Xg9OsEmCjv+gb8ta4w==}
engines: {node: '>=22'}
'@scalar/snippetz@0.9.18':
resolution: {integrity: sha512-C0lflx3Az9epM4dFsDl73w5fxgyw68ZMTr8W0fkLHJvWTM8q26jjDDrgDCDivG0SBc2UUs3xRKSjjsfC6TkZKw==}
'@scalar/snippetz@0.9.19':
resolution: {integrity: sha512-RVTZQqUxOiBhAuPywDmser1xOatVj7HCNmg+qpW8172MsMpx00lRGZ940WVAhjHgKglTkwHPaTdVX7gQeDrFUQ==}
engines: {node: '>=22'}
'@scalar/themes@0.16.1':
resolution: {integrity: sha512-yTMjqlTAYZrlJsHIuDiCVJjMJZ1UzL33C/60c88JYuvuuwSXm15TAEcLXkRrPa7u8PPz/llKGgWjMEiMhsOAGA==}
'@scalar/themes@0.16.2':
resolution: {integrity: sha512-4mAn7z2W5/ASi1OF06dqf04xjxTHnMzESC2E+qVMukJsEsV4kDlYSIfm/g5hwWxhqWsBMjorF9Dtlqd0ycJ92g==}
engines: {node: '>=22'}
'@scalar/typebox@0.1.3':
resolution: {integrity: sha512-lU055AUccECZMIfGA0z/C1StYmboAYIPJLDFBzOO81yXBi35Pxdq+I4fWX6iUZ8qcoHneiLGk9jAUM1rA93iEg==}
'@scalar/types@0.15.0':
resolution: {integrity: sha512-Rn9XAFrnQL4cAINCyNlPdvSbd3+Cvqgv2SBX1J8WgtZLxqC32+8Ptmns51Lvn6zKeiX+mFHk1UdvIWmqiG1xRg==}
'@scalar/types@0.16.0':
resolution: {integrity: sha512-26OSrvZjKWZ7F236wWmJajBGDVFJuvXFJqKPFqbt/PxlgZKXQXfJsZorASRQmdNogT576nxYalQ7oaYWEnQwfw==}
engines: {node: '>=22'}
'@scalar/use-codemirror@0.14.12':
@@ -6796,8 +6800,8 @@ packages:
resolution: {integrity: sha512-tpmmG+/xRE2Kn9RpflU3AIyZv08v10+E1ZrJCx7z6+/91zHVxy0M73kC1LT4/8PbYNt85ywyC8+n+D99JdMcGA==}
engines: {node: '>=20'}
'@scalar/workspace-store@0.54.5':
resolution: {integrity: sha512-MMaELRxKNwJ1dxbDcFNRwh229jAX5DQhkQpHgDSbr+oAt6AqFf2BTr9Fr/8kbLIJK+L4P2C8rANE8xrnqjKGLg==}
'@scalar/workspace-store@0.55.0':
resolution: {integrity: sha512-4EkaLfONW8qu2vigJopbjg9ahE/Z9Q+IInnVoUTZfxLp4rb2Z3ztx8LmQOym8DPpv62BvuMtJcNdQOnQ6tVLHA==}
engines: {node: '>=22'}
'@schummar/icu-type-parser@1.21.5':
@@ -9678,6 +9682,10 @@ packages:
resolution: {integrity: sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ==}
engines: {node: '>= 14.17'}
dockerode@5.0.1:
resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==}
engines: {node: '>= 14.17'}
docusaurus-plugin-image-zoom@3.0.1:
resolution: {integrity: sha512-mQrqA99VpoMQJNbi02qkWAMVNC4+kwc6zLLMNzraHAJlwn+HrlUmZSEDcTwgn+H4herYNxHKxveE2WsYy73eGw==}
peerDependencies:
@@ -12009,11 +12017,6 @@ packages:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
nanoid@5.1.15:
resolution: {integrity: sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==}
engines: {node: ^18 || >=20}
hasBin: true
nanoid@5.1.16:
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
engines: {node: ^18 || >=20}
@@ -13116,8 +13119,8 @@ packages:
resolution: {integrity: sha512-Ray65LYEJrMMqDtsBUBXunEVP/g4wtATvq/xz9rchUoLy/9mSkkFgUko/8DVtGxgiP3vivpFMgfb9HpCuDrBHg==}
engines: {node: '>=10.15.1'}
posthog-js@1.393.4:
resolution: {integrity: sha512-mOBSVQczEfyLnW3aFRbLXZEuE0Rfbmhqm9UCnuLeMFUUz0uYTkbnyhl5+Uc+BLSGfyiXHLf3nm2BqxYC+G/PXA==}
posthog-js@1.395.0:
resolution: {integrity: sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==}
posthog-node@4.18.0:
resolution: {integrity: sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==}
@@ -17951,7 +17954,7 @@ snapshots:
'@esbuild-kit/core-utils@3.3.2':
dependencies:
esbuild: 0.28.0
esbuild: 0.28.1
source-map-support: 0.5.21
'@esbuild-kit/esm-loader@2.6.5':
@@ -19897,7 +19900,7 @@ snapshots:
'@polka/url@1.0.0-next.25': {}
'@posthog/core@1.37.3':
'@posthog/core@1.38.0':
dependencies:
'@posthog/types': 1.391.1
@@ -20116,21 +20119,21 @@ snapshots:
'@rvf/set-get@7.0.1': {}
'@scalar/agent-chat@0.12.12(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
'@scalar/agent-chat@0.12.13(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
dependencies:
'@ai-sdk/vue': 3.0.33(vue@3.5.35(typescript@6.0.3))(zod@4.4.3)
'@scalar/api-client': 3.11.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/components': 0.27.3(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.8.2
'@scalar/api-client': 3.12.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/components': 0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/json-magic': 0.12.16
'@scalar/json-magic': 0.12.17
'@scalar/openapi-types': 0.9.1
'@scalar/schemas': 0.6.0
'@scalar/themes': 0.16.1
'@scalar/types': 0.15.0
'@scalar/schemas': 0.7.0
'@scalar/themes': 0.16.2
'@scalar/types': 0.16.0
'@scalar/use-toasts': 0.10.2(typescript@6.0.3)
'@scalar/validation': 0.6.0
'@scalar/workspace-store': 0.54.5(typescript@6.0.3)
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
'@vueuse/core': 13.9.0(vue@3.5.35(typescript@6.0.3))
ai: 6.0.33(zod@4.4.3)
js-base64: 3.7.8
@@ -20154,33 +20157,32 @@ snapshots:
- universal-cookie
- zod
'@scalar/api-client@3.11.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)':
'@scalar/api-client@3.12.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)':
dependencies:
'@headlessui/tailwindcss': 0.2.2(tailwindcss@4.3.1)
'@headlessui/vue': 1.7.23(vue@3.5.35(typescript@6.0.3))
'@scalar/components': 0.27.3(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.8.2
'@scalar/blocks': 0.1.0(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/components': 0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/json-magic': 0.12.16
'@scalar/oas-utils': 0.19.0(typescript@6.0.3)
'@scalar/oas-utils': 0.19.1(typescript@6.0.3)
'@scalar/openapi-types': 0.9.1
'@scalar/sidebar': 0.9.24(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/snippetz': 0.9.18
'@scalar/themes': 0.16.1
'@scalar/sidebar': 0.9.25(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/snippetz': 0.9.19
'@scalar/themes': 0.16.2
'@scalar/typebox': 0.1.3
'@scalar/types': 0.15.0
'@scalar/types': 0.16.0
'@scalar/use-codemirror': 0.14.12(typescript@6.0.3)
'@scalar/use-hooks': 0.4.7(typescript@6.0.3)
'@scalar/use-toasts': 0.10.2(typescript@6.0.3)
'@scalar/workspace-store': 0.54.5(typescript@6.0.3)
'@types/har-format': 1.2.16
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
'@vueuse/core': 13.9.0(vue@3.5.35(typescript@6.0.3))
'@vueuse/integrations': 13.9.0(focus-trap@7.8.0)(fuse.js@7.4.2)(nprogress@0.2.0)(vue@3.5.35(typescript@6.0.3))
focus-trap: 7.8.0
fuse.js: 7.4.2
js-base64: 3.7.8
jsonc-parser: 3.3.1
nanoid: 5.1.15
nanoid: 5.1.16
pretty-ms: 9.3.0
radix-vue: 1.9.17(vue@3.5.35(typescript@6.0.3))
set-cookie-parser: 3.1.0
@@ -20203,10 +20205,10 @@ snapshots:
- typescript
- universal-cookie
'@scalar/api-reference-react@0.9.48(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
'@scalar/api-reference-react@0.9.49(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
dependencies:
'@scalar/api-reference': 1.61.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@scalar/types': 0.15.0
'@scalar/api-reference': 1.62.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@scalar/types': 0.16.0
react: 19.2.7
transitivePeerDependencies:
- '@vue/composition-api'
@@ -20225,30 +20227,31 @@ snapshots:
- universal-cookie
- zod
'@scalar/api-reference@1.61.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
'@scalar/api-reference@1.62.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
dependencies:
'@headlessui/vue': 1.7.23(vue@3.5.35(typescript@6.0.3))
'@scalar/agent-chat': 0.12.12(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@scalar/api-client': 3.11.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/code-highlight': 0.3.6
'@scalar/components': 0.27.3(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.8.2
'@scalar/agent-chat': 0.12.13(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@scalar/api-client': 3.12.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/blocks': 0.1.0(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/code-highlight': 0.4.0
'@scalar/components': 0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/oas-utils': 0.19.0(typescript@6.0.3)
'@scalar/schemas': 0.6.0
'@scalar/sidebar': 0.9.24(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/snippetz': 0.9.18
'@scalar/themes': 0.16.1
'@scalar/types': 0.15.0
'@scalar/oas-utils': 0.19.1(typescript@6.0.3)
'@scalar/schemas': 0.7.0
'@scalar/sidebar': 0.9.25(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/snippetz': 0.9.19
'@scalar/themes': 0.16.2
'@scalar/types': 0.16.0
'@scalar/use-hooks': 0.4.7(typescript@6.0.3)
'@scalar/use-toasts': 0.10.2(typescript@6.0.3)
'@scalar/validation': 0.6.0
'@scalar/workspace-store': 0.54.5(typescript@6.0.3)
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
'@unhead/vue': 2.1.15(vue@3.5.35(typescript@6.0.3))
'@vueuse/core': 13.9.0(vue@3.5.35(typescript@6.0.3))
fuse.js: 7.4.2
microdiff: 1.5.0
nanoid: 5.1.15
nanoid: 5.1.16
vue: 3.5.35(typescript@6.0.3)
yaml: 2.9.0
transitivePeerDependencies:
@@ -20268,7 +20271,25 @@ snapshots:
- universal-cookie
- zod
'@scalar/code-highlight@0.3.6':
'@scalar/blocks@0.1.0(tailwindcss@4.3.1)(typescript@6.0.3)':
dependencies:
'@scalar/components': 0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/snippetz': 0.9.19
'@scalar/themes': 0.16.2
'@scalar/types': 0.16.0
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
'@types/har-format': 1.2.16
js-base64: 3.7.8
vue: 3.5.35(typescript@6.0.3)
transitivePeerDependencies:
- '@vue/composition-api'
- supports-color
- tailwindcss
- typescript
'@scalar/code-highlight@0.4.0':
dependencies:
hast-util-to-text: 4.0.2
highlight.js: 11.11.1
@@ -20288,16 +20309,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@scalar/components@0.27.3(tailwindcss@4.3.1)(typescript@6.0.3)':
'@scalar/components@0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/vue': 1.1.9(vue@3.5.35(typescript@6.0.3))
'@headlessui/tailwindcss': 0.2.2(tailwindcss@4.3.1)
'@headlessui/vue': 1.7.23(vue@3.5.35(typescript@6.0.3))
'@scalar/code-highlight': 0.3.6
'@scalar/helpers': 0.8.2
'@scalar/code-highlight': 0.4.0
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/themes': 0.16.1
'@scalar/themes': 0.16.2
'@scalar/use-hooks': 0.4.7(typescript@6.0.3)
'@vueuse/core': 13.9.0(vue@3.5.35(typescript@6.0.3))
cva: 1.0.0-beta.4(typescript@6.0.3)
@@ -20310,7 +20331,7 @@ snapshots:
- tailwindcss
- typescript
'@scalar/helpers@0.8.2': {}
'@scalar/helpers@0.9.0': {}
'@scalar/icons@0.7.3(typescript@6.0.3)':
dependencies:
@@ -20321,18 +20342,18 @@ snapshots:
transitivePeerDependencies:
- typescript
'@scalar/json-magic@0.12.16':
'@scalar/json-magic@0.12.17':
dependencies:
'@scalar/helpers': 0.8.2
'@scalar/helpers': 0.9.0
pathe: 2.0.3
yaml: 2.9.0
'@scalar/oas-utils@0.19.0(typescript@6.0.3)':
'@scalar/oas-utils@0.19.1(typescript@6.0.3)':
dependencies:
'@scalar/helpers': 0.8.2
'@scalar/themes': 0.16.1
'@scalar/types': 0.15.0
'@scalar/workspace-store': 0.54.5(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/themes': 0.16.2
'@scalar/types': 0.16.0
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
flatted: 3.4.2
vue: 3.5.35(typescript@6.0.3)
yaml: 2.9.0
@@ -20345,19 +20366,19 @@ snapshots:
dependencies:
'@scalar/openapi-types': 0.9.1
'@scalar/schemas@0.6.0':
'@scalar/schemas@0.7.0':
dependencies:
'@scalar/helpers': 0.8.2
'@scalar/helpers': 0.9.0
'@scalar/validation': 0.6.0
'@scalar/sidebar@0.9.24(tailwindcss@4.3.1)(typescript@6.0.3)':
'@scalar/sidebar@0.9.25(tailwindcss@4.3.1)(typescript@6.0.3)':
dependencies:
'@scalar/components': 0.27.3(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.8.2
'@scalar/components': 0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/themes': 0.16.1
'@scalar/themes': 0.16.2
'@scalar/use-hooks': 0.4.7(typescript@6.0.3)
'@scalar/workspace-store': 0.54.5(typescript@6.0.3)
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
vue: 3.5.35(typescript@6.0.3)
transitivePeerDependencies:
- '@vue/composition-api'
@@ -20365,24 +20386,24 @@ snapshots:
- tailwindcss
- typescript
'@scalar/snippetz@0.9.18':
'@scalar/snippetz@0.9.19':
dependencies:
'@scalar/helpers': 0.8.2
'@scalar/types': 0.15.0
'@scalar/helpers': 0.9.0
'@scalar/types': 0.16.0
js-base64: 3.7.8
stringify-object: 6.0.0
'@scalar/themes@0.16.1':
'@scalar/themes@0.16.2':
dependencies:
nanoid: 5.1.15
nanoid: 5.1.16
'@scalar/typebox@0.1.3': {}
'@scalar/types@0.15.0':
'@scalar/types@0.16.0':
dependencies:
'@scalar/helpers': 0.8.2
nanoid: 5.1.15
type-fest: 5.6.0
'@scalar/helpers': 0.9.0
nanoid: 5.1.16
type-fest: 5.7.0
zod: 4.4.3
'@scalar/use-codemirror@0.14.12(typescript@6.0.3)':
@@ -20425,18 +20446,18 @@ snapshots:
'@scalar/validation@0.6.0': {}
'@scalar/workspace-store@0.54.5(typescript@6.0.3)':
'@scalar/workspace-store@0.55.0(typescript@6.0.3)':
dependencies:
'@scalar/helpers': 0.8.2
'@scalar/json-magic': 0.12.16
'@scalar/helpers': 0.9.0
'@scalar/json-magic': 0.12.17
'@scalar/openapi-upgrader': 0.2.9
'@scalar/schemas': 0.6.0
'@scalar/snippetz': 0.9.18
'@scalar/schemas': 0.7.0
'@scalar/snippetz': 0.9.19
'@scalar/typebox': 0.1.3
'@scalar/types': 0.15.0
'@scalar/types': 0.16.0
'@scalar/validation': 0.6.0
js-base64: 3.7.8
type-fest: 5.6.0
type-fest: 5.7.0
vue: 3.5.35(typescript@6.0.3)
yaml: 2.9.0
transitivePeerDependencies:
@@ -23479,6 +23500,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
dockerode@5.0.1:
dependencies:
'@balena/dockerignore': 1.0.2
'@grpc/grpc-js': 1.12.5
'@grpc/proto-loader': 0.7.13
docker-modem: 5.0.7
protobufjs: 7.4.0
tar-fs: 2.1.4
transitivePeerDependencies:
- supports-color
docusaurus-plugin-image-zoom@3.0.1(@docusaurus/theme-classic@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@swc/helpers@0.5.15)(esbuild@0.28.1)(postcss@8.5.15))(@rspack/core@1.7.11(@swc/helpers@0.5.15))(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)):
dependencies:
'@docusaurus/theme-classic': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@swc/helpers@0.5.15)(esbuild@0.28.1)(postcss@8.5.15))(@rspack/core@1.7.11(@swc/helpers@0.5.15))(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
@@ -26327,8 +26359,6 @@ snapshots:
nanoid@3.3.12: {}
nanoid@5.1.15: {}
nanoid@5.1.16: {}
nanostores@1.3.0: {}
@@ -27405,9 +27435,9 @@ snapshots:
posthog-docusaurus@2.0.5: {}
posthog-js@1.393.4:
posthog-js@1.395.0:
dependencies:
'@posthog/core': 1.37.3
'@posthog/core': 1.38.0
'@posthog/types': 1.391.1
core-js: 3.49.0
dompurify: 3.4.11
+3 -3
View File
@@ -88,11 +88,11 @@ catalog:
"@types/prismjs": ^1.26.6
"@types/react": 19.2.17
"@types/react-dom": 19.2.3
"@scalar/api-reference-react": ^0.9.48
"@scalar/api-reference-react": ^0.9.49
"@types/video.js": ^7.3.58
"@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14
posthog-js: ^1.393.4
posthog-js: ^1.395.0
posthog-node: ^4.18.0
"@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.9
@@ -118,7 +118,7 @@ catalog:
dayjs: ^1.11.21
deepmerge: 4.3.1
dns-caching: ^0.2.9
dockerode: ^5.0.0
dockerode: ^5.0.1
dotenv: ^17.4.2
dotenv-cli: ^11.0.0
drizzle-kit: ^0.31.10
+1 -1
View File
@@ -7,7 +7,7 @@ runs:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24.17.0
node-version: 24.18.0
cache: "pnpm"
- shell: bash