Compare commits

..
Author SHA1 Message Date
ajnartandCopilot 5d9bfa3978 fix(boards): open tabs before async fetch in bulk action
Pre-open blank tabs synchronously to preserve browser user-activation, then navigate those tab handles after app URLs are fetched. Also close surplus tabs and update tests for the new flow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-06 10:05:57 +02:00
hirrrooo b97856dcb8 docs: mention board-wide open all apps action 2026-06-29 07:46:11 +00:00
hirrrooo d6e22a6150 refactor(boards): extract shared openAppsInNewTabs helper 2026-06-29 07:26:27 +00:00
hirrrooo dceb3ebee7 fix(boards): prevent opener access for opened apps 2026-06-29 07:22:17 +00:00
hirrrooo 7c3b42f3fd fix(boards): de-duplicate open all app IDs 2026-06-29 07:02:56 +00:00
hirrrooo 224648a708 feat(boards): add board-wide open all apps in new tabs action 2026-06-29 06:47:34 +00:00
homarr-renovate[bot] 7a21e2a9c3 chore(deps): update dependency @playwright/test to ^1.61.1 (#6120)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 21:14:08 +00:00
homarr-renovate[bot] c4a0c818d0 chore(deps): update mantine monorepo to ^9.4.0 (#6102)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 19:32:49 +00:00
homarr-renovate[bot] ef016061ae chore(deps): update pnpm to v11.9.0 (#6117)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 17:26:09 +00:00
homarr-renovate[bot] 3492700e6b chore(deps): update dependency vite to ^8.1.0 (#6114)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 14:06:36 +00:00
homarr-renovate[bot] 590d316671 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.1.0 (#6115)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 14:03:05 +00:00
homarr-renovate[bot] 6e9d20e651 chore(deps): update dependency recharts to ^3.9.0 (#6113)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 14:00:52 +00:00
homarr-renovate[bot] ed829fddfa chore(deps): update dependency posthog-js to ^1.393.0 (#6112)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 13:57:57 +00:00
homarr-renovate[bot] ed8b8a40eb chore(deps): update dependency @vitejs/plugin-react to ^6.0.3 (#6110)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 11:18:49 +00:00
homarr-renovate[bot] 7bc3bbb8c0 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.6 (#6109)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 09:03:24 +00:00
homarr-renovate[bot] 74943ef9bc chore(deps): update dependency hono@<4.6.5 to >=4.12.27 (#6108)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 05:28:04 +00:00
homarr-renovate[bot] 5dd7f839ee chore(deps): update dependency axios@>=1.0.0 <1.8.2 to >=1.18.1 (#6104)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 00:16:33 +00:00
homarr-renovate[bot] d8c6c0f978 chore(deps): update dependency posthog-js to ^1.391.4 (#6094)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 00:16:09 +00:00
homarr-renovate[bot] 75b2d1bd1b chore(deps): update dependency oxlint to ^1.71.0 (#6101)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 00:15:23 +00:00
homarr-renovate[bot] 7c12a9ce48 chore(deps): update dependency @scalar/api-reference-react to ^0.9.48 (#6103)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 00:13:44 +00:00
homarr-renovate[bot] 9033acc66c chore(deps): update dependency oxfmt to ^0.56.0 (#6100)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-27 22:17:27 +00:00
Thomas a42dc34d34 Revert "feat(config): enable pnpm global virtual store for worktree support" (#6106) 2026-06-27 23:37:36 +02:00
euduardo db3d58821e feat(widgets): show calendar events on hover with pin on click (#2637) (#6081)
feat(widgets): show calendar events on hover with pin on click (#2637)

docs(widgets): update calendar interactions section for hover behavior
2026-06-27 22:49:26 +02:00
Bergasha 076526293d feat(docker-widget): add per-column visibility toggle (#6093)
* Add 'Columns to show' label in Docker stats

* feat: Add column translation keys for Docker widget

* feat: Enhance table with column visibility state

Added column visibility state management for the table.

* Add 'noColumns' error message to en.json
2026-06-27 22:44:39 +02:00
Thomasandajnart 2443f1cd3f fix: dns-hole widgets showing stale disconnected state (#6096)
Co-authored-by: ajnart <ajnart@users.noreply.github.com>
2026-06-27 22:37:34 +02:00
Thomas 3864727502 feat(config): enable pnpm global virtual store for worktree support (#6098) 2026-06-27 13:23:01 +02:00
homarr-releases[bot] f3608b26a0 chore: update bug report template (#6091)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-26 18:43:53 +00:00
Thomas 73bed51df8 fix(widgets): make downloads widget table background transparent (#6025)
The MantineReactTable defaults table cell backgrounds to
var(--mantine-color-body) which is opaque. This makes the
downloads widget table block appear solid while the footer
and other widgets correctly show the board's background.

Added a styles.css targeting table elements to force
background-color: transparent, following the same pattern
used by the beszel-system-table widget. Also added
bg=transparent to mantinePaperProps for the Paper wrapper.
2026-06-26 20:27:25 +02:00
25 changed files with 945 additions and 738 deletions

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.68.0
- 1.67.0
- 1.66.1
- 1.66.0
-62
View File
@@ -163,65 +163,3 @@ 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"
-7
View File
@@ -1,10 +1,3 @@
## [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
+14
View File
@@ -20,6 +20,17 @@ 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
@@ -64,6 +75,9 @@ 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"]
@@ -316,6 +316,7 @@ You can drag and drop items in to this category in the edit mode.
Categories will have a big space before and after them in the edit mode to
enable you to drag and drop items outside the category.
You can also collapse or open categories in edit mode to facilitate your editing process.
Use the category menu to open every app in that category in new tabs, or use the board header action to open every app on the current board layout in new tabs.
:::note
@@ -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.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Version: 8.19.0](https://img.shields.io/badge/Version-8.19.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-informational?style=flat)
![AppVersion: v1.60.0](https://img.shields.io/badge/AppVersion-v1.60.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -383,7 +383,6 @@ 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 |
@@ -396,6 +395,7 @@ 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 |
+3 -1
View File
@@ -63,7 +63,9 @@ import withoutIntegrations from './img/without-integrations.png';
### Interactions
- View calendar events by clicking on the marked dates.
- Hover over a marked date to preview its events in a popover.
- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away).
- Click the date again or click outside to close the pinned popover.
### Adding the widget
+4
View File
@@ -31,6 +31,8 @@ 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 */
@@ -41,6 +43,8 @@ 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,
@@ -9,6 +9,7 @@ import {
IconBox,
IconBoxAlignTop,
IconChevronDown,
IconExternalLink,
IconLayoutBoard,
IconPencil,
IconPencilOff,
@@ -21,8 +22,11 @@ import {
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { openAppsInNewTabs } from "~/components/board/open-apps-in-new-tabs";
import { filterByItemKind } from "~/components/board/sections/category/filter";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
@@ -46,9 +50,34 @@ export const BoardContentHeaderActions = () => {
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
const settings = useSettings();
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board);
const appIds = filterByItemKind(
board.items.filter((item) => item.layouts.some((layout) => layout.layoutId === currentLayoutId)),
settings,
"app",
).map((item) => {
return item.options.appId;
});
await openAppsInNewTabs(appIds, { t, openConfirmModal });
}, [board, t, openConfirmModal, settings]);
if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />;
return (
<>
{!isLoading && (
<HeaderButton onClick={openAllInNewTabs} aria-label={t("section.category.action.openAllInNewTabs")}>
<IconExternalLink stroke={1.5} />
</HeaderButton>
)}
<SelectBoardsMenu />
</>
);
}
return (
@@ -57,6 +86,12 @@ export const BoardContentHeaderActions = () => {
<EditModeMenu demoMode={demoMode ?? false} />
{!isEditMode && (
<HeaderButton onClick={openAllInNewTabs} aria-label={t("section.category.action.openAllInNewTabs")}>
<IconExternalLink stroke={1.5} />
</HeaderButton>
)}
{!demoMode && (
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
@@ -0,0 +1,48 @@
import { fetchApi } from "@homarr/api/client";
type AppWithHref = {
href: string | null;
};
type TranslationKey = "section.category.openAllInNewTabs.title" | "section.category.openAllInNewTabs.text";
interface OpenAppsInNewTabsOptions {
t: (key: TranslationKey) => string;
openConfirmModal: (options: { title: string; children: string }) => void;
fetchAppsByIds?: (appIds: string[]) => Promise<AppWithHref[]>;
}
export const openAppsInNewTabs = async (
appIds: string[],
{ t, openConfirmModal, fetchAppsByIds = fetchApi.app.byIds.query }: OpenAppsInNewTabsOptions,
) => {
if (appIds.length === 0) return;
const distinctAppIds = [...new Set(appIds)];
const openedWindows = distinctAppIds.map(() => window.open("", "_blank", "noopener,noreferrer"));
for (const openedWindow of openedWindows) {
if (!openedWindow) continue;
openedWindow.opener = null;
}
const apps = await fetchAppsByIds(distinctAppIds);
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
const tabsToClose = openedWindows.slice(appsWithUrls.length).filter((openedWindow): openedWindow is Window => Boolean(openedWindow));
for (const tabToClose of tabsToClose) {
tabToClose.close();
}
for (const [index, app] of appsWithUrls.entries()) {
const openedWindow = openedWindows[index];
if (openedWindow) {
openedWindow.location.href = app.href;
continue;
}
openConfirmModal({
title: t("section.category.openAllInNewTabs.title"),
children: t("section.category.openAllInNewTabs.text"),
});
break;
}
};
@@ -1,6 +1,5 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
@@ -8,6 +7,7 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { CategorySection } from "~/app/[locale]/boards/_types";
import { openAppsInNewTabs } from "~/components/board/open-apps-in-new-tabs";
import { useCategoryActions } from "./category-actions";
import { CategoryEditModal } from "./category-edit-modal";
import { filterByItemKind } from "./filter";
@@ -115,21 +115,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
return item.options.appId;
});
const apps = await fetchApi.app.byIds.query(appIds);
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
for (const app of appsWithUrls) {
const openedWindow = window.open(app.href ?? undefined);
if (openedWindow) {
continue;
}
openConfirmModal({
title: t("section.category.openAllInNewTabs.title"),
children: t("section.category.openAllInNewTabs.text"),
});
break;
}
await openAppsInNewTabs(appIds, { t, openConfirmModal });
}, [category, board, t, openConfirmModal, settings]);
return {
@@ -0,0 +1,65 @@
import { describe, expect, test, vi } from "vitest";
import { openAppsInNewTabs } from "../open-apps-in-new-tabs";
describe("openAppsInNewTabs", () => {
test("opens tabs synchronously and navigates them to fetched URLs", async () => {
const openedWindowOne = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
const openedWindowTwo = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
const openedWindowThree = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
const open = vi.spyOn(window, "open").mockReturnValueOnce(openedWindowOne).mockReturnValueOnce(openedWindowTwo).mockReturnValueOnce(openedWindowThree);
const fetchAppsByIds = vi
.fn()
.mockResolvedValue([{ href: "https://example.com/one" }, { href: null }, { href: "https://example.com/two" }]);
const openConfirmModal = vi.fn();
await openAppsInNewTabs(["app-one", "app-two", "app-three"], {
t: (key) => key,
openConfirmModal,
fetchAppsByIds,
});
expect(fetchAppsByIds).toHaveBeenCalledWith(["app-one", "app-two", "app-three"]);
expect(open).toHaveBeenCalledTimes(3);
expect(open).toHaveBeenNthCalledWith(1, "", "_blank", "noopener,noreferrer");
expect(open).toHaveBeenNthCalledWith(2, "", "_blank", "noopener,noreferrer");
expect(open).toHaveBeenNthCalledWith(3, "", "_blank", "noopener,noreferrer");
expect(openedWindowOne.location.href).toBe("https://example.com/one");
expect(openedWindowTwo.location.href).toBe("https://example.com/two");
expect(openedWindowThree.close).toHaveBeenCalledTimes(1);
expect(openConfirmModal).not.toHaveBeenCalled();
});
test("de-duplicates app IDs before fetching", async () => {
const openedWindow = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
const open = vi.spyOn(window, "open").mockReturnValue(openedWindow);
const fetchAppsByIds = vi.fn().mockResolvedValue([{ href: "https://example.com/one" }]);
const openConfirmModal = vi.fn();
await openAppsInNewTabs(["app-one", "app-one", "app-one"], {
t: (key) => key,
openConfirmModal,
fetchAppsByIds,
});
expect(fetchAppsByIds).toHaveBeenCalledWith(["app-one"]);
expect(open).toHaveBeenCalledTimes(1);
});
test("shows confirm modal when popup is blocked", async () => {
vi.spyOn(window, "open").mockReturnValue(null);
const fetchAppsByIds = vi.fn().mockResolvedValue([{ href: "https://example.com/one" }]);
const openConfirmModal = vi.fn();
await openAppsInNewTabs(["app-one"], {
t: (key) => key,
openConfirmModal,
fetchAppsByIds,
});
expect(openConfirmModal).toHaveBeenCalledWith({
title: "section.category.openAllInNewTabs.title",
children: "section.category.openAllInNewTabs.text",
});
});
});
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.17.0",
"pnpm": ">=11.8.0"
"pnpm": ">=11.9.0"
},
"packageManager": "pnpm@11.8.0"
"packageManager": "pnpm@11.9.0"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.68.1",
"version": "1.68.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -62,9 +62,9 @@
},
"engines": {
"node": ">=24.17.0",
"pnpm": ">=11.8.0"
"pnpm": ">=11.9.0"
},
"packageManager": "pnpm@11.8.0",
"packageManager": "pnpm@11.9.0",
"dependencies": {
"jsonpath-plus": "catalog:"
}
+5 -1
View File
@@ -2357,6 +2357,9 @@
"name": "Docker stats",
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
"option": {
"columns": {
"label": "Columns to show"
},
"enableRowSorting": {
"label": "Enable items sorting"
},
@@ -5096,7 +5099,8 @@
}
},
"error": {
"internalServerError": "Failed to fetch Docker containers"
"internalServerError": "Failed to fetch Docker containers",
"noColumns": "Select at least one column in the widget settings"
}
},
"kubernetes": {
+33 -7
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -13,10 +13,12 @@ interface CalendarDayProps {
disabled: boolean;
rootWidth: number;
rootHeight: number;
onOpen: (close: () => void) => void;
}
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onOpen }: CalendarDayProps) => {
const [opened, setOpened] = useState(false);
const [pinned, setPinned] = useState(false);
const { primaryColor } = useMantineTheme();
const { ref, height } = useElementSize();
const board = useRequiredBoard();
@@ -29,6 +31,19 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
const isTooSmallForIndicators = height < 30;
const handleMouseEnter = useCallback(() => {
if (disabled) return;
onOpen(() => {
setOpened(false);
setPinned(false);
});
setOpened(true);
}, [disabled, onOpen]);
const handleMouseLeave = useCallback(() => {
if (!pinned) setOpened(false);
}, [pinned]);
return (
<Popover
position="bottom"
@@ -39,7 +54,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
transitionProps={{
transition: "pop",
}}
onChange={setOpened}
onChange={(value) => {
setOpened(value);
if (!value) setPinned(false);
}}
opened={opened}
disabled={disabled}
>
@@ -59,10 +77,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
cursor: disabled ? "default" : "pointer",
}}
onClick={() => {
if (disabled) return;
setOpened((prev) => !prev);
if (!disabled) setPinned((prev) => !prev);
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
@@ -71,7 +89,15 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
</Container>
</Popover.Target>
{/* Popover has some offset on the left side, padding is removed because of scrollarea paddings */}
<Popover.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
<Popover.Dropdown
maw="calc(100vw - 24px)"
w={512}
pe={4}
pb={0}
style={{ overflow: "hidden" }}
onMouseEnter={() => setOpened(true)}
onMouseLeave={handleMouseLeave}
>
<CalendarEventList events={events} />
</Popover.Dropdown>
</Popover>
+50 -10
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
@@ -16,11 +16,15 @@ import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
export default function CalendarWidget(
props: WidgetComponentProps<"calendar">,
) {
const [month, setMonth] = useState(new Date());
if (props.integrationIds.length === 0) {
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
return (
<CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />
);
}
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
@@ -31,7 +35,13 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
const FetchCalendar = ({
month,
setMonth,
isEditMode,
integrationIds,
options,
}: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth(),
@@ -61,9 +71,20 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
},
});
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
const events = useMemo(
() => data?.flatMap((item) => item.events) ?? [],
[data],
);
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
return (
<CalendarBase
isEditMode={isEditMode}
events={events}
month={month}
setMonth={setMonth}
options={options}
/>
);
};
interface CalendarBaseProps {
@@ -74,7 +95,13 @@ interface CalendarBaseProps {
options: WidgetComponentProps<"calendar">["options"];
}
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
const CalendarBase = ({
isEditMode,
events,
month,
setMonth,
options,
}: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const { firstDayOfWeek } = useSettings();
@@ -85,6 +112,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const activeCloseRef = useRef<(() => void) | null>(null);
const onDayOpen = useCallback((close: () => void) => {
activeCloseRef.current?.();
activeCloseRef.current = close;
}, []);
return (
<Calendar
@@ -150,9 +182,14 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const eventsForDate = normalizedEvents
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
.filter(
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
(event) =>
event.metadata?.type !== "radarr" ||
options.releaseType.includes(event.metadata.releaseType),
)
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
.toSorted(
(eventA, eventB) =>
eventA.startDate.getTime() - eventB.startDate.getTime(),
);
return (
<CalendarDay
@@ -161,6 +198,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
onOpen={onDayOpen}
/>
);
}}
@@ -198,7 +236,9 @@ export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
splitEvents.push({
...event,
startDate: currentStart.toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate)
? event.endDate
: currentStart.endOf("day").toDate(),
});
currentStart = currentStart.add(1, "day").startOf("day");
@@ -57,6 +57,7 @@ export default function DnsHoleControlsWidget({
},
{
staleTime: 5 * 1000,
refetchInterval: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -26,6 +26,7 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
},
{
staleTime: 5 * 1000,
refetchInterval: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
+21 -2
View File
@@ -1,10 +1,10 @@
"use client";
import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, Group, Stack, Text, Tooltip } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
@@ -213,6 +213,15 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const columns = useMemo(() => createColumns(t), [t]);
const columnVisibility: MRT_VisibilityState = {
name: options.columns.includes("name"),
state: options.columns.includes("state"),
host: options.columns.includes("host"),
cpuUsage: options.columns.includes("cpuUsage"),
memoryUsage: options.columns.includes("memoryUsage"),
actions: options.columns.includes("actions"),
};
const table = useTranslatedMantineReactTable({
columns,
data: containers,
@@ -233,6 +242,9 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }],
density: "xs",
},
state: {
columnVisibility,
},
mantinePaperProps: {
flex: 1,
withBorder: false,
@@ -260,6 +272,13 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
},
});
if (options.columns.length === 0)
return (
<Center h="100%">
<Text>{t("error.noColumns")}</Text>
</Center>
);
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
+19
View File
@@ -12,10 +12,29 @@ const columnsList = [
"memoryUsage",
] as const satisfies (keyof RouterOutputs["docker"]["getContainers"]["containers"][number])[];
const allColumnsList = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
const columnTranslationKeyMap = {
name: "docker.field.name.label",
state: "docker.field.state.label",
host: "docker.field.host.label",
cpuUsage: "docker.field.stats.cpu.label",
memoryUsage: "docker.field.stats.memory.label",
actions: "docker.action.title",
} as const satisfies Record<(typeof allColumnsList)[number], string>;
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
createOptions() {
return optionsBuilder.from((factory) => ({
columns: factory.multiSelect({
defaultValue: [...allColumnsList],
options: allColumnsList.map((value) => ({
value,
label: (t) => t(columnTranslationKeyMap[value]),
})),
searchable: true,
}),
enableRowSorting: factory.switch({
defaultValue: false,
}),
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import "../widgets-common.css";
import "./styles.css";
import { useCallback, useMemo, useState } from "react";
import type { MantineStyleProp } from "@mantine/core";
@@ -577,7 +578,7 @@ export default function DownloadClientsWidget({
enableColumnOrdering: isEditMode,
enableRowVirtualization: true,
rowVirtualizerOptions: { overscan: 5 },
mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined },
mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined, bg: "transparent" },
mantineTableContainerProps: { style: { height: "100%" } },
mantineTableProps: {
className: "downloads-widget-table",
+12
View File
@@ -0,0 +1,12 @@
/* Make the MantineReactTable transparent so the board card's opacity-controlled
background shows through. MRT defaults to var(--mantine-color-body) which is opaque. */
.downloads-widget-table,
.downloads-widget-table .mantine-Table-table,
.downloads-widget-table .mantine-Table-thead,
.downloads-widget-table .mantine-Table-tbody,
.downloads-widget-table .mantine-Table-tfoot,
.downloads-widget-table .mantine-Table-tr,
.downloads-widget-table th,
.downloads-widget-table td {
background-color: transparent !important;
}
+595 -598
View File
File diff suppressed because it is too large. Load diff
+23 -23
View File
@@ -14,7 +14,7 @@ catalog:
"@dnd-kit/utilities": ^3.2.2
"@drizzle-team/brocli": ^0.12.0
"@extractus/feed-extractor": 7.2.1
'@gfazioli/mantine-onboarding-tour': ^4.0.5
'@gfazioli/mantine-onboarding-tour': ^4.0.6
"@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0
"@homarr/node-unifi": ^2.6.0
@@ -22,21 +22,21 @@ catalog:
"@jellyfin/sdk": ^0.13.0
"@kubernetes/client-node": ^1.4.0
"@modelcontextprotocol/sdk": 1.29.0
"@mantine/charts": ^9.3.2
"@mantine/colors-generator": ^9.3.2
"@mantine/core": ^9.3.2
"@mantine/dates": ^9.3.2
"@mantine/dropzone": ^9.3.2
"@mantine/form": ^9.3.2
"@mantine/hooks": ^9.3.2
"@mantine/modals": ^9.3.2
"@mantine/notifications": ^9.3.2
"@mantine/spotlight": ^9.3.2
"@mantine/tiptap": ^9.3.2
"@mantine/charts": ^9.4.0
"@mantine/colors-generator": ^9.4.0
"@mantine/core": ^9.4.0
"@mantine/dates": ^9.4.0
"@mantine/dropzone": ^9.4.0
"@mantine/form": ^9.4.0
"@mantine/hooks": ^9.4.0
"@mantine/modals": ^9.4.0
"@mantine/notifications": ^9.4.0
"@mantine/spotlight": ^9.4.0
"@mantine/tiptap": ^9.4.0
"@next/eslint-plugin-next": 16.2.9
"@octokit/auth-app": ^8.2.0
"@paralleldrive/cuid2": ^3.3.0
"@playwright/test": ^1.61.0
"@playwright/test": ^1.61.1
"@semantic-release/changelog": ^6.0.3
"@semantic-release/commit-analyzer": ^13.0.1
"@semantic-release/git": ^10.0.1
@@ -88,13 +88,13 @@ catalog:
"@types/prismjs": ^1.26.6
"@types/react": 19.2.17
"@types/react-dom": 19.2.3
"@scalar/api-reference-react": ^0.9.47
"@scalar/api-reference-react": ^0.9.48
"@types/video.js": ^7.3.58
"@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14
posthog-js: ^1.391.2
posthog-js: ^1.393.0
posthog-node: ^4.18.0
"@vitejs/plugin-react": ^6.0.2
"@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.9
"@vitest/ui": ^4.1.9
"@xterm/addon-canvas": ^0.7.0
@@ -150,8 +150,8 @@ catalog:
next-intl: 4.13.0
node-loader: ^2.1.0
octokit: ^5.0.5
oxfmt: ^0.55.0
oxlint: ^1.70.0
oxfmt: ^0.56.0
oxlint: ^1.71.0
pg: ^8.22.0
postcss-preset-mantine: ^1.18.0
prismjs: ^1.30.0
@@ -163,7 +163,7 @@ catalog:
react-markdown: ^10.1.0
react-quill-new: ^3.8.3
react-simple-code-editor: ^0.14.1
recharts: ^3.8.1
recharts: ^3.9.0
sass: ^1.101.0
semantic-release: ^25.0.5
semver-parser: ^4.2.1
@@ -177,7 +177,7 @@ catalog:
tsx: 4.20.4
turbo: ^2.9.18
typescript: ^6.0.3
vite: ^8.0.16
vite: ^8.1.0
undici: 7.27.2
use-deep-compare-effect: ^1.8.1
video.js: ^8.23.9
@@ -206,12 +206,12 @@ minimumReleaseAgeExclude:
overrides:
"@babel/helpers@<7.26.10": ">=7.29.7"
"@babel/runtime@<7.26.10": ">=7.29.7"
"axios@>=1.0.0 <1.8.2": ">=1.18.0"
"axios@>=1.0.0 <1.8.2": ">=1.18.1"
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6"
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6"
"esbuild@<=0.24.2": ">=0.28.0"
"form-data@>=4.0.0 <4.0.4": ">=4.0.6"
"hono@<4.6.5": ">=4.12.26"
"hono@<4.6.5": ">=4.12.27"
"linkifyjs@<4.3.2": ">=4.3.3"
"nanoid@>=4.0.0 <5.0.9": ">=5.1.15"
"prismjs@<1.30.0": ">=1.30.0"
@@ -222,7 +222,7 @@ overrides:
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2"
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2"
"tmp@<=0.2.3": ">=0.2.7"
"vite@>=5.0.0 <=5.4.18": ">=8.0.16"
"vite@>=5.0.0 <=5.4.18": ">=8.1.0"
patchedDependencies:
"@types/node-unifi": "patches/@types__node-unifi.patch"
"trpc-to-openapi": "patches/trpc-to-openapi.patch"