Compare commits

..
Author SHA1 Message Date
Thomas Camlong 9b25ab15ff fix: regenerate lockfile for spotlight notifications dependency 2026-06-04 14:29:51 +02:00
Thomas Camlong 79eb7f2302 Merge remote-tracking branch 'origin/feat/docker-logs' into insiders/ui-ux 2026-06-04 13:59:28 +02:00
Thomas Camlong 83366e86aa fix(docker): add tooltip on image name for truncated values 2026-06-04 13:56:41 +02:00
Thomas Camlong 79490399ef fix(docker): reduce table cell padding for compact layout 2026-06-04 13:56:13 +02:00
Thomas Camlong 0e24508a73 Merge remote-tracking branch 'origin/feat/docker-logs' into insiders/ui-ux
# Conflicts:
#	apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx
2026-06-04 13:55:00 +02:00
ajnart 2a92135adf style: auto-fix lint and format 2026-06-04 11:54:39 +00:00
Thomas Camlong fa665e0093 fix(docker): fix breadcrumb, xterm cleanup, and replace action column with menu
- Fix breadcrumb checking dynamicMappings before nonInteractable so container
  name shows instead of raw translation key
- Fix xterm _linkifier2 error by disposing canvas addon before terminal
- Replace single-action row column with a Menu dropdown containing all actions
  (view logs, start, stop, restart, remove, add to homarr)
- Refactor action bar to use shared containerActions lookup table
2026-06-04 13:53:51 +02:00
Thomas Camlong 69e3b7bc97 Merge remote-tracking branch 'origin/feat/docker-logs' into insiders/ui-ux
# Conflicts:
#	apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx
#	apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx
#	apps/nextjs/src/components/layout/navigation-link.tsx
#	pnpm-lock.yaml
2026-06-04 13:41:47 +02:00
Thomas Camlong b829568a7e Merge remote-tracking branch 'origin/dev' into feat/docker-logs
# Conflicts:
#	apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx
#	apps/nextjs/src/components/layout/navigation-link.tsx
2026-06-04 13:40:35 +02:00
Thomas Camlong 0167e6d564 fix(docker): fix logs page font size, sidebar active state, and breadcrumb translations
- Set fontSize: 14 on both docker and server logs terminals for consistency
- Use startsWith for nav link active state so docker sub-pages highlight Docker
- Add missing navigationStructure.manage.tools.docker.logs translation key
- Pass dynamicMappings for containerId in breadcrumb to show container name
2026-06-04 13:39:36 +02:00
Thomas Camlong 3b11671366 Merge branch 'feat/add-integration-from-dashboard' into insiders/ui-ux 2026-06-04 13:28:03 +02:00
Thomas Camlong 3cb2932474 feat: redesign select modals, add log font-size slider, improve onboarding UX
- Redesign widget/app/integration select modal cards: icon left of name,
  description tooltip on hover, "Add to board" button revealed on hover,
  reduced card height from 180 to 140
- Add client-side font-size slider (10-24px) to server logs terminal,
  persisted in localStorage
- Merge Docker discovery into integration grid with "Detected via Docker"
  badges, remove separate DockerDiscoveryIndicator panel
- Make base URL input more prominent (orange alert, auto-focused) in
  onboarding with stronger hint text
- Add disableAutoFocus prop to SelectGridLayout
2026-06-04 13:27:26 +02:00
Thomas Camlong 95fc9f2a61 fix(tour): add TourTarget wrappers to users management page 2026-06-02 22:44:26 +02:00
Thomas Camlong e9374d53e1 fix: add withBorder to old-import bookmark options mapping 2026-06-02 22:30:42 +02:00
Thomas Camlong acf9ca9acb fix: add missing user settings components from app-tour branch 2026-06-02 22:26:55 +02:00
Thomas Camlong 5ae05c9bb4 chore: regenerate pnpm-lock.yaml after merge 2026-06-02 22:25:10 +02:00
Thomas Camlong 777fdb6f9c fix: remove unused ModalFormFooter import after merge 2026-06-02 22:24:15 +02:00
Thomas Camlong c110e10e22 Merge remote-tracking branch 'origin/fix/bookmark-with-border-option' into insiders/ui-ux 2026-06-02 22:22:14 +02:00
Thomas Camlong d7848390d1 Merge origin/feat/inline-app-edit-modal into insiders/ui-ux 2026-06-02 22:22:10 +02:00
Thomas Camlong 0722abcf39 Merge origin/feat/media-request-search into insiders/ui-ux 2026-06-02 22:21:29 +02:00
Thomas Camlong afb1cfa2bf Merge remote-tracking branch 'origin/feat/cnmdk-preferences' into insiders/ui-ux 2026-06-02 22:19:59 +02:00
Thomas Camlong 6a91af1f6f Merge origin/feat/add-integration-from-dashboard into insiders/ui-ux 2026-06-02 22:19:55 +02:00
Thomas Camlong c822fc5a99 Merge origin/feat/app-tour into insiders/ui-ux 2026-06-02 22:19:23 +02:00
Thomas Camlong 1169fb3b8d Merge remote-tracking branch 'origin/fix/credentials-login-session' into insiders/ui-ux 2026-06-02 22:18:03 +02:00
Thomas Camlong c29e2ed0e3 Merge origin/feat/docker-logs into insiders/ui-ux 2026-06-02 22:17:59 +02:00
Thomas Camlong 82c4622b28 Merge origin/feat/global-refresh-indicator into insiders/ui-ux 2026-06-02 22:17:31 +02:00
Thomas Camlong 9fb2fafb9c Merge origin/feat/board-cache into insiders/ui-ux 2026-06-02 22:17:08 +02:00
ajnart 144a55b34e style: auto-fix lint and format 2026-06-02 20:08:53 +00:00
Thomas Camlong 46ccc6c245 fix: add bookmark widget option to toggle card borders
Replace hardcoded borderless cards with a withBorder switch defaulting to false so existing bookmarks stay borderless while users can opt in to borders.
2026-06-02 22:07:40 +02:00
ajnart 3a1adab3e5 style: auto-fix lint and format 2026-06-01 08:31:53 +00:00
Thomas Camlong e67d603570 fix(spotlight): remove all non-null assertions to pass oxlint
- Refactor groupDispatchers to accept userId as a parameter instead of closing over nullable userId
- Replace mutationGroup! with a guard clause
- Use optional chaining for preferenceChildrenOptionsByKey lookup with fallback
2026-06-01 10:30:41 +02:00
Thomas Camlong 0449716805 fix(spotlight): address PR review feedback
- Clear optimistic store on user/session change to prevent stale values leaking across logins
- Move dispatcher lookup before setOptimistic to avoid permanent ghost entries when no dispatcher exists
- Inline dayjs.weekdays() calls to reflect runtime locale changes instead of caching at module load
- Remove unused UserPreferenceKey import
2026-06-01 10:26:08 +02:00
copilot-swe-agent[bot] 2bfe2caffc fix: use i18n for availability badge labels and make requestedSeasons optional 2026-06-01 07:28:36 +00:00
Thomas e98487d2aa Merge branch 'dev' into feat/add-integration-from-dashboard 2026-05-30 01:10:17 +02:00
Thomas a2a65c9a54 Route integration creation to the new selector page (#5825) 2026-05-30 01:09:54 +02:00
ajnart 6bf3c75f9f style: auto-fix lint and format 2026-05-29 22:58:49 +00:00
Thomas afa36bffa9 fix: media request search bugs and availability indicators (#5824) 2026-05-30 00:58:10 +02:00
ajnart 6230db53e0 style: auto-fix lint and format 2026-05-29 21:43:47 +00:00
Thomas Camlong d4279a938d wip on fixing seerr media request 2026-05-29 23:42:39 +02:00
Thomas ef27d739bb remove green dot (#5822) 2026-05-29 23:41:40 +02:00
Thomas 9fc0e5f94c Delete .cursor/plans/widget_refresh_indicator_ffb2b58d.plan.md 2026-05-29 22:55:08 +02:00
Thomas 60ae487842 Merge branch 'dev' into feat/global-refresh-indicator 2026-05-29 22:35:05 +02:00
Thomas e0a77948e2 Merge branch 'dev' into feat/cnmdk-preferences 2026-05-29 22:01:10 +02:00
Thomas 5c0fd755cc Merge branch 'dev' into fix/credentials-login-session 2026-05-29 21:28:41 +02:00
Thomas Camlong 75a45c2b71 Merge remote-tracking branch 'origin/dev' into feat/app-tour
# Conflicts:
#	apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx
#	apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
#	apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
#	apps/nextjs/src/app/[locale]/manage/apps/page.tsx
#	apps/nextjs/src/app/[locale]/manage/boards/page.tsx
#	apps/nextjs/src/app/[locale]/manage/integrations/page.tsx
#	apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx
#	apps/nextjs/src/components/layout/navigation-link.tsx
#	packages/ui/src/theme.ts
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
2026-05-29 21:02:18 +02:00
Thomas 77f5d9bf9a Merge branch 'dev' into feat/add-integration-from-dashboard 2026-05-29 20:52:15 +02:00
ajnart 4bfde12b65 style: auto-fix lint and format 2026-05-29 17:55:03 +00:00
ajnart a2c4b29bfd style: auto-fix lint and format 2026-05-29 17:52:12 +00:00
Thomas Camlong 5899e1b44b chore(deps): add @tanstack/query-persist-client-core to workspace catalog 2026-05-29 17:26:44 +02:00
Thomas Camlong 201a461729 feat(ui): rewrite query refresh indicator with integration icons
Filter out widget.app.ping from the indicator. Show colored status dots
(green/orange/red) per query alongside integration icons with name
tooltips. Use lookup maps and resolver functions instead of ternaries.
Display relative timestamps for last data update.
2026-05-29 17:26:36 +02:00
Thomas Camlong 87798a9789 feat(nextjs): stream cached query data from Redis during SSR
Add QueryCacheHydration async server component inside <Suspense> that
reads all persisted queries from Redis via HGETALL and streams a
HydrationBoundary to the client. Builds DehydratedState directly with
original dataUpdatedAt timestamps so fresh prefetch data is never
overwritten by stale cache. Make client persister write-only (getItem
returns null) since reads are handled server-side.
2026-05-29 17:26:28 +02:00
Thomas Camlong 5501ee6a7d refactor(spotlight): registry-driven preferences with data-driven hook
Replace 5 per-preference children files with a single
preference-registry.tsx that auto-generates CMDK children via factory
functions. Rewrite useUserPreferences from ~347 lines to ~160 lines by
deriving mutation dispatch, pending state, and optimistic values from
preference definitions.

Simplify settings-children.tsx and groups.tsx to iterate over
visiblePreferenceDefinitions instead of maintaining manual mappings.

Deleted: board.tsx, color-scheme.tsx, first-day-of-week.tsx,
search-engine.tsx, registry.ts

Net reduction: ~261 lines across 9 files.
2026-05-29 17:26:27 +02:00
Thomas Camlong 682f6bd3ea fix(widgets): fix downloads widget and add SSR prefetch
Remove WidgetEmptyState guard from downloads (widget renders fine with
empty data). Fix columnsSort filter logic from .some() to .every() so
non-sortable columns are correctly excluded. Add server-side prefetch
via tRPC caller so download data is available immediately on page load.
2026-05-29 17:26:15 +02:00
Thomas Camlong f49ae79c99 refactor(widgets): stabilize widget components after useQuery migration
Add WidgetEmptyState component for widgets that need a loading fallback.
Disable refetch-on-mount/focus/reconnect and retry for integration widgets
so cached data is preserved. Ensure hooks are never called conditionally.
2026-05-29 17:26:05 +02:00
Thomas Camlong f6592b27a1 refactor(settings): add mutationGroup to preference definitions
Enrich userPreferenceDefinitions with mutationGroup field to express
which tRPC mutation handles each preference key. Move
visiblePreferenceDefinitions helper from spotlight registry to the
shared settings package.

This enables data-driven mutation dispatch in the CMDK hook and
eliminates manual key-to-group mappings.
2026-05-29 17:26:02 +02:00
Thomas Camlong f7f6d1e382 feat(boards): wire board cache scope for query persistence
Call setActiveQueryCacheBoardId when board context initializes so the
client-side persister knows which board to scope cache reads/writes to.
2026-05-29 17:25:53 +02:00
Thomas Camlong 6c623a3a2d feat(redis): migrate query cache to Hash storage for bulk SSR reads
Replace individual Redis keys (queryCache:sha256(...)) with a Hash per
user+board (qc:{userId}:{boardId}). This enables HGETALL to read all
cached entries in a single call for server-side hydration. Remove unused
SHA256 hashKey function and createHash import.
2026-05-29 17:25:46 +02:00
Thomas Camlong b65eef08ea feat(api): add Redis-backed query cache persistence layer
Add tRPC router (queryCache.getItem/setItem/removeItem) for persisting
TanStack Query cache entries to Redis, scoped per user and board.
Includes isPersistableWidgetQueryKey filter that excludes widget.app.ping,
and comprehensive tests covering access control, TTL expiry, and size limits.
2026-05-29 17:25:38 +02:00
Thomas Camlong 970b92cd95 Merge remote-tracking branch 'origin/feat/rework-perferences' into feat/cnmdk-preferences 2026-05-29 17:05:49 +02:00
Thomas Camlong e182d28605 continue working on user prefernces in command palette 2026-05-29 16:23:29 +02:00
Thomas Camlong 19d86daa87 wip: user preferences in command palette 2026-05-29 15:37:00 +02:00
Thomas Camlong 1c9a63d9ef fix: address CodeQL and code quality review comments
Replace polynomial trailing-slash regex with removeTrailingSlash helper
and remove unused ModalFormFooter import in quick-add-app modal.
2026-05-29 14:54:39 +02:00
Thomas Camlong 46f19c120d fix(widgets): refresh app data after embedded app edit
Invalidate app queries on update so board widgets reflect changes.
Sync AppForm with refetched initialValues and reset after submit.
2026-05-29 12:06:46 +02:00
ajnart b125a6159a style: auto-fix lint and format 2026-05-29 09:46:55 +00:00
Thomas Camlong ecbed76a65 refactor(forms): update form submission methods 2026-05-29 11:46:03 +02:00
Thomas Camlong fec0eb51a3 fix(widgets): remove non-null assertion in widget edit modal
Satisfies oxlint no-non-null-assertion by narrowing appId before render.
2026-05-29 11:36:33 +02:00
Thomas 67b8084bc3 Merge branch 'dev' into feat/inline-app-edit-modal 2026-05-29 11:28:53 +02:00
Thomas 9bf6d9851c Merge branch 'dev' into fix/credentials-login-session 2026-05-29 11:17:53 +02:00
Thomas Camlong f1ed293b79 fix: address PR review feedback on password suggestions
Remove redundant tests, rename requirement translations to suggestion for clearer i18n, simplify password input state and requirement display.
2026-05-29 11:16:07 +02:00
Meier Lukas 1bdfc44ba1 Merge branch 'dev' into feat/rework-perferences 2026-05-29 11:08:03 +02:00
Meier Lukas 0ab0be0aa4 Merge branch 'dev' into feat/rework-perferences 2026-05-29 10:28:31 +02:00
ajnart 598307c7bf style: auto-fix lint and format 2026-05-28 16:08:04 +00:00
Thomas Camlong 94cbbde576 fix(widgets): prevent NoIntegrationDataError during initial load and scope indicator
- Add isFetched guard in media-requests/list to avoid throwing before data loads
- Scope refresh indicator to only track widget and integration queries
2026-05-28 18:07:03 +02:00
Thomas Camlong 1b003bfc50 fix(widgets): resolve hooks violations, stale state, and missing edit-mode guard
- Move hooks above early returns in system-disks, immich-carousel, media-transcoding
- Add enabled flag to immich-carousel query when albumId is absent
- Replace disconnected local state in ping-indicator with query cache setData
- Stabilize docker timestamp with useMemo to prevent useTimeAgo flicker
- Wrap releases results in useMemo with proper type narrowing
- Hide refresh indicator during edit mode
2026-05-28 18:07:03 +02:00
Thomas Camlong 394debbea1 refactor(widgets): migrate all widgets from useSuspenseQuery to useQuery
Non-blocking data fetching for all 38 widget components. Widgets now
render immediately and show data when available, with loading state
delegated to the global refresh indicator in the board header.
2026-05-28 18:07:03 +02:00
Thomas Camlong cb5ae65f8a refactor(ui): simplify indicator to status dot on board logo
Use Mantine Indicator with processing animation instead of custom CSS.
Switch from HoverCard to Menu with grouped sections. Remove beep,
position dot next to board logo, hide in edit mode.
2026-05-28 18:07:03 +02:00
ajnart fc80a61aef style: auto-fix lint and format 2026-05-28 14:07:20 +00:00
Thomas Camlong a2668740c0 feat(ui): add global query refresh indicator to board header
Replace per-widget loading spinners with a shared header indicator that
shows live TanStack Query cache status for all widget-related queries.
Includes orange/green dot, completion beep, and hover card with
per-query staleness and error details.
2026-05-28 16:05:49 +02:00
ajnart d44cf13c5b style: auto-fix lint and format 2026-05-28 12:56:44 +00:00
Thomas 1b4532651b Merge branch 'dev' into feat/inline-app-edit-modal 2026-05-28 14:55:54 +02:00
Thomas Camlong 3d1bb2f0ba feat(widgets): wire up tabbed app editing in widget edit modal
- Add hideButtons/formRef props to AppForm for headless embedding
- Add Mantine Tabs to WidgetEditModal for app widgets (permission-gated)
- Pass appId from BoardItemMenu to the modal
- Add translation keys for tab labels and propagation notice
2026-05-28 14:55:15 +02:00
Thomas Camlong d83e9b2611 feat(widgets): add embedded app edit form for in-place app editing
Add EmbeddedAppEditForm component that allows editing app properties
(name, icon, URL, description) directly from the widget edit modal
without navigating to the management pages.
2026-05-28 14:51:23 +02:00
ajnart 28f31243fb style: auto-fix lint and format 2026-05-28 12:06:00 +00:00
Thomas Camlong 7c0653672a feat(init): apply base URL template to discovered apps
Discovered Docker apps now use the same buildAppUrl logic as
integrations when a base host is set, instead of using the raw
Docker socket URL (e.g. http://container:5432).

Extracts shared buildUrl helper and adds buildAppUrl that derives
a slug from the container name. Also passes publishedPort through
the DiscoveredApp type so host:port mode works correctly.
2026-05-28 12:21:03 +02:00
Thomas Camlong bbb04b084f fix(definitions): use HTTPS for subdomain URL template
Subdomain-based URLs (e.g. sonarr.example.com) are typically served
behind a reverse proxy with TLS. Update the template to generate
https:// URLs. Host:port mode remains http:// for local access.
2026-05-28 12:16:14 +02:00
Thomas Camlong efc48e5097 feat(definitions): add apiKeySettingsPath for qBittorrent
Points to /#/settings/webui so the integration form can link directly
to the qBittorrent API key settings page.
2026-05-28 12:16:04 +02:00
Thomas Camlong 3ff3d41d7c fix(init): improve integrations step UX
- Replace plain Base URL input with a prominent Alert callout so users
  don't overlook it
- Add disableScroll prop to SelectGridLayout and use it in onboarding
  so integrations expand the page instead of scrolling
- Fix Collapse `in` → `expanded` prop for Mantine v9 migration in both
  the URL helpers panel and the Docker discovery panel
2026-05-28 12:15:56 +02:00
Thomas Camlong 1065e30c8d feat(init): unify onboarding step widths with shared layout
Replace per-step hardcoded Card widths (384px, 776px, 1024px) with a
consistent 1024px shell defined in _constants.ts. All steps now use an
InitStepCard wrapper set to w=100%, inheriting width from the page shell.

Also updates old-import cards to w=100% for proper width propagation.
2026-05-28 12:15:44 +02:00
Thomas Camlong 0e2441167d Merge branch 'dev' into feat/add-integration-from-dashboard
Integrate dev's centralized Mantine theme in @homarr/ui and reconcile modal grid work.
2026-05-28 11:20:03 +02:00
Thomas Camlong cf998b474b Merge branch 'dev' into feat/rework-perferences
Resolve conflicts by keeping the consolidated general settings form,
porting the firstDayOfWeek Radio.Group fix from dev, and retaining
mobile avatar layout improvements from this branch.
2026-05-28 11:11:27 +02:00
Thomas Camlong 737e2f8c42 chore(users): remove unused general settings form components
These were superseded by UserGeneralSettingsForm in the preferences rework.
2026-05-28 11:09:52 +02:00
Thomas Camlong 2268aa3d99 fix(ui): enforce minimum width on all grid-based select modals
Apply miw="min(1400px, 90vw)" to app select and integration select
modals so they never shrink when filtering reduces visible items.
2026-05-28 10:54:32 +02:00
Thomas Camlong 10c2e42172 fix(ui): enforce minimum width on item select modal to prevent resizing 2026-05-28 10:51:47 +02:00
Thomas Camlong 095d40b065 fix(ui): prevent item select grid from collapsing with few results 2026-05-28 10:42:45 +02:00
Thomas Camlong d0f63b9563 fix(seed): use 10 columns for default dashboard layout 2026-05-28 10:39:24 +02:00
ajnart 8cb3c9d12d style: auto-fix lint and format 2026-05-27 15:32:21 +00:00
Thomas 710187fee1 Merge branch 'dev' into feat/add-integration-from-dashboard 2026-05-27 17:31:10 +02:00
Thomas Camlong 0efbe8f6a3 style: format onboard-router with oxfmt 2026-05-27 17:28:55 +02:00
Thomas Camlong 4d801aa231 feat(onboarding): add Docker scanning loading animation and fix hydration
- Show animated "Scanning Docker..." indicator with dots loader while
  the discovery query is pending
- Add isLoading prop to DockerDiscoveryIndicator
- Fix button hydration mismatch with suppressHydrationWarning
- Add scanning/scanningDescription translation keys
2026-05-27 16:42:24 +02:00
Thomas Camlong a89479ed6f fix(onboarding): strict Docker discovery — only running containers with matched icons
- Change listContainers to all:false so only running containers are discovered
- Replace loose bestMatch with strictIconMatch (exact or startsWith) to avoid
  false positives from short image names matching unrelated icons
- Gate discovered apps behind DB icon match; unmatched containers are hidden
- Add CDN icon URL fallback for matched integrations display
2026-05-27 16:42:14 +02:00
Thomas Camlong 460875345b fix(onboarding): fallback to CDN icon URL when icons table is empty
During fresh onboarding the icons DB table hasn't been populated by the
cron job yet. Fall back to a dashboard-icons CDN URL guess based on
the container image name so apps still get icons for popular services.
2026-05-27 16:28:04 +02:00
Thomas Camlong d571396fba fix(onboarding): discover Docker apps even without icon match
Containers that don't match a known integration were only added as apps
if they had a matching icon in the DB. Remove the iconUrl requirement
so all non-integration containers appear as selectable apps.
2026-05-27 16:25:45 +02:00
Thomas Camlong 51004f0857 revert changes 2026-05-27 16:23:44 +02:00
Thomas Camlong 1b19248262 fix(ui): revert Modal.extend, not available at runtime 2026-05-27 16:23:06 +02:00
Thomas Camlong 6461d84ec8 fix(ui): use Modal.extend for proper Mantine v7 typing 2026-05-27 16:22:16 +02:00
Thomas Camlong 18db344ed1 fix(security): replace regex with loop to prevent ReDoS
CodeQL flagged `/\/+$/` as polynomial-time on repeated slashes.
Use `while (endsWith("/"))` loop instead.
2026-05-27 16:20:19 +02:00
Thomas Camlong 1df84cbccd feat(onboarding): hide 0-widget integrations and add selectable app grid
- Filter out integrations with no widgets from onboarding selection
- Replace single checkbox app toggle with a compact selectable grid
- Each Docker-discovered app is individually toggleable (all on by default)
- Track selected apps by container ID instead of a boolean flag
2026-05-27 15:55:40 +02:00
Thomas Camlong a6566f74c9 fix: address PR review feedback
- Show error notification on setup failure instead of silent reset
- Fix toggleKind stale closure by using prop array directly
- Remove duplicate translations from en-gb.json (falls back to en.json)
- Align seed board columnCount to 12 (codebase default)
- Extract shared placeAllWidgetsAsync helper to @homarr/db/queries
- Remove redundant onboardingFormSchema; reuse formSchema for both paths
- Fix lint errors (optional chain, unused imports, unnecessary conditions)
2026-05-27 15:53:14 +02:00
Thomas Camlong c97513a61c chore: trigger deployment 2026-05-27 15:20:33 +02:00
Thomas Camlong 309ba0590d feat(onboarding): enhance integration setup with Docker discovery, URL templating, and API key helpers
Add automatic Docker container discovery during onboarding to pre-select
and pre-fill integrations. Introduce base URL templating with subdomain/port
modes, API key settings path links, and a curated onboarding list that
hides niche integrations (GitHub, npm, Docker Hub, etc.). Prevent duplicate
widget seeding and remove the timetable widget from defaults.
2026-05-27 14:36:04 +02:00
Thomas Camlong 1fcc149c91 feat(seed): add demo user seeding with pre-placed widgets on dashboard board
- seedBoardWidgetsAsync always places widgets (clock, weather, bookmarks,
  integration-linked, app widgets) on the default board
- seedDemoUserAsync creates demo user + admin group, skips onboarding,
  reuses the existing dashboard board instead of creating a duplicate
- Fix broken open-collective icon URL in defaultBookmarkApps
2026-05-25 12:42:55 +02:00
Thomas Camlong 9377e4b2f8 Merge remote-tracking branch 'origin/dev' into feat/add-integration-from-dashboard
# Conflicts:
#	packages/db/migrations/seed.ts
2026-05-25 11:36:33 +02:00
Thomas Camlong 0df64f05e5 fix: remove auth session and env schema changes, keep only password requirement relaxation 2026-05-25 11:30:32 +02:00
Thomas Camlong 884fde245b fix(boards): replace global cache with React cache() per-request dedup
The unstable_cache approach bypassed throwIfActionForbiddenAsync and
dropped user-specific data (permissions, collapse states). Use React
cache() to deduplicate getInitialBoard within a single request instead,
preserving auth checks and all user data while reducing DB calls from
9 to 3 per board page load.
2026-05-25 11:01:07 +02:00
Thomas Camlong d2ce64252c fix: revert unrelated changes (Dockerfile, Redis null-safety) 2026-05-25 10:09:55 +02:00
Thomas Camlong 63d50ed5fb fix(ci): resolve format, lint and typecheck failures 2026-05-21 16:50:28 +02:00
Thomas Camlong 27325326da fix(auth): resolve credentials login SQLITE_CONSTRAINT_NOTNULL and relax password rules
Session creation failed with NOT NULL constraint on expires column due to
invalid date propagation from duration parsing. Override adapter.createSession
with validated insert, harden signIn callback, fix createDurationSchema for
numeric-only values, and guard events.ts group insert.

Also relaxes password complexity to suggestions-only (min 8 chars enforced),
extracts shared UserCreatePasswordFields component, and fixes login form
passing extra fields to signIn.
2026-05-21 16:30:07 +02:00
Thomas Camlong 9e55f55c58 Merge branch 'dev' into feat/docker-logs
Resolve docker.ts conflict by keeping dev improvements (host field,
bestMatch icons, Promise.allSettled with logging, Podman-safe stats)
alongside container log streaming and decode utilities.
2026-05-21 14:27:28 +02:00
Thomas Camlong 379dc8f407 feat(docker): replace logs modal with dedicated xterm page
Address PR review feedback by moving container logs to a full-page viewer
using xterm (matching /manage/tools/logs), with per-row actions for better
UX when browsing long container lists. Harden log streaming with multiplex
decode fallbacks, stream cleanup on parse errors, and decode unit tests.
2026-05-21 14:18:21 +02:00
Thomas 62a0bae989 Merge branch 'dev' into feat/app-tour 2026-05-20 14:06:31 +02:00
Thomas Camlong 1220fbf6ad fix(onboarding): resolve tour shell and manage tour type errors
Use OnboardingTourController and guard optional step index.
Type users step focus reveal props for FloatingPosition compatibility.
2026-05-20 09:55:41 +02:00
Thomas Camlong 3da104256f feat(i18n): add onboarding tour copy for manage and board flows
Add per-page management tour strings and update board tour descriptions.
2026-05-20 09:43:00 +02:00
Thomas Camlong 60bf867203 feat(onboarding): add board tour on dashboard pages
Scope the board tour to content pages, target header actions, remove the
loading overlay, and persist completion with optimistic cache updates.
2026-05-20 09:43:00 +02:00
Thomas Camlong 1279168538 feat(onboarding): add management tour with multi-page navigation
Guide admins through boards, apps, integrations, and users with per-page
steps, TourTarget highlights, optimistic completion, and reset in settings.
2026-05-20 09:42:54 +02:00
Thomas Camlong 97e9c3258e feat(onboarding): add shared tour shell with keyboard navigation
Introduce TourShell with step counter, Enter key support, route-aware
advancement, and TourTarget wrappers. Remove unused fork and hold-skip steps.
2026-05-20 09:42:42 +02:00
Thomas Camlong 8e6f31e92a chore(ui): import mantine onboarding tour styles
Register onboarding tour stylesheet and align lockfile with Mantine v9.
2026-05-20 09:42:42 +02:00
Thomas Camlong acd248cca2 feat(api): add onboarding tour status tRPC endpoints
Expose getTourStatus, completeTour, and resetTours for persisting
per-user management and board tour completion.
2026-05-20 09:42:42 +02:00
Thomas Camlong 2190436d9f feat(db): add user onboarding tour completion columns
Persist completedManageTour and completedBoardTour on the user table
with renamed migrations across sqlite, mysql, and postgresql.
2026-05-20 09:42:36 +02:00
Thomas Camlong 48d157b4f4 Merge branch 'renovate/major-mantine-monorepo' into feat/app-tour 2026-05-19 16:21:46 +02:00
Thomas Camlong f773b4a15e wip 2026-05-19 16:21:32 +02:00
Thomas Camlong cb5b73ddbd chore: migrate to mantine v9
Followed the migration guide
2026-05-19 14:58:51 +02:00
homarr-renovate[bot] df56eeab95 chore(deps): update mantine monorepo to v9 2026-05-18 20:31:33 +00:00
Thomas Camlong c33b09c5d4 fix(users): fix mobile layout and add missing ddgBangsEnabled field to general settings form
- Make avatar responsive on mobile (120px) vs desktop (200px) and center on mobile
- Add missing ddgBangsEnabled field to form schema, initial values, mutation, and UI
- Fix async function naming to satisfy lint rule
2026-05-18 17:25:35 +02:00
Thomas bc0627ca9c Merge branch 'dev' into feat/rework-perferences 2026-05-18 17:18:28 +02:00
Thomas Camlong e714c96172 chore: fix formatting 2026-05-18 15:49:10 +02:00
Thomas Camlong 5bc88eb4ee fix: MissingSecretError type mismatch in onboard createIntegration mutation 2026-05-18 15:19:11 +02:00
Thomas 7111099acf Merge branch 'dev' into feat/add-integration-from-dashboard 2026-05-18 15:04:21 +02:00
Thomas Camlong 689da2fb91 fix: format files and fix revalidateTag signature for Next 16
- Run prettier on all modified files
- Add required cache profile arg to revalidateTag
- Suppress require-await for server action (must be async)
2026-05-18 15:01:01 +02:00
Thomas Camlong 22af9e2c3e feat(boards): cache named board data with tag-based invalidation
Named boards are now served from Next.js unstable_cache (1h TTL,
tag `board-{name}`). A new `getSharedBoardWithWhereAsync` helper
fetches the board without per-user data so the result is safe to
share across requests.

Cache is busted via `invalidateBoardCacheAsync` (revalidateTag +
revalidatePath) from board save, layout save, and partial settings
mutations.
2026-05-18 14:51:44 +02:00
Thomas Camlong 2c388bd449 perf(boards): parallelize board page data fetching
Board fetch + auth() now run concurrently via Promise.all instead of
sequentially. Widget prefetches for all kinds also run in parallel
alongside integration permission loading.
2026-05-18 14:49:07 +02:00
Thomas Camlong 545a10ab20 fix: remove timetable from defaultWidgetConfigs 2026-05-16 11:39:43 +02:00
Thomas dd3cb3956d Merge branch 'dev' into feat/add-integration-from-dashboard 2026-05-16 11:38:11 +02:00
Thomas Camlong 6364e4181b fix(onboarding): add error recovery for setupIntegrations failure
If setupIntegrations rejects, fall back to the select phase instead
of leaving the user stuck on an infinite loading screen. Also fix
OpenCollective bookmark icon to use dashboard-icons CDN SVG for
consistency with other bookmark icons.
2026-05-14 18:20:19 +02:00
Thomas Camlong 8de443768e fix(ui): use default variant for skip button in integration form
Subtle variant was too easy to miss. Switch to default variant for
better visibility alongside the other action buttons.
2026-05-14 18:18:16 +02:00
Thomas Camlong c8ff718778 fix(definitions): skip timetable widget from default onboarding dashboard
The SBB timetable widget is too niche to include by default during
onboarding. Add it back to defaultWidgetConfigs with skip: true so
it won't be placed automatically.
2026-05-14 18:16:29 +02:00
Thomas Camlong 16091ffd02 fix(onboarding): use consistent card width across all integration phases
The select, configure, and done phases each had different hardcoded
widths causing a jarring layout shift when transitioning between them.
Unify all three to a single CARD_WIDTH constant.
2026-05-14 18:08:59 +02:00
Thomas Camlong c3523adfd5 feat(onboarding): add skip button to integration configuration step
Allow skipping individual integrations during onboarding without
filling in the form. Adds an optional onSkip prop to NewIntegrationForm
that advances to the next integration or finishes setup.
2026-05-14 18:08:23 +02:00
Thomas Camlong c7300aee76 fix(onboarding): preserve selected integrations when navigating back
The multi-select grid initializes its own internal Set on mount, so
returning from the configure phase to the select phase lost all
selections. Accept an initialSelection prop and pass the parent's
selectedKinds to restore state on re-mount.
2026-05-14 18:06:41 +02:00
Thomas Camlong 904bef50d8 fix(ui): use inline vars resolver instead of Modal.extend
Modal.extend() is not available at module evaluation time in the
Next.js/Turbopack ESM context, causing a runtime TypeError. Define
the vars resolver as a plain object in createTheme's components key.
2026-05-14 17:00:26 +02:00
Thomas Camlong bef275804c fix(api): refactor onboard-router widget placement and step advancement
- Extract placeWidgetAsync into a standalone function with a
  PlaceWidgetContext parameter for clearer state management.
- Fix widget skip logic: use config?.skip instead of !config || config.skip
  so integration-specific widgets without an explicit defaultWidgetConfigs
  entry are still placed with default 2x2 sizes.
- Add createIntegration mutation for onboarding (no app linking or
  search engine creation needed during init).
- Fix nextOnboardingStepAsync call: pass undefined instead of "start"
  to advance naturally without resetting to the beginning.
- Add early return when no board is found in setupIntegrations.
2026-05-14 17:00:20 +02:00
Thomas Camlong 97e39ced44 refactor(integrations): make onSuccess required and simplify new page
- Make onSuccess a required prop on NewIntegrationForm so the consumer
  always controls post-creation behavior. Remove internal router.push.
- Add IntegrationNewFormWrapper client component to handle navigation
  for the server-rendered manage page.
- Replace IntegrationPickerPage with a simple "back to overview" button
  when an invalid integration kind is provided. Delete unused
  integration-picker-page.tsx.
2026-05-14 16:54:31 +02:00
Thomas Camlong 947504a16e fix(onboarding): replace stepper with progress bar and fix finish flow
- Replace Mantine Stepper with a Progress bar + current integration
  avatar/name. The stepper broke layout with 10+ integrations on mobile.
- Remove useEffect for setupIntegrations — call it directly when
  advancing past the last integration or clicking skip, avoiding
  double-call risks (cf. Cloudflare outage pattern).
- Call router.refresh() after setupIntegrations so the server-rendered
  InitFinish component renders properly with login links.
- Remove DB query from init-finish.tsx — redirect to root path instead
  of looking up an arbitrary board name.
2026-05-14 16:54:22 +02:00
Thomas Camlong 34500366b4 fix(ui): add custom xxl modal size to prevent overflow on small screens
Hardcoded size: 1200 could overflow on narrow viewports. Add a custom
"xxl" size (75rem) to the Mantine Modal theme via a vars resolver.
Mantine caps modal width at 100vw so it naturally adapts on mobile.
Use "xxl" in item-select, app-select, and integration-select modals.
2026-05-14 16:54:12 +02:00
Thomas Camlong bfa2295a7c refactor(ui): extract shared integration grid logic and deduplicate
Both IntegrationMultiSelectGrid and IntegrationSelectGrid had
duplicated logic for building widget-by-integration maps, category
labels, sorting, and filtering. Extract shared code into
integration-grid-shared.ts using objectEntries and IntegrationKind
typing. Use i18n keys for category badges and widget counts instead
of hardcoded strings. Remove useEffect for onSelectionChange in
multi-select grid — call it directly in the toggle callback.
2026-05-14 16:54:02 +02:00
Thomas Camlong 9c0e73922d fix(i18n): add translation keys for integration grid categories and widget counts
Category labels and widget count badges in the integration grid were
hardcoded English strings. Add proper i18n keys under integration.grid
and integration.category so they can be translated.
2026-05-14 16:53:53 +02:00
Thomas Camlong 8fc758721c fix(definitions): correct featured integrations and default configs
- Remove unused timetable skip entry from defaultWidgetConfigs
- Use "seerr" instead of "jellyseerr" in featuredIntegrations
- Add "Support Homarr" OpenCollective link to defaultBookmarkApps
2026-05-14 16:53:47 +02:00
Thomas 6520dedf1c Merge branch 'dev' into feat/add-integration-from-dashboard 2026-05-07 19:10:03 +02:00
Thomas 5a0ba98298 Merge branch 'dev' into feat/add-integration-from-dashboard 2026-05-04 12:40:58 +02:00
Meier Lukas c4ab2c35ab fix: format issue 2026-05-01 16:38:53 +02:00
Thomas Camlong b8b2097551 fix(onboarding): fix nextOnboardingStepAsync 2026-05-01 10:29:32 +02:00
Thomas Camlong 62d571621f chore: format
fix(lint): remove empty fragment and redundant undefined argument

chore: format
2026-05-01 10:23:48 +02:00
Thomas Camlong e0c2978f52 fix: resolve CI failures from integrations onboarding step
- Fix TypeScript error: InitIntegrations returning null by transitioning
  to done phase instead
- Fix TypeScript error: Object.fromEntries cast in integration grids by
  typing the array elements
- Update E2E onboarding tests to process the new integrations step
  (skip for now) and increase finish assertion timeout for async mutation
2026-05-01 10:12:14 +02:00
Thomas d44e7e9b52 Merge branch 'dev' into feat/rework-perferences 2026-05-01 10:00:32 +02:00
Thomas Camlong 1c6782a102 feat(integrations): add grid picker page and dashboard integration action
Refactor integration creation page to show a grid picker before the
form. Add "Add integration" action to dashboard header menu. Convert
app select modal to a two-step stepper. Standardize modal widths to
1200 for item, integration, and app select modals.
2026-05-01 09:59:01 +02:00
Thomas Camlong 7985ff953a feat(onboarding): add integration configuration stepper
Add 3-phase integration setup to onboarding: multi-select grid,
per-integration configuration with NewIntegrationForm stepper, and
finishing phase that populates the dashboard and shows final actions.
Merge finish step actions into the integrations step. Add translation
keys for configure phase.
2026-05-01 09:58:54 +02:00
Thomas Camlong e3fb8572cd feat(ui): add reusable integration select grid and modal components
Create IntegrationSelectGrid (single-select) and
IntegrationMultiSelectGrid (multi-select) components with search,
category badges, widget count tooltips, and featured integration
sorting. Use SimpleGrid to prevent horizontal overflow. Add stepper
modal for creating integrations from the dashboard.
2026-05-01 09:58:47 +02:00
Thomas Camlong 9e3b4955b9 feat(api): add integrations onboarding step with dashboard population
Add "integrations" step to onboarding flow between settings and finish.
Add setupIntegrations mutation that reads existing integrations from DB,
populates the dashboard with matching widgets (respecting config-driven
sizes/options/skip), places app widgets for each app, and creates a
bookmarks widget with seeded default apps.
2026-05-01 09:58:41 +02:00
Thomas Camlong b6615fbce2 feat(db): seed default board, home board, and bookmark apps
Create a default "dashboard" board during seeding with a section and
layout. Set it as the home board for the everyone group. Seed default
bookmark apps (Homarr Docs, GitHub, Help Translate) for the bookmarks
widget.
2026-05-01 09:58:34 +02:00
Thomas Camlong bba80add60 feat(definitions): add widget-integration map and default configs
Add static mapping of widget kinds to integration kinds for server-side
use without importing @homarr/widgets. Includes default widget configs
(size, options, skip), featured integrations list for grid sorting,
and default bookmark apps for onboarding dashboard population.
2026-05-01 09:58:28 +02:00
Thomas 1340880fef Merge branch 'dev' into feat/rework-perferences 2026-03-15 16:08:05 +01:00
Thomas Camlong e79efe06bb refactor(manage): update user settings 2026-01-28 12:19:31 +01:00
Thomas Camlong c16974b8db chore: address pr comments 2026-01-28 11:43:38 +01:00
Thomas Camlong b0c5de82f4 chore: address pr comments 2026-01-28 11:43:28 +01:00
Thomas Camlong d99b20d3ef feat(manage): consolidate user general settings form 2026-01-25 16:00:21 +01:00
Thomas Camlong d8eaef859a chore(gitignore): ignore Cursor project files 2026-01-25 16:00:10 +01:00
ajnart fb3da7e997 Merge branch 'dev' into feat/docker-logs 2026-01-15 21:19:39 +01:00
Thomas Camlong 6179c47606 Merge pull request #4808 from homarr-labs/copilot/update-docker-table-code 2026-01-06 21:18:21 +01:00
copilot-swe-agent[bot]andajnart c351c7e09b Add payload size validation to prevent memory exhaustion from corrupted Docker streams
Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com>
2026-01-06 20:04:57 +00:00
copilot-swe-agent[bot]andajnart 58a2c508c6 Fix race condition in subscription cleanup and add log size limit for memory management
Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com>
2026-01-06 20:03:27 +00:00
copilot-swe-agent[bot]andajnart 8b7d849cd0 Address code review nitpicks: improve comments and use named constant for header size
Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com>
2026-01-06 20:01:39 +00:00
copilot-swe-agent[bot]andajnart 4497f6ca6a Refactor docker logs modal: extract helper function and prevent duplicate initial logs
Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com>
2026-01-06 20:00:08 +00:00
copilot-swe-agent[bot]andajnart 9e9e259fd1 Add return type annotation to safeValue function for consistency
Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com>
2026-01-06 19:58:41 +00:00
copilot-swe-agent[bot]andajnart 23ce0d14ae Implement log streaming with subscription-based approach instead of polling
Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com>
2026-01-06 19:57:33 +00:00
copilot-swe-agent[bot]andajnart 762cdab78f Address review comments 1-4: Fix optional checks, remove redundant code, simplify column visibility, and share utility functions
Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com>
2026-01-06 19:54:03 +00:00
copilot-swe-agent[bot] 9bf28c00cd Initial plan 2026-01-06 19:48:18 +00:00
Thomas Camlong 365918c62a Merge branch 'dev' into feat/docker-logs 2026-01-06 19:02:44 +01:00
Thomas Camlong 31323ed674 feat(i18n): add docker logs labels 2026-01-06 18:56:20 +01:00
Thomas Camlong 3dbaaf70fc feat(docker): add logs action and stats columns to management table 2026-01-06 18:56:10 +01:00
Thomas Camlong ac327caf5f feat(modals): export docker logs modal 2026-01-06 18:56:01 +01:00
Thomas Camlong ecaaba429d feat(modals): add docker logs modal 2026-01-06 18:55:51 +01:00
Thomas Camlong 749d00daee feat(docker): decode and fetch container logs 2026-01-06 18:55:42 +01:00
Thomas Camlong 2890f04661 feat(docker): add container logs query 2026-01-06 18:55:34 +01:00
162 changed files with 2923 additions and 4507 deletions

No files matched your search

-1
View File
@@ -33,7 +33,6 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.64.0
- 1.62.0
- 1.61.0
- 1.60.1-beta.1
+3
View File
@@ -0,0 +1,3 @@
[tools]
node = "24.16.0"
pnpm = "10.33.4"
-64
View File
@@ -1,67 +1,3 @@
## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05)
### Features
* add comprehensive demo mode with mock integrations ([#5870](https://github.com/homarr-labs/homarr/issues/5870)) ([0a64679](https://github.com/homarr-labs/homarr/commit/0a6467979a5e3b3e3eae6252c31fca1d9af54dc1))
* add PeaNUT integration and UPS monitoring widget ([#5862](https://github.com/homarr-labs/homarr/issues/5862)) ([0dc2558](https://github.com/homarr-labs/homarr/commit/0dc2558fafedcbba246c2e968d00cc541ab9f84e))
* **docker:** add container logs modal and stats columns ([#4807](https://github.com/homarr-labs/homarr/issues/4807)) ([dbf021e](https://github.com/homarr-labs/homarr/commit/dbf021e04a785b44ad0e2ce45634de371d76de6d))
* media request search as first-class spotlight mode ([#5823](https://github.com/homarr-labs/homarr/issues/5823)) ([3d3e305](https://github.com/homarr-labs/homarr/commit/3d3e3059a98b36b6e64e3aad90f715e334baff3e))
* **onboarding:** enhance integration setup with Docker discovery and URL templating ([#5607](https://github.com/homarr-labs/homarr/issues/5607)) ([dd7a490](https://github.com/homarr-labs/homarr/commit/dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d))
* **onboarding:** guided management and board tours ([#5756](https://github.com/homarr-labs/homarr/issues/5756)) ([0b663a7](https://github.com/homarr-labs/homarr/commit/0b663a7fc6c017b9fbe78c019f06d46ee95f165f))
* **widgets:** add in-place app editing in widget edit modal ([#5809](https://github.com/homarr-labs/homarr/issues/5809)) ([99346f2](https://github.com/homarr-labs/homarr/commit/99346f288bcfc701c41fecaab6f7bc0438d80563))
### Bug Fixes
* add bookmark widget option to toggle card borders ([#5854](https://github.com/homarr-labs/homarr/issues/5854)) ([41fe592](https://github.com/homarr-labs/homarr/commit/41fe5928f19207d02b04e64c2caac49d896f72ce))
* **auth:** relax password complexity requirements to suggestions-only ([#5764](https://github.com/homarr-labs/homarr/issues/5764)) ([fd10788](https://github.com/homarr-labs/homarr/commit/fd107888385557ededcf51576c6320a1960690f2))
* **db:** log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding ([efd37dc](https://github.com/homarr-labs/homarr/commit/efd37dca93be7f5f362331e9b5c2a95a87e24379))
* disable onboarding tours when demo mode is enabled ([68f24e3](https://github.com/homarr-labs/homarr/commit/68f24e34d580e3a155c37ca37c3ffd61f296bd0c))
* **docker:** restore container selection toolbar broken by Mantine v9 ([#5855](https://github.com/homarr-labs/homarr/issues/5855)) ([fe4598f](https://github.com/homarr-labs/homarr/commit/fe4598f7221a2861b8c5ae0cfd267e8c4fbf48df))
* **immich:** respect app color scheme in server-stats widget ([#5859](https://github.com/homarr-labs/homarr/issues/5859)) ([2c6b7a8](https://github.com/homarr-labs/homarr/commit/2c6b7a818e9d32b414b024d711a3b64533e61cb9))
* redirect to login after credentials logout instead of crashing on current page ([#5819](https://github.com/homarr-labs/homarr/issues/5819)) ([cc24f07](https://github.com/homarr-labs/homarr/commit/cc24f070db26337a4a2d87d68c7f142ed1118584))
* sync docs sitemap and remove stale coolify ts-expect-error ([4f5f3e1](https://github.com/homarr-labs/homarr/commit/4f5f3e14a129a94295d4b8ffb7d3702fdb4f6a74))
* **ui:** restore pre-v9 light variant colors ([#5856](https://github.com/homarr-labs/homarr/issues/5856)) ([a755692](https://github.com/homarr-labs/homarr/commit/a755692d81b80b5ff83051a3b7261c01ac10321b))
## [1.63.0](https://github.com/homarr-labs/homarr/compare/v1.62.0...v1.63.0) (2026-05-29)
### Features
* **auth:** support custom token endpoint auth methods ([#5806](https://github.com/homarr-labs/homarr/issues/5806)) ([be61602](https://github.com/homarr-labs/homarr/commit/be61602576a8bec4bb7e4928d36e0854e926264d))
* auto-open widget settings on add with all integrations pre-selected ([#5767](https://github.com/homarr-labs/homarr/issues/5767)) ([f10bb3a](https://github.com/homarr-labs/homarr/commit/f10bb3ae23fefc9779332733706aa567cc40ba48))
* **board:** persist category collapse state in localStorage for guests ([#5714](https://github.com/homarr-labs/homarr/issues/5714)) ([66212b4](https://github.com/homarr-labs/homarr/commit/66212b4e475738e61d2ea78f3a7e72d7a610bf12)), closes [#3355](https://github.com/homarr-labs/homarr/issues/3355)
* **manage:** unified management page layout ([#5758](https://github.com/homarr-labs/homarr/issues/5758)) ([c252b4f](https://github.com/homarr-labs/homarr/commit/c252b4f9accc4523f222354c73f687714640701f))
* **users:** rework general settings ([#4915](https://github.com/homarr-labs/homarr/issues/4915)) ([2991777](https://github.com/homarr-labs/homarr/commit/2991777b0a0ae72522d0769a86a91250d618dbe6))
### Bug Fixes
* matine ui fixes for concistency ([#5733](https://github.com/homarr-labs/homarr/issues/5733)) ([4454e3f](https://github.com/homarr-labs/homarr/commit/4454e3fb7afa624ee2f1eaec015f2725e664ac46))
* parse Speedtest Tracker created_at as local time ([#5751](https://github.com/homarr-labs/homarr/issues/5751)) ([b19549b](https://github.com/homarr-labs/homarr/commit/b19549b601636bcbc2fadaa270a77a9fdb48b77a))
* prevent GitHub releases check from blocking app on API failure ([#5807](https://github.com/homarr-labs/homarr/issues/5807)) ([6cc2ac5](https://github.com/homarr-labs/homarr/commit/6cc2ac56a920563c47aaaab1b33b19a9b6d5ecee))
* **ui:** use imported theme instead of undefined createTheme ([4a67bf0](https://github.com/homarr-labs/homarr/commit/4a67bf0ef30ff8d10b06d2d3719ccab792395669))
### Performance Improvements
* **boards:** parallelize board page data fetching ([#5736](https://github.com/homarr-labs/homarr/issues/5736)) ([8f03a38](https://github.com/homarr-labs/homarr/commit/8f03a38de5b9b385da7ffcc9a963be00710acb5e))
## [1.62.0](https://github.com/homarr-labs/homarr/compare/v1.61.1...v1.62.0) (2026-05-22)
### Features
* **seed:** add demo mode with pre-seeded user ([#5739](https://github.com/homarr-labs/homarr/issues/5739)) ([dade477](https://github.com/homarr-labs/homarr/commit/dade477b3226765eb614e66588ebee84c45cb688))
### Bug Fixes
* **deps:** update dependency better-sqlite3 to ^12.10.0 ([#5727](https://github.com/homarr-labs/homarr/issues/5727)) ([044ad9e](https://github.com/homarr-labs/homarr/commit/044ad9edf9c1dc9b50b9591dea0b5f0cce4be01a))
* **docker:** improve icon matching ([#5693](https://github.com/homarr-labs/homarr/issues/5693)) ([7cd30b6](https://github.com/homarr-labs/homarr/commit/7cd30b640cbf47e4678abda18f83c0924f039b1b))
* exclude .env files from Docker build context ([#5738](https://github.com/homarr-labs/homarr/issues/5738)) ([fa73763](https://github.com/homarr-labs/homarr/commit/fa737638a812372d904207fe7af5bd7c6f53d159))
* **tracearr:** support absolute image urls ([#5654](https://github.com/homarr-labs/homarr/issues/5654)) ([7a12721](https://github.com/homarr-labs/homarr/commit/7a1272146c597c1f9df89d74b8389f70e7cdd61a))
### Performance Improvements
* (next branch) reduce idle memory on startup ([#5637](https://github.com/homarr-labs/homarr/issues/5637)) ([5ee84f8](https://github.com/homarr-labs/homarr/commit/5ee84f8cf464c308729281cb4db572950ba85a82))
* **boards:** remove loading overlay and show board immediately ([#5735](https://github.com/homarr-labs/homarr/issues/5735)) ([df4b57b](https://github.com/homarr-labs/homarr/commit/df4b57bd414d4402522f9210b1470d0561c37274))
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
### Bug Fixes
+1
View File
@@ -57,6 +57,7 @@
"@mantine/modals": "catalog:",
"@mantine/tiptap": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/query-persist-client-core": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:",
@@ -0,0 +1,49 @@
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
import { parse, stringify } from "superjson";
import { fetchApi } from "@homarr/api/client";
import {
getActiveQueryCacheBoardId,
isPersistableWidgetQueryKey,
queryCacheStoragePrefix,
} from "@homarr/api/query-cache";
const queryCacheStorage = {
async getItem(_key) {
return null;
},
async setItem(key, value) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
} catch {
// Query persistence is best-effort; failed writes should not affect widget rendering.
}
},
async removeItem(key) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.removeItem.mutate({ boardId, key });
} catch {
// Query persistence is best-effort; failed removals should not affect widget rendering.
}
},
} satisfies AsyncStorage<string>;
export const createWidgetQueryPersister = () =>
experimental_createQueryPersister<string>({
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
maxAge: Number.POSITIVE_INFINITY,
prefix: queryCacheStoragePrefix,
refetchOnRestore: true,
serialize: (query) => stringify(query),
deserialize: (value) => parse<PersistedQuery>(value),
filters: {
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
},
});
@@ -17,13 +17,13 @@ import {
import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { createWidgetQueryPersister } from "./query-cache-persister";
const getWebSocketProtocol = () => {
// window is not defined on server side
@@ -52,26 +52,14 @@ const wsClient = createWSClient({
});
export function TRPCReactProvider(props: PropsWithChildren) {
const [queryPersister] = useState(() => createWidgetQueryPersister());
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
},
mutations: {
onError(error) {
if (
error instanceof TRPCClientError &&
error.data?.code === "FORBIDDEN" &&
error.message === "Mutations are disabled in demo mode"
) {
showWarningNotification({
title: "Demo mode",
message: "This action is disabled in demo mode.",
});
}
},
persister: queryPersister.persisterFn,
staleTime: queryCacheDefaultStaleTimeMs,
},
},
}),
@@ -1,14 +1,11 @@
import { redirect } from "next/navigation";
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
@@ -40,13 +37,6 @@ export default async function Login(props: LoginProps) {
{t("subtitle")}
</Text>
</Stack>
{appEnv.DEMO_MODE && (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
<Text size="sm" fw={500}>
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
</Text>
</Alert>
)}
<Card w={64 * 6} maw="90vw">
<LoginForm
providers={env.AUTH_PROVIDERS}
@@ -1,10 +1,14 @@
import { cache } from "react";
import type { Metadata } from "next";
import { Suspense } from "react";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import type { DehydratedState } from "@tanstack/react-query";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { parse } from "superjson";
import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client";
@@ -14,6 +18,7 @@ import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
import { getAllQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -34,10 +39,13 @@ interface Props<TParams extends Params> {
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
getInitialBoardAsync: getInitialBoard,
}: Props<TParams>) => {
const getCachedBoard = cache((_paramsKey: string, params: TParams) => getInitialBoard(params));
const getBoardForParams = (params: TParams) => getCachedBoard(JSON.stringify(params), params);
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard,
getInitialBoardAsync: getBoardForParams,
withTour: true,
}),
// eslint-disable-next-line no-restricted-syntax
@@ -45,7 +53,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
const resolvedParams = await params;
const queryClient = getQueryClient();
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const [board, session] = await Promise.all([getBoardForParams(resolvedParams), auth()]);
const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind);
@@ -72,9 +80,14 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
),
]);
const userId = session?.user.id ?? "anonymous";
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
@@ -82,7 +95,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try {
const board = await getInitialBoard(await params);
const board = await getBoardForParams(await params);
const t = await getI18n();
return {
@@ -98,7 +111,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
} catch (error) {
// Ignore not found errors and return empty metadata
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
return {};
}
@@ -108,3 +120,37 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
};
interface PersistedEntry {
state: { data: unknown; dataUpdatedAt: number; status: string };
queryKey: unknown[];
queryHash: string;
}
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
try {
const entries = await getAllQueryCacheAsync(userId, boardId);
const queries: DehydratedState["queries"] = [];
for (const serialized of Object.values(entries)) {
try {
const persisted = parse<PersistedEntry>(serialized);
if (persisted.state.status !== "success") continue;
queries.push({
state: persisted.state as DehydratedState["queries"][number]["state"],
queryKey: persisted.queryKey,
queryHash: persisted.queryHash,
});
} catch {
// skip malformed entries
}
}
if (queries.length === 0) return null;
return <HydrationBoundary state={{ mutations: [], queries }} />;
} catch {
return null;
}
}
@@ -45,9 +45,8 @@ export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
if (!hasChangeAccess || isLoading) {
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
}
@@ -55,15 +54,13 @@ export const BoardContentHeaderActions = () => {
<>
{isEditMode && <AddMenu />}
<EditModeMenu demoMode={demoMode ?? false} />
<EditModeMenu />
{!demoMode && (
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</OnboardingTour.Target>
)}
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</OnboardingTour.Target>
<SelectBoardsMenu />
</>
@@ -158,7 +155,7 @@ const AddMenu = () => {
);
};
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const EditModeMenu = () => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
@@ -181,18 +178,10 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
},
});
const discardDemoChanges = useCallback(() => {
void utils.board.getBoardByName.invalidate({ name: board.name });
close();
}, [utils, board.name, close]);
const toggle = useCallback(() => {
if (isEditMode) {
if (demoMode) return discardDemoChanges();
return saveBoard(board);
}
if (isEditMode) return saveBoard(board);
open();
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
}, [board, isEditMode, saveBoard, open]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
@@ -78,7 +78,10 @@ export const InitIntegrations = () => {
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
const totalDetected = dockerIntegrations.length + dockerApps.length;
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
const detectedKinds = useMemo(
() => new Set(dockerIntegrations.map((i) => i.kind)),
[dockerIntegrations],
);
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
const [appsExpanded, setAppsExpanded] = useState(false);
-1
View File
@@ -26,7 +26,6 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
@@ -0,0 +1,85 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userChangeHomeBoardsSchema } from "@homarr/validation/user";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select";
interface ChangeHomeBoardFormProps {
user: RouterOutputs["user"]["getById"];
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
}
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
homeBoardId: variables.homeBoardId,
mobileHomeBoardId: variables.mobileHomeBoardId,
});
showSuccessNotification({
message: t("user.action.changeHomeBoard.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeHomeBoard.notification.error.message"),
});
},
});
const form = useZodForm(userChangeHomeBoardsSchema, {
initialValues: {
homeBoardId: user.homeBoardId,
mobileHomeBoardId: user.mobileHomeBoardId,
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<BoardSelect
label={t("management.page.user.setting.general.item.board.type.general")}
clearable
boards={boardsData}
w="100%"
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={t("management.page.user.setting.general.item.board.type.mobile")}
clearable
boards={boardsData}
w="100%"
{...form.getInputProps("mobileHomeBoardId")}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userChangeHomeBoardsSchema>;
@@ -0,0 +1,84 @@
"use client";
import { Button, Group, Select, Stack, Switch } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
interface ChangeSearchPreferencesFormProps {
user: RouterOutputs["user"]["getById"];
searchEnginesData: { value: string; label: string }[];
}
export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
defaultSearchEngineId: variables.defaultSearchEngineId,
openInNewTab: variables.openInNewTab,
ddgBangsEnabled: variables.ddgBangsEnabled,
});
showSuccessNotification({
message: t("user.action.changeSearchPreferences.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeSearchPreferences.notification.error.message"),
});
},
});
const form = useZodForm(userChangeSearchPreferencesSchema, {
initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
ddgBangsEnabled: user.ddgBangs,
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label={t("user.field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={t("user.field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={t("user.field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userChangeSearchPreferencesSchema>;
@@ -0,0 +1,83 @@
"use client";
import { Button, Group, Radio, Stack } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userFirstDayOfWeekSchema } from "@homarr/validation/user";
dayjs.extend(localeData);
interface FirstDayOfWeekProps {
user: RouterOutputs["user"]["getById"];
}
const weekDays = dayjs.weekdays(false);
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
firstDayOfWeek: variables.firstDayOfWeek as DayOfWeek,
});
showSuccessNotification({
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
});
},
});
const form = useZodForm(userFirstDayOfWeekSchema, {
initialValues: {
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
const inputProps = form.getInputProps("firstDayOfWeek");
const onChange = inputProps.onChange as (value: number) => void;
const value = (inputProps.value as number).toString();
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Radio.Group {...inputProps} value={value} onChange={(nextValue: string) => onChange(parseInt(nextValue, 10))}>
<Group mt="xs">
<Radio value="1" label={weekDays[1]} />
<Radio value="6" label={weekDays[6]} />
<Radio value="0" label={weekDays[0]} />
</Group>
</Radio.Group>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userFirstDayOfWeekSchema>;
@@ -0,0 +1,69 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userPingIconsEnabledSchema } from "@homarr/validation/user";
interface PingIconsEnabledProps {
user: RouterOutputs["user"]["getById"];
}
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
pingIconsEnabled: variables.pingIconsEnabled,
});
showSuccessNotification({
message: t("user.action.changePingIconsEnabled.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changePingIconsEnabled.notification.error.message"),
});
},
});
const form = useZodForm(userPingIconsEnabledSchema, {
initialValues: {
pingIconsEnabled: user.pingIconsEnabled,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Switch
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
label={t("user.field.pingIconsEnabled.label")}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userPingIconsEnabledSchema>;
@@ -0,0 +1,94 @@
"use client";
import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userEditProfileSchema } from "@homarr/validation/user";
interface UserProfileFormProps {
user: RouterOutputs["user"]["getById"];
}
export const UserProfileForm = ({ user }: UserProfileFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
async onSettled() {
await revalidatePathActionAsync("/manage/users");
},
onSuccess(_, variables) {
// Reset form initial values to reset dirty state
form.setInitialValues({
name: variables.name,
email: variables.email ?? "",
});
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("user.action.editProfile.notification.success.message"),
});
},
onError(error) {
const message =
error.data?.code === "CONFLICT"
? t("user.error.usernameTaken")
: t("user.action.editProfile.notification.error.message");
showErrorNotification({
title: t("common.notification.update.error"),
message,
});
},
});
const form = useZodForm(userEditProfileSchema.omit({ id: true }), {
initialValues: {
name: user.name ?? "",
email: user.email ?? "",
},
});
// Only credentials users can edit their profile
const isProviderCredentials = user.provider === "credentials";
const handleSubmit = useCallback(
(values: FormType) => {
if (!isProviderCredentials) return;
mutate({
...values,
id: user.id,
});
},
[isProviderCredentials, mutate, user.id],
);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
disabled={!isProviderCredentials}
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isProviderCredentials}
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
{isProviderCredentials && (
<Group justify="end">
<Button type="submit" disabled={!form.isDirty()} loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
)}
</Stack>
</form>
);
};
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;
@@ -6,13 +6,18 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
import { ResetTours } from "./_components/_reset-tours";
interface Props {
@@ -59,7 +64,7 @@ export default async function EditUserPage(props: Props) {
const boards = await api.board.getAllBoards();
const searchEngines = await api.searchEngine.getSelectable();
const isSelf = session?.user.id === user.id;
const isCredentialsUser = user.provider === "credentials";
return (
@@ -69,25 +74,51 @@ export default async function EditUserPage(props: Props) {
{t("management.page.user.fieldsDisabledExternalProvider")}
</Alert>
)}
<Title>{tGeneral("title")}</Title>
<Group gap="xl" align="flex-start" wrap="wrap">
<Box flex={1} miw={{ base: "100%", md: 540 }}>
<UserGeneralSettingsForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
searchEnginesData={searchEngines}
showLanguageSelector={isSelf}
/>
<Group gap="xl">
<Box flex={1}>
<UserProfileForm user={user} />
</Box>
<Box w={{ base: "100%", lg: 260 }}>
<Box w={{ base: "100%", lg: 200 }}>
<UserProfileAvatarForm user={user} />
</Box>
</Group>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
</Stack>
)}
<Stack mb="lg">
<Title order={2}>{tGeneral("item.board.title")}</Title>
<ChangeHomeBoardForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
/>
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.search")}</Title>
<ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
<FirstDayOfWeek user={user} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.accessibility")}</Title>
<PingIconsEnabled user={user} />
</Stack>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access";
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
const session = await auth();
const t = await getI18n();
const tUser = await getScopedI18n("management.page.user");
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized);
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form";
@@ -23,8 +23,7 @@ export default async function UserSecurityPage(props: Props) {
.getById({
userId: params.userId,
})
.catch(catchTrpcNotFound)
.catch(catchTrpcUnauthorized);
.catch(catchTrpcNotFound);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
@@ -1,155 +0,0 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
export type Translator = {
(key: string): string;
};
export interface HoverMetadataItem {
label: string;
value: string;
}
const maxMetadataItems = 3;
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
app: true,
anchorNote: true,
dynamicSelect: true,
sortableItemList: true,
multiReleasesRepositories: true,
umamiEventName: true,
umamiEventNames: true,
umamiWebsite: true,
};
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
type OptionDefinitionWithRules = WidgetOptionDefinition & {
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
options?: readonly { value: unknown; label: unknown }[];
};
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
const match = definition.options?.find((option) => option.value === value);
const label = match?.label;
const fallbackLabel = () => String(value);
const labelResolvers: Record<string, () => string> = {
function: () => (label as (translator: Translator) => string)(t),
string: () => label as string,
missing: fallbackLabel,
};
const labelType = typeof label;
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
return resolveLabel();
};
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
switch: (value, _, t) => {
const switchLabels: Record<string, string> = {
true: t("common.action.show"),
false: t("common.action.hide"),
};
return switchLabels[String(Boolean(value))] ?? null;
},
text: (value) => {
const text = String(value).trim();
return text.length > 0 ? text : null;
},
number: (value) => String(value),
slider: (value) => String(value),
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
multiSelect: (value) => {
const items = value as unknown[];
return items.length > 0 ? String(items.length) : null;
},
multiText: (value) => {
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
return items.length > 0 ? items.join(", ") : null;
},
location: (value) => {
const location = value as { name?: string };
const name = location.name?.trim() ?? "";
return name.length > 0 ? name : null;
},
};
const formatOptionValue = (
type: WidgetOptionType,
value: unknown,
definition: OptionDefinitionWithRules,
t: Translator,
) => {
const formatter = optionTypeFormatters[type];
return formatter?.(value, definition, t) ?? null;
};
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
const labelKey = `widget.${kind}.option.${key}.label`;
const translated = t(labelKey);
return translated === labelKey ? key : translated;
};
export const collectWidgetHoverMetadata = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrationKinds: IntegrationKind[],
t: Translator,
): HoverMetadataItem[] => {
const { definition } = widgetImports[item.kind];
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
return objectEntries(optionDefinitions)
.filter(([key, optionDefinition]) => {
const skip = skippedOptionTypes[optionDefinition.type];
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
return !skip && !hidden && !unchanged;
})
.map(([key, optionDefinition]) => {
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
return {
label: getOptionLabel(item.kind, key, t),
value: formattedValue ?? "",
};
})
.filter((entry) => entry.value.length > 0)
.slice(0, maxMetadataItems);
};
interface DisplayNameSource {
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
}
const displayNameSources: DisplayNameSource[] = [
{
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
},
{
matches: (item, options) =>
item.kind === "clock" &&
Boolean(options.customTitleToggle) &&
String(options.customTitle ?? "").trim().length > 0,
resolve: (_, options) => String(options.customTitle).trim(),
},
];
export const resolveWidgetDisplayName = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
t: Translator,
) => {
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
};
@@ -1,116 +0,0 @@
.wrapper {
position: absolute;
left: 10px;
right: 10px;
bottom: calc(100% - 10px);
margin-bottom: 1px;
z-index: 200;
pointer-events: auto;
opacity: 0;
transform: translateY(6px);
transition:
opacity 150ms ease,
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
.panel {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9);
}
@mixin light {
--background-color: rgb(from var(--mantine-color-white) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9);
}
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
backdrop-filter: blur(8px);
overflow: hidden;
}
.nameSection {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-shrink: 0;
}
.widgetIcon {
flex-shrink: 0;
color: var(--mantine-color-dimmed);
}
.name {
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.integrations {
flex-shrink: 0;
}
.integrationAvatar {
border: none;
}
.metadata {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
margin-left: auto;
}
.metadataItem {
@mixin dark {
--chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9);
}
@mixin light {
--chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95);
}
display: flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 2px 6px;
border-radius: var(--mantine-radius-md);
background-color: var(--chip-background);
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
}
.metadataLabel {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.metadataValue {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.wrapper {
display: none;
}
}
@@ -1,86 +0,0 @@
"use client";
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { Translator } from "./widget-hover-metadata";
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
import classes from "./widget-hover-overlay.module.css";
interface WidgetHoverOverlayProps {
item: SectionItem;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const [isEditMode] = useEditMode();
const settings = useSettings();
const t = useI18n();
const { definition } = widgetImports[item.kind];
const WidgetIcon = definition.icon;
const translate = ((key: string) => t(key as never)) as Translator;
const displayName = resolveWidgetDisplayName(item, settings, translate);
const connectedIntegrations = (integrations ?? []).filter((integration) =>
item.integrationIds.includes(integration.id),
);
const metadataItems = collectWidgetHoverMetadata(
item,
settings,
connectedIntegrations.map((integration) => integration.kind),
translate,
);
const hasIntegrations = connectedIntegrations.length > 0;
const hasMetadata = metadataItems.length > 0;
if (isEditMode) {
return null;
}
return (
<div className={classes.wrapper}>
<div className={classes.panel}>
<div className={classes.nameSection}>
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
<Text className={classes.name} title={displayName}>
{displayName}
</Text>
</div>
{hasIntegrations && (
<Avatar.Group spacing="xs" className={classes.integrations}>
{connectedIntegrations.map((integration) => (
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
<Avatar
src={getIconUrl(integration.kind)}
size={20}
radius="sm"
className={classes.integrationAvatar}
/>
</Tooltip>
))}
</Avatar.Group>
)}
{hasMetadata && (
<Group className={classes.metadata} gap={4}>
{metadataItems.map((metadataItem) => (
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
<span className={classes.metadataLabel}>{metadataItem.label}</span>
<span className={classes.metadataValue}>{metadataItem.value}</span>
</div>
))}
</Group>
)}
</div>
</div>
);
};
@@ -2,12 +2,8 @@ import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { useSectionContext } from "./section-context";
@@ -15,7 +11,6 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
const { data: integrations } = clientApi.integration.all.useQuery();
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -23,7 +18,7 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
const sortedItems = useMemo(() => {
return [...items, ...innerSections].toSorted((itemA, itemB) => {
return [...items, ...innerSections].sort((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) {
return itemA.xOffset - itemB.xOffset;
}
@@ -35,7 +30,7 @@ export const SectionContent = () => {
return (
<>
{sortedItems.map((item) => (
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
))}
</>
);
@@ -44,12 +39,12 @@ export const SectionContent = () => {
interface ItemProps {
item: DynamicSectionItem | SectionItem;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
const Item = ({ item, innerRef, integrations }: ItemProps) => {
const Item = ({ item, innerRef }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
return (
<GridStackItem
key={item.id}
@@ -64,7 +59,6 @@ const Item = ({ item, innerRef, integrations }: ItemProps) => {
minWidth={minWidth}
minHeight={minHeight}
>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
);
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
window.location.assign(logoutUrl);
return;
}
router.push("/auth/login");
router.refresh();
},
});
}, [logoutUrl, openModal, router]);
-1
View File
@@ -3,7 +3,6 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
},
experimental__runtimeEnv: process.env,
});
-4
View File
@@ -102,10 +102,6 @@
overflow-y: auto;
}
.grid-stack > .grid-stack-item[data-type="item"] {
overflow: visible;
}
.grid-stack.grid-stack-animate {
transition: none;
}
+10 -20
View File
@@ -19,9 +19,6 @@
<!-- Links -->
<p align="center">
<a href="https://demo.homarr.dev">
<strong>Live Demo 🎮</strong>
</a> •
<a href="https://homarr.dev/docs/category/installation-1/">
<strong>Install 💻</strong>
</a> •
@@ -249,20 +246,13 @@
</a>
</td>
<td align="center">
<a href="null" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg" alt="PeaNUT" width="90" height="90" />
<br/>
<p align="center">PeaNUT</p>
</a>
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
<br/>
<p align="center">Pi-hole</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
<br/>
@@ -303,15 +293,15 @@
<br/>
<p align="center">Radarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
<br/>
<p align="center">Readarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
<br/>
@@ -352,15 +342,15 @@
<br/>
<p align="center">Speedtest<br/>Tracker</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
<br/>
<p align="center">Tdarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
<br/>
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.64.0",
"version": "1.61.1",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -78,7 +78,7 @@
"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.17.0",
"axios@>=1.0.0 <1.8.2": ">=1.16.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",
@@ -88,13 +88,13 @@
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
"react-is": "^19.2.7",
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
"react-is": "^19.2.6",
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
"sha.js@<=2.4.11": ">=2.4.12",
"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.0.14"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
+1
View File
@@ -9,6 +9,7 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./query-cache": "./src/query-cache.ts",
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
+1 -3
View File
@@ -1,14 +1,12 @@
import { z } from "zod/v4";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
import { createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
},
});
+2 -2
View File
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env";
import { isDemoMode, publicProcedure } from "../trpc";
import { publicProcedure } from "../trpc";
export const dockerMiddleware = () => {
return publicProcedure.use(async ({ next }) => {
if (env.ENABLE_DOCKER || isDemoMode) {
if (env.ENABLE_DOCKER) {
return await next();
}
throw new TRPCError({
+34
View File
@@ -0,0 +1,34 @@
import type { QueryKey } from "@tanstack/react-query";
export const queryCacheRetentionMs = 1000 * 60 * 60 * 24;
export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5;
export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query";
let activeQueryCacheBoardId: string | null = null;
export const setActiveQueryCacheBoardId = (boardId: string | null) => {
activeQueryCacheBoardId = boardId;
};
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
if (!Array.isArray(first)) return null;
const parts = first.filter((part): part is string => typeof part === "string");
return parts.length > 0 ? parts : null;
};
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
return persistableQueryPaths.has(path.join("."));
};
+1
View File
@@ -24,6 +24,7 @@ export const appRouter = createTRPCRouter({
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
+1 -2
View File
@@ -1,7 +1,7 @@
import z from "zod/v4";
import packageJson from "../../../../package.json";
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
@@ -13,5 +13,4 @@ export const infoRouter = createTRPCRouter({
version: packageJson.version,
};
}),
isDemoMode: publicProcedure.query(() => isDemoMode),
});
+54
View File
@@ -0,0 +1,54 @@
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { createQueryCacheChannel } from "@homarr/redis";
import { queryCacheMaxValueBytes, queryCacheRetentionMs } from "../query-cache";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const queryCacheKeyInput = z.object({
boardId: z.string().min(1),
key: z.string().min(1).max(4096),
});
const queryCacheSetInput = queryCacheKeyInput.extend({
value: z.string().max(queryCacheMaxValueBytes * 2),
});
const createChannel = (input: z.infer<typeof queryCacheKeyInput>, userId: string) =>
createQueryCacheChannel({
userId,
boardId: input.boardId,
key: input.key,
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
};
export const queryCacheRouter = createTRPCRouter({
getItem: publicProcedure.input(queryCacheKeyInput).query(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return await channel.getAsync();
}),
setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return {
stored: await channel.setAsync(input.value),
};
}),
removeItem: publicProcedure.input(queryCacheKeyInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
await channel.removeAsync();
}),
});
-8
View File
@@ -32,7 +32,6 @@ import {
import { convertIntersectionToZodObject } from "../schema-merger";
import {
createTRPCRouter,
isDemoMode,
onboardingProcedure,
permissionRequiredProcedure,
protectedProcedure,
@@ -630,13 +629,6 @@ export const userRouter = createTRPCRouter({
.where(eq(users.id, ctx.session.user.id));
}),
getTourStatus: protectedProcedure.query(async ({ ctx }) => {
if (isDemoMode) {
return {
completedManageTour: true,
completedBoardTour: true,
};
}
const user = await ctx.db.query.users.findFirst({
columns: {
completedManageTour: true,
-1
View File
@@ -31,5 +31,4 @@ export const widgetRouter = createTRPCRouter({
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
});
-51
View File
@@ -1,51 +0,0 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { UpsSummary } from "@homarr/integrations/types";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
return innerHandler.subscribe((summaries) => {
emit.next({
integrationId: integration.id,
summaries,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
}),
});
+3 -1
View File
@@ -1,5 +1,7 @@
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
import { queryCacheDefaultStaleTimeMs } from "./query-cache";
/**
* Creates a headers callback for a given source
* It will set the x-trpc-source header and cookies if needed
@@ -58,7 +60,7 @@ export const makeQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
staleTime: queryCacheDefaultStaleTimeMs,
},
dehydrate: {
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
+224
View File
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import {
isPersistableWidgetQueryKey,
queryCacheDefaultStaleTimeMs,
queryCacheMaxValueBytes,
queryCacheRetentionMs,
} from "@homarr/api/query-cache";
import { queryCacheRouter } from "../router/query-cache";
const redisMock = vi.hoisted(() => {
const values = new Map<string, { value: string; expiresAt: number }>();
const calls: {
userId: string;
boardId: string;
key: string;
ttlMs: number;
maxValueBytes: number;
}[] = [];
return {
values,
calls,
createQueryCacheChannel: vi.fn(
(options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => {
calls.push(options);
const storageKey = `${options.userId}:${options.boardId}:${options.key}`;
return {
getAsync: async () => {
const item = values.get(storageKey);
if (!item) return null;
if (Date.now() > item.expiresAt) {
values.delete(storageKey);
return null;
}
return item.value;
},
setAsync: async (value: string) => {
if (Buffer.byteLength(value, "utf8") > options.maxValueBytes) {
return false;
}
values.set(storageKey, { value, expiresAt: Date.now() + options.ttlMs });
return true;
},
removeAsync: async () => {
values.delete(storageKey);
},
};
},
),
};
});
vi.mock("@homarr/redis", () => ({
createQueryCacheChannel: redisMock.createQueryCacheChannel,
}));
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
afterEach(() => {
vi.useRealTimers();
redisMock.values.clear();
redisMock.calls.length = 0;
redisMock.createQueryCacheChannel.mockClear();
});
const createSession = (userId = createId()): Session => ({
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const insertUserAsync = async (db: Database, id: string) => {
await db.insert(users).values({ id });
};
describe("isPersistableWidgetQueryKey", () => {
test.each([
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
[[["app", "byId"], { type: "query" }], true],
[[["app", "byIds"], { type: "query" }], true],
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
])("matches persist filter for %j", (queryKey, expected) => {
expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
});
});
describe("queryCacheRouter", () => {
test("stores, reads, and removes data for a board viewer", async () => {
const db = createDb();
const session = createSession();
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" })).resolves.toEqual({
stored: true,
});
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await caller.removeItem({ boardId: "board-1", key: "query-key" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
test("does not touch Redis when board access is denied", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
redisMock.createQueryCacheChannel.mockClear();
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).rejects.toThrow("Board not found");
expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled();
});
test("scopes anonymous public-board cache entries by anonymous user and board", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "anonymous",
boardId: "board-1",
key: "query-key",
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("scopes signed-in cache entries by user and rejects oversized values", async () => {
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
const value = "x".repeat(queryCacheMaxValueBytes + 1);
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value })).resolves.toEqual({
stored: false,
});
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "user-1",
boardId: "board-1",
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("expires values through the Redis TTL path", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await vi.advanceTimersByTimeAsync(queryCacheRetentionMs + 1);
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
});
describe("query cache stale handling", () => {
test("uses TanStack Query dataUpdatedAt and staleTime to decide when cached data refetches", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z"));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: queryCacheDefaultStaleTimeMs,
},
},
});
const queryKey = [["widget", "example"], { input: { integrationIds: ["integration-1"] }, type: "query" }];
const queryFn = vi.fn().mockResolvedValue("fresh");
queryClient.setQueryData(queryKey, "cached", {
updatedAt: Date.now() - queryCacheDefaultStaleTimeMs - 1,
});
await expect(
queryClient.fetchQuery({
queryKey,
queryFn,
}),
).resolves.toBe("fresh");
expect(queryFn).toHaveBeenCalledOnce();
});
});
+7 -17
View File
@@ -18,7 +18,6 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
const logger = createLogger({ module: "trpc" });
@@ -88,20 +87,6 @@ export const createCallerFactory = t.createCallerFactory;
*/
export const createTRPCRouter = t.router;
export const isDemoMode = env.DEMO_MODE;
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
if (env.DEMO_MODE && type === "mutation") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Mutations are disabled in demo mode",
});
}
return next({ ctx });
});
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/**
* Public (unauthed) procedure
*
@@ -109,14 +94,19 @@ const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) :
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
export const publicProcedure = baseProcedure;
export const publicProcedure = t.procedure;
/**
* Reusable middleware that enforces users are logged in before running the
* procedure
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
@@ -131,7 +121,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed);
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
/**
* Procedure that requires a specific permission
+14 -1
View File
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
import { updateBoardName } from "./updater";
@@ -28,6 +29,8 @@ export const BoardProvider = ({
},
);
setActiveQueryCacheBoardId(data.id);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
@@ -42,6 +45,16 @@ export const BoardProvider = ({
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
setActiveQueryCacheBoardId(data.id);
return () => {
if (getActiveQueryCacheBoardId() === data.id) {
setActiveQueryCacheBoardId(null);
}
};
}, [data.id]);
return (
<BoardContext.Provider
value={{
@@ -73,7 +86,7 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (typeof window === "undefined") return board.layouts.at(0)!.id;
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
// Fallback to smallest if none exists with breakpoint smaller than window width
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+6 -229
View File
@@ -1,5 +1,3 @@
import SuperJSON from "superjson";
import { createId, objectKeys } from "@homarr/common";
import {
createDocumentationLink,
@@ -11,7 +9,6 @@ import {
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
@@ -22,16 +19,13 @@ import {
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
import {
apps,
boards,
groupMembers,
groupPermissions,
groups,
integrationItems,
integrations,
itemLayouts,
items,
layouts,
onboarding,
@@ -41,16 +35,7 @@ import {
} from "../schema";
import type { Integration } from "../schema";
const isTruthyEnv = (value: string | undefined) =>
["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
export const seedDataAsync = async (db: Database) => {
if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) {
console.warn(
"UNSAFE_ENABLE_MOCK_INTEGRATION is enabled: mock integration is available in the UI. Disable by setting UNSAFE_ENABLE_MOCK_INTEGRATION=false.",
);
}
await seedEveryoneGroupAsync(db);
await seedOnboardingAsync(db);
await seedServerSettingsAsync(db);
@@ -60,7 +45,7 @@ export const seedDataAsync = async (db: Database) => {
await seedDefaultBoardAsync(db);
await seedBoardWidgetsAsync(db);
if (isTruthyEnv(process.env.DEMO_MODE)) {
if (process.env.DEMO_MODE === "true") {
await seedDemoUserAsync(db);
}
};
@@ -288,134 +273,6 @@ const seedDefaultBoardAsync = async (db: Database) => {
console.log("Created default board 'dashboard' through seed");
};
interface DemoWidget {
kind: WidgetKind;
width: number;
height: number;
needsIntegration: boolean;
options?: Record<string, unknown>;
}
const demoApps = [
{
name: "Sonarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
href: "https://sonarr.tv",
},
{
name: "Radarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
href: "https://radarr.video",
},
{
name: "Plex",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
href: "https://plex.tv",
},
{
name: "Jellyfin",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
href: "https://jellyfin.org",
},
{
name: "Proxmox",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg",
href: "https://proxmox.com",
},
{
name: "Pi-hole",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
href: "https://pi-hole.net",
},
{
name: "Grafana",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg",
href: "https://grafana.com",
},
{
name: "Portainer",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg",
href: "https://portainer.io",
},
{
name: "Home Assistant",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
href: "https://home-assistant.io",
},
{
name: "Nextcloud",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg",
href: "https://nextcloud.com",
},
{
name: "qBittorrent",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
href: "https://qbittorrent.org",
},
{
name: "Overseerr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
href: "https://overseerr.dev",
},
] as const;
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
// Row 1: calendar + downloads + clock = 12
{ kind: "calendar", width: 5, height: 3, needsIntegration: true },
{ kind: "downloads", width: 5, height: 3, needsIntegration: true },
{ kind: "clock", width: 2, height: 1, needsIntegration: false },
// Row 2: healthMonitoring + bookmarks = 12
{ kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
{
kind: "bookmarks",
width: 6,
height: 3,
needsIntegration: false,
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
},
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
{ kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
{ kind: "notifications", width: 6, height: 2, needsIntegration: true },
// Row 5: requestList + requestStats + indexerManager = 12
{ kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
{ kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
{ kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
// Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
{ kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
{ kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
{
kind: "rssFeed",
width: 6,
height: 2,
needsIntegration: false,
options: {
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
maximumAmountPosts: 20,
textLinesClamp: 2,
hideDescription: false,
},
},
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
...appIds.map(
(appId): DemoWidget => ({
kind: "app",
width: 2,
height: 1,
needsIntegration: false,
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
}),
),
// Row 9: notebook + dockerContainers + weather = 12
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
];
const seedDemoUserAsync = async (db: Database) => {
const existingUser = await db.query.users.findFirst({
where: eq(users.name, "demo"),
@@ -432,7 +289,7 @@ const seedDemoUserAsync = async (db: Database) => {
id: userId,
name: "demo",
email: "demo@example.com",
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2",
});
const maxPosition = await getMaxGroupPositionAsync(db);
@@ -457,93 +314,13 @@ const seedDemoUserAsync = async (db: Database) => {
previousStep: "settings",
});
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "Demo Integration",
url: "https://demo.homarr.dev",
kind: "mock",
appId: null,
});
const appIds: string[] = [];
for (const app of demoApps) {
const appId = createId();
appIds.push(appId);
await db.insert(apps).values({
id: appId,
name: app.name,
iconUrl: app.iconUrl,
href: app.href,
});
const board = await db.query.boards.findFirst();
if (board) {
await db.update(users).set({ homeBoardId: board.id }).where(eq(users.id, userId));
}
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "default",
isPublic: false,
creatorId: userId,
});
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
const layoutId = createId();
await db.insert(layouts).values({
id: layoutId,
name: "Base",
columnCount: 12,
breakpoint: 0,
boardId,
});
const demoWidgets = buildDemoWidgets(appIds);
let xOffset = 0;
let yOffset = 0;
let rowMaxHeight = 0;
for (const widget of demoWidgets) {
if (xOffset + widget.width > 12) {
xOffset = 0;
yOffset += rowMaxHeight;
rowMaxHeight = 0;
}
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
const itemId = createId();
await db.insert(items).values({
id: itemId,
boardId,
kind: widget.kind,
...(widget.options ? { options: SuperJSON.stringify(widget.options) } : {}),
});
await db.insert(itemLayouts).values({
itemId,
sectionId,
layoutId,
xOffset,
yOffset,
width: widget.width,
height: widget.height,
});
if (widget.needsIntegration) {
await db.insert(integrationItems).values({
itemId,
integrationId,
});
}
xOffset += widget.width;
}
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
console.log(
"Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.",
"Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.",
);
};
@@ -157,7 +157,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/anchor"
| "/docs/integrations/aria2"
| "/docs/integrations/codeberg"
| "/docs/integrations/coolify"
| "/docs/integrations/dash-dot"
| "/docs/integrations/deluge"
| "/docs/integrations/docker-hub"
@@ -182,7 +181,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/open-media-vault"
| "/docs/integrations/opnsense"
| "/docs/integrations/overseerr"
| "/docs/integrations/peanut"
| "/docs/integrations/pi-hole"
| "/docs/integrations/plex"
| "/docs/integrations/prowlarr"
@@ -219,7 +217,6 @@ export type HomarrDocumentationPath =
| "/docs/widgets/bookmarks"
| "/docs/widgets/calendar"
| "/docs/widgets/clock"
| "/docs/widgets/coolify"
| "/docs/widgets/dns-hole-controls"
| "/docs/widgets/dns-hole-summary"
| "/docs/widgets/docker-containers"
@@ -250,7 +247,6 @@ export type HomarrDocumentationPath =
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
| "/docs/widgets/umami"
| "/docs/widgets/ups"
| "/docs/widgets/video"
| "/docs/widgets/weather"
| ""
+2 -10
View File
@@ -364,7 +364,7 @@ export const integrationDefs = {
},
truenas: {
name: "TrueNAS",
secretKinds: [["username", "password"], ["apiKey"]],
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
@@ -383,6 +383,7 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg",
category: ["healthMonitoring"],
// @ts-expect-error - docs page will be created when integration is merged
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
defaultPort: 8000,
},
@@ -426,13 +427,6 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
defaultPort: 3000,
},
peaNut: {
name: "PeaNUT",
secretKinds: [["username", "password"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg",
category: ["ups"],
documentationUrl: null,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -451,7 +445,6 @@ export const integrationDefs = {
"networkController",
"notifications",
"smartHomeServer",
"ups",
],
documentationUrl: null,
},
@@ -539,7 +532,6 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
"ups",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
-1
View File
@@ -38,6 +38,5 @@ export const widgetKinds = [
"tracearr",
"speedtestTracker",
"umami",
"ups",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
-2
View File
@@ -47,14 +47,12 @@
"timezones-ical-library": "catalog:",
"tsdav": "catalog:",
"undici": "catalog:",
"ws": "catalog:",
"xml2js": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-unifi": "catalog:",
"@types/ws": "catalog:",
"@types/xml2js": "catalog:",
"typescript": "catalog:"
}
@@ -36,7 +36,6 @@ import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PeaNutIntegration } from "../peanut/peanut-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
@@ -124,7 +123,6 @@ export const integrationCreators = {
immich: ImmichIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
umami: UmamiIntegration,
peaNut: PeaNutIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -85,16 +85,6 @@ export abstract class Integration {
return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams);
}
protected webSocketUrl(
path: `/${string}`,
queryParams?: Record<string, string | Date | number | boolean | null | undefined>,
) {
const url = this.url(path, queryParams);
// http -> ws, https -> wss
url.protocol = url.protocol.replace("http", "ws");
return url;
}
public async testConnectionAsync(): Promise<TestingResult> {
try {
const url = new URL(this.integration.url);
-1
View File
@@ -34,7 +34,6 @@ export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -1,5 +0,0 @@
import type { UpsSummary } from "./ups-summary-types";
export interface IUpsSummaryIntegration {
getUpsSummariesAsync(): Promise<UpsSummary[]>;
}
@@ -1,32 +0,0 @@
/**
* Normalized UPS status, resolved from the raw NUT status flags by the integration so that
* widgets and future UPS integrations share a single, typed vocabulary.
*/
export const upsStatuses = ["online", "charging", "onBattery", "lowBattery", "unknown"] as const;
export type UpsStatus = (typeof upsStatuses)[number];
export interface UpsSummary {
/** Stable identifier of the UPS device on the NUT server */
id: string;
/** Human readable name, derived from the manufacturer and model */
name: string;
manufacturer: string | null;
model: string | null;
serial: string | null;
/** Normalized status, resolved from the raw NUT `ups.status` flags */
status: UpsStatus;
/** Battery charge in percent */
batteryCharge: number | null;
/** Estimated remaining runtime on battery, in seconds */
batteryRuntime: number | null;
batteryVoltage: number | null;
/** UPS load in percent */
load: number | null;
inputVoltage: number | null;
outputVoltage: number | null;
/** Real power drawn by the load, in watts */
power: number | null;
/** UPS temperature in degrees celsius */
temperature: number | null;
}
+69 -191
View File
@@ -1,130 +1,24 @@
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
interface EventTemplate {
title: string;
subTitle: string;
description: string;
dayOffset: number;
hours: number;
image: CalendarEvent["image"];
indicatorColor: string;
metadata?: CalendarEvent["metadata"];
location?: string;
links: CalendarEvent["links"];
export class CalendarMockService implements ICalendarIntegration {
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
const result = [homarrMeetup(start, end), titanicRelease(start, end), seriesRelease(start, end)];
return await Promise.resolve(result);
}
}
const eventTemplates: EventTemplate[] = [
{
title: "The Mandalorian",
subTitle: "Season 4",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
dayOffset: 0,
hours: 20,
image: {
src: "https://image.tmdb.org/t/p/w300/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S4/E3", color: "red" },
},
indicatorColor: "blue",
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Silo",
subTitle: "Season 2",
description: "In a ruined and toxic future, thousands live in a giant underground silo.",
dayOffset: 3,
hours: 21,
image: {
src: "https://image.tmdb.org/t/p/w300/2xSzAuKMhERTcPA19SpFQtVrMC0.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S2/E8", color: "teal" },
},
indicatorColor: "teal",
links: [
{
href: "https://www.imdb.com/title/tt14688458/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Dune: Part Two",
subTitle: "Digital Release",
description: "Paul Atreides unites with the Fremen to seek revenge against the conspirators.",
dayOffset: 5,
hours: 0,
image: {
src: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "digitalRelease" },
indicatorColor: "cyan",
links: [
{
href: "https://www.imdb.com/title/tt15239678/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Severance",
subTitle: "Season 2",
description: "Mark leads a team of office workers whose memories are surgically divided.",
dayOffset: 7,
hours: 22,
image: {
src: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S2/E5", color: "violet" },
},
indicatorColor: "violet",
links: [],
},
{
title: "Oppenheimer",
subTitle: "Physical Release",
description: "The story of the development of the atomic bomb.",
dayOffset: 10,
hours: 0,
image: {
src: "https://image.tmdb.org/t/p/w300/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "physicalRelease" },
indicatorColor: "orange",
links: [
{
href: "https://www.imdb.com/title/tt15398776/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
const startDate = randomDateBetween(start, end);
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
return {
title: "Homarr Meetup",
subTitle: "",
description: "Yearly meetup of the Homarr community",
dayOffset: 14,
hours: 14,
location: "Mountains",
startDate,
endDate,
image: null,
location: "Mountains",
indicatorColor: "#fa5252",
links: [
{
@@ -135,79 +29,63 @@ const eventTemplates: EventTemplate[] = [
isDark: true,
},
],
},
{
title: "The Bear",
subTitle: "Season 3",
description: "A young chef fights to transform a sandwich shop.",
dayOffset: 18,
hours: 3,
image: {
src: "https://image.tmdb.org/t/p/w300/sHFlYJhNbmpMOkr7JKGxFAnMPFm.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S3/E1", color: "yellow" },
},
indicatorColor: "yellow",
links: [],
},
{
title: "Foundation",
subTitle: "Season 3",
description: "A band of exiles discovers that the only way to save the Galactic Empire is to defy it.",
dayOffset: 21,
hours: 20,
image: {
src: "https://image.tmdb.org/t/p/w300/wSByZTwCPVYAjlqcVCBTCLZchkC.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S3/E1", color: "indigo" },
},
indicatorColor: "indigo",
links: [],
},
{
title: "Blade Runner 2099",
subTitle: "Premiere",
description: "A new chapter in the Blade Runner universe.",
dayOffset: 25,
hours: 21,
image: {
src: "https://image.tmdb.org/t/p/w300/63UMsIFOlCSz3uCPKtbgOdUr4pE.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "digitalRelease" },
indicatorColor: "grape",
links: [],
},
];
const buildEventsForRange = (start: Date, end: Date): CalendarEvent[] => {
const rangeStart = new Date(start);
rangeStart.setDate(rangeStart.getDate() - 3);
return eventTemplates
.map((tpl) => {
const startDate = new Date(rangeStart);
startDate.setDate(startDate.getDate() + tpl.dayOffset);
startDate.setHours(tpl.hours, 0, 0, 0);
return {
title: tpl.title,
subTitle: tpl.subTitle,
description: tpl.description,
startDate,
endDate: tpl.location ? new Date(startDate.getTime() + 2 * 60 * 60 * 1000) : null,
image: tpl.image,
location: tpl.location ?? null,
indicatorColor: tpl.indicatorColor,
links: tpl.links,
...(tpl.metadata ? { metadata: tpl.metadata } : {}),
};
})
.filter((event) => event.startDate >= start && event.startDate <= end);
};
};
export class CalendarMockService implements ICalendarIntegration {
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
return await Promise.resolve(buildEventsForRange(start, end));
}
const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
title: "Titanic",
subTitle: "A classic movie",
description: "A tragic love story set on the ill-fated RMS Titanic.",
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
aspectRatio: { width: 7, height: 12 },
},
location: null,
metadata: {
type: "radarr",
releaseType: "inCinemas",
},
indicatorColor: "cyan",
links: [
{
href: "https://www.imdb.com/title/tt0120338/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
});
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
title: "The Mandalorian",
subTitle: "A Star Wars Series",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
aspectRatio: { width: 7, height: 12 },
badge: {
content: "S1/E1",
color: "red",
},
},
location: null,
indicatorColor: "blue",
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
});
function randomDateBetween(start: Date, end: Date): Date {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
}
+29 -135
View File
@@ -2,163 +2,57 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
const mockItems: Omit<DownloadClientItem, "id" | "index">[] = [
{
name: "Big.Buck.Bunny.2008.1080p.BluRay.x264",
type: "torrent",
size: 4_700_000_000,
progress: 0.47,
state: "leeching",
downSpeed: 12_400_000,
upSpeed: 890_000,
sent: 420_000_000,
received: 2_209_000_000,
time: 201_000,
category: "movies",
},
{
name: "Ubuntu.24.04.1.LTS.Desktop.amd64.iso",
type: "torrent",
size: 5_800_000_000,
progress: 0.82,
state: "leeching",
downSpeed: 34_500_000,
upSpeed: 2_100_000,
sent: 1_200_000_000,
received: 4_756_000_000,
time: 30_000,
category: "iso",
},
{
name: "Sintel.2010.4K.DTS-HD.MA.5.1.mkv",
type: "torrent",
size: 18_200_000_000,
progress: 0.03,
state: "leeching",
downSpeed: 1_200_000,
upSpeed: 50_000,
sent: 30_000_000,
received: 546_000_000,
time: 14_880_000,
category: "movies",
},
{
name: "Arch.Linux.2024.12.01.x86_64.iso",
type: "torrent",
size: 1_100_000_000,
progress: 1,
state: "seeding",
downSpeed: 0,
upSpeed: 4_500_000,
sent: 3_300_000_000,
received: 1_100_000_000,
time: -7_200_000,
category: "iso",
},
{
name: "Tears.of.Steel.2012.1080p.DTS.mkv",
type: "torrent",
size: 7_340_000_000,
progress: 0,
state: "paused",
downSpeed: 0,
upSpeed: 0,
time: 0,
category: "movies",
},
{
name: "Cosmos.Laundromat.First.Cycle.2015.4K.mkv",
type: "torrent",
size: 12_000_000_000,
progress: 0.65,
state: "stalled",
downSpeed: 0,
upSpeed: 0,
sent: 200_000_000,
received: 7_800_000_000,
time: 0,
category: "movies",
},
{
name: "Spring.2019.4K.HDR.mkv",
type: "usenet",
size: 3_200_000_000,
progress: 0.91,
state: "downloading",
downSpeed: 45_000_000,
time: 6_500,
category: "movies",
},
{
name: "Agent.327.Operation.Barbershop.2017.1080p.mkv",
type: "usenet",
size: 1_800_000_000,
progress: 1,
state: "completed",
time: -3_600_000,
category: "movies",
},
{
name: "Caminandes.3.Llamigos.2016.1080p.mkv",
type: "torrent",
size: 890_000_000,
progress: 1,
state: "seeding",
downSpeed: 0,
upSpeed: 1_800_000,
sent: 2_670_000_000,
received: 890_000_000,
time: -86_400_000,
category: "movies",
},
{
name: "Hero.2024.S01.1080p.WEB-DL.DDP5.1",
type: "usenet",
size: 9_400_000_000,
progress: 0.22,
state: "downloading",
downSpeed: 28_000_000,
time: 244_000,
category: "tv",
},
];
export class DownloadClientMockService implements IDownloadClientIntegration {
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
return await Promise.resolve({
status: {
paused: false,
paused: Math.random() < 0.5,
rates: {
down: 121_100_000,
up: 9_340_000,
down: Math.floor(Math.random() * 5000),
up: Math.floor(Math.random() * 5000),
},
types: ["torrent", "usenet"] as const,
types: ["torrent", "usenet"],
},
items: mockItems.slice(0, input.limit).map((item, index) => ({
...item,
id: `mock-${index}`,
index,
})),
items: Array.from({ length: 20 }, (_, index) => DownloadClientMockService.createItem(index)).slice(
0,
input.limit,
),
});
}
public async pauseQueueAsync(): Promise<void> {
await Promise.resolve();
return await Promise.resolve();
}
public async pauseItemAsync(_item: DownloadClientItem): Promise<void> {
await Promise.resolve();
return await Promise.resolve();
}
public async resumeQueueAsync(): Promise<void> {
await Promise.resolve();
return Promise.resolve();
}
public async resumeItemAsync(_item: DownloadClientItem): Promise<void> {
await Promise.resolve();
return await Promise.resolve();
}
public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> {
await Promise.resolve();
return await Promise.resolve();
}
private static createItem(index: number): DownloadClientItem {
const progress = Math.random() < 0.5 ? Math.random() : 1;
return {
id: `item-${index}`,
index,
name: `Item ${index}`,
type: Math.random() > 0.5 ? "torrent" : "usenet",
progress,
size: Math.floor(Math.random() * 10000) + 1,
downSpeed: Math.floor(Math.random() * 5000),
upSpeed: Math.floor(Math.random() * 5000),
state: progress >= 1 ? "completed" : "downloading",
time: 0,
};
}
}
@@ -13,17 +13,17 @@ export const mockMediaReleases: MediaRelease[] = [
title: "Inception",
subtitle: "A mind-bending thriller",
description:
"A thief who steals corporate secrets through dream-sharing technology is given the task of planting an idea into the mind of a CEO.",
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.",
releaseDate: new Date("2010-07-16"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/ljsZTbVsrQSqZgWeep2B1QiDKuh.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/8ZTVqvKDQ8emSGUEMjsS4yHAwrp.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/inception_backdrop.jpg",
},
producer: "Warner Bros.",
price: 14.99,
rating: "8.8/10",
tags: ["Sci-Fi", "Thriller"],
href: "https://www.themoviedb.org/movie/27205-inception",
href: "https://example.com/inception",
length: 148,
},
{
@@ -34,13 +34,13 @@ export const mockMediaReleases: MediaRelease[] = [
description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
releaseDate: new Date("2013-09-15"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/ztkUQFLlC19CCMYHW73GM9hBNRh.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/breaking_bad_backdrop.jpg",
},
producer: "AMC",
rating: "9.5/10",
tags: ["Crime", "Drama"],
href: "https://www.themoviedb.org/tv/1396-breaking-bad",
href: "https://example.com/breaking_bad",
},
{
id: "3",
@@ -50,86 +50,79 @@ export const mockMediaReleases: MediaRelease[] = [
description: "The fourth studio album by French electronic music duo Daft Punk.",
releaseDate: new Date("2013-05-17"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/ram_backdrop.jpg",
},
producer: "Columbia Records",
price: 9.99,
rating: "8.5/10",
tags: ["Electronic", "Funk"],
href: "https://www.daftpunk.com",
tags: ["Electronic", "Dance", "Pop", "Funk"],
href: "https://example.com/ram",
},
{
id: "4",
type: "movie",
title: "The Dark Knight",
subtitle: "Theatrical Release",
description:
"Batman raises the stakes in his war on crime. With the help of Lt. Gordon and DA Harvey Dent, Batman sets out to dismantle organized crime.",
releaseDate: new Date("2008-07-18"),
type: "book",
title: "The Great Gatsby",
subtitle: "F. Scott Fitzgerald",
description: "A novel about the American dream and the disillusionment that comes with it.",
releaseDate: new Date("1925-04-10"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/qJ2tW6WMUDux911BTUgMe1nV8Is.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/nMKdUUepR0i5zn0y1T4CsSB5ez.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/gatsby_backdrop.jpg",
},
producer: "Warner Bros.",
price: 12.99,
rating: "9.0/10",
tags: ["Action", "Crime", "Drama"],
href: "https://www.themoviedb.org/movie/155-the-dark-knight",
length: 152,
producer: "Scribner",
price: 10.99,
rating: "4.2/5",
tags: ["Classic", "Fiction"],
href: "https://example.com/gatsby",
},
{
id: "5",
type: "movie",
title: "Interstellar",
subtitle: "Digital Release",
description: "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.",
releaseDate: new Date("2014-11-07"),
type: "game",
title: "The Legend of Zelda: Breath of the Wild",
subtitle: "Nintendo Switch",
description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
releaseDate: new Date("2017-03-03"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/xJHokMbljXjADYdit5fK1DVfjko.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/zelda_backdrop.jpg",
},
producer: "Paramount",
price: 14.99,
rating: "8.7/10",
tags: ["Sci-Fi", "Adventure"],
href: "https://www.themoviedb.org/movie/157336-interstellar",
length: 169,
producer: "Nintendo",
price: 59.99,
rating: "10/10",
tags: ["Action", "Adventure"],
href: "https://example.com/zelda",
},
{
id: "6",
type: "tv",
title: "Severance",
subtitle: "S2E1 - Premiere",
description:
"Mark leads a team of office workers whose memories are surgically divided between work and personal lives.",
releaseDate: new Date("2025-01-17"),
type: "article",
title: "The Rise of AI in Healthcare",
subtitle: "Tech Innovations",
description: "Exploring the impact of artificial intelligence on the healthcare industry.",
releaseDate: new Date("2023-10-01"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/gvaAaDUbF41Z1NLuJsHPRHkv1Ie.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
},
producer: "Apple TV+",
rating: "8.7/10",
tags: ["Thriller", "Sci-Fi"],
href: "https://www.themoviedb.org/tv/95396-severance",
producer: "Tech Innovations",
rating: "4.8/5",
tags: ["Technology", "Healthcare"],
href: "https://example.com/ai_healthcare",
},
{
id: "7",
type: "movie",
title: "Dune: Part Two",
subtitle: "Theatrical Release",
description:
"Paul Atreides unites with the Fremen to seek revenge against the conspirators who destroyed his family.",
releaseDate: new Date("2024-03-01"),
type: "video",
title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
releaseDate: new Date("2024-05-18T17:00:00Z"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
poster:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
backdrop:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
},
producer: "Warner Bros.",
price: 19.99,
rating: "8.5/10",
tags: ["Sci-Fi", "Adventure"],
href: "https://www.themoviedb.org/movie/693134-dune-part-two",
length: 166,
producer: "PietSmiet",
rating: "1K",
tags: [],
href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
},
];
@@ -1,95 +1,36 @@
import type { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types";
const sessions: StreamSession[] = [
{
sessionId: "s-0",
sessionName: "Living Room TV",
user: { userId: "u-0", username: "thomas", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "Interstellar",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-1",
sessionName: "Bedroom Chromecast",
user: { userId: "u-1", username: "sarah", profilePictureUrl: null },
currentlyPlaying: {
type: "video",
name: "Breaking Bad",
seasonName: "Season 5",
episodeName: "Ozymandias",
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-2",
sessionName: "Office Desktop",
user: { userId: "u-2", username: "mike", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "The Dark Knight",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-3",
sessionName: "iPad",
user: { userId: "u-3", username: "emily", profilePictureUrl: null },
currentlyPlaying: {
type: "video",
name: "Severance",
seasonName: "Season 2",
episodeName: "The We We Are",
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-4",
sessionName: "Mobile",
user: { userId: "u-4", username: "alex", profilePictureUrl: null },
currentlyPlaying: {
type: "audio",
name: "Daft Punk",
seasonName: undefined,
episodeName: null,
albumName: "Random Access Memories",
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-5",
sessionName: "Guest Room",
user: { userId: "u-5", username: "guest", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "Oppenheimer",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
];
export class MediaServerMockService implements IMediaServerIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
return await Promise.resolve(sessions);
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
return await Promise.resolve(
Array.from({ length: 10 }, (_, index) => MediaServerMockService.createSession(index)).filter(
(session) => !options.showOnlyPlaying || session.currentlyPlaying !== null,
),
);
}
private static createSession(index: number): StreamSession {
return {
sessionId: `session-${index}`,
sessionName: `Session ${index}`,
user: {
userId: `user-${index}`,
username: `User${index}`,
profilePictureUrl: "/images/mock/avatar.jpg",
},
currentlyPlaying:
Math.random() > 0.9 // 10% chance of being null (not currently playing)
? {
type: "movie",
name: `Movie ${index}`,
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
}
: null,
};
}
}
@@ -4,11 +4,27 @@ import type { NetworkControllerSummary } from "../../types";
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
return await Promise.resolve({
lan: { guests: 5, users: 18, status: "enabled" },
vpn: { users: 3, status: "enabled" },
wanStatus: "enabled",
wifi: { status: "enabled", guests: 2, users: 12 },
www: { latency: 22, status: "enabled", ping: 14, uptime: 864000 },
lan: {
guests: 5,
users: 10,
status: "enabled",
},
vpn: {
users: 3,
status: "enabled",
},
wanStatus: "disabled",
wifi: {
status: "disabled",
guests: 0,
users: 0,
},
www: {
latency: 22,
status: "enabled",
ping: 32,
uptime: 3600,
},
});
}
}
@@ -1,41 +0,0 @@
import type { IUpsSummaryIntegration } from "../../interfaces/ups-summary/ups-summary-integration";
import type { UpsSummary } from "../../types";
export class UpsSummaryMockService implements IUpsSummaryIntegration {
public async getUpsSummariesAsync(): Promise<UpsSummary[]> {
return await Promise.resolve([
{
id: "ups-rack-1",
name: "CyberPower OR1500",
manufacturer: "CyberPower",
model: "OR1500LCDRM1U",
serial: "CPS1500-001",
status: "online",
batteryCharge: 100,
batteryRuntime: 2280,
batteryVoltage: 27.3,
load: 42,
inputVoltage: 230,
outputVoltage: 230,
power: 315,
temperature: 29,
},
{
id: "ups-rack-2",
name: "APC Back-UPS Pro",
manufacturer: "APC",
model: "BR1500G",
serial: "APC1500-002",
status: "onBattery",
batteryCharge: 64,
batteryRuntime: 840,
batteryVoltage: 25.1,
load: 58,
inputVoltage: 0,
outputVoltage: 229,
power: 410,
temperature: 31,
},
]);
}
}
@@ -15,7 +15,6 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
import type { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
import { CalendarMockService } from "./data/calendar";
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
import { DnsHoleMockService } from "./data/dns-hole";
@@ -29,7 +28,6 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
import { NotificationsMockService } from "./data/notifications";
import { SmartHomeMockService } from "./data/smart-home";
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
import { UpsSummaryMockService } from "./data/ups-summary";
export class MockIntegration
extends Integration
@@ -45,8 +43,7 @@ export class MockIntegration
IMediaServerIntegration,
IMediaTranscodingIntegration,
NetworkControllerSummaryIntegration,
ISmartHomeIntegration,
IUpsSummaryIntegration
ISmartHomeIntegration
{
private static readonly dnsHole = new DnsHoleMockService();
private static readonly calendar = new CalendarMockService();
@@ -61,7 +58,6 @@ export class MockIntegration
private static readonly networkController = new NetworkControllerSummaryMockService();
private static readonly notifications = new NotificationsMockService();
private static readonly smartHome = new SmartHomeMockService();
private static readonly upsSummary = new UpsSummaryMockService();
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
return await Promise.resolve({
@@ -89,7 +85,7 @@ export class MockIntegration
// Health Monitoring Integrations
getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring);
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.clusterMonitoring);
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.downloadClient);
// IndexerManagerIntegration
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
@@ -108,7 +104,7 @@ export class MockIntegration
declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest);
// MediaServerIntegration
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaServer);
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaRequest);
// MediaTranscodingIntegration
getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding);
@@ -127,7 +123,4 @@ export class MockIntegration
getEntityStateAsync = MockIntegration.smartHome.getEntityStateAsync.bind(MockIntegration.smartHome);
triggerAutomationAsync = MockIntegration.smartHome.triggerAutomationAsync.bind(MockIntegration.smartHome);
triggerToggleAsync = MockIntegration.smartHome.triggerToggleAsync.bind(MockIntegration.smartHome);
// UpsSummaryIntegration
getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
}
@@ -1,103 +0,0 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
import type { UpsStatus, UpsSummary } from "../interfaces/ups-summary/ups-summary-types";
import type { PeaNutDevice } from "./peanut-types";
import { peaNutDevicesSchema } from "./peanut-types";
export class PeaNutIntegration extends Integration implements IUpsSummaryIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v1/devices"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) return TestConnectionError.StatusResult(response);
await response.json();
return { success: true };
}
public async getUpsSummariesAsync(): Promise<UpsSummary[]> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/devices", { meta: true }), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
const devices = peaNutDevicesSchema.parse(await response.json());
return devices.map((device, index) => this.mapDeviceToSummary(device, index));
}
private mapDeviceToSummary(device: PeaNutDevice, index: number): UpsSummary {
const manufacturer = this.readString(device, "device.mfr") ?? this.readString(device, "ups.mfr");
const model = this.readString(device, "device.model") ?? this.readString(device, "ups.model");
const id = this.readString(device, "peanut.device_id") ?? `ups-${index}`;
const name = [manufacturer, model].filter(Boolean).join(" ") || id;
return {
id,
name,
manufacturer,
model,
serial: this.readString(device, "device.serial"),
status: this.parseStatus(this.readString(device, "ups.status")),
batteryCharge: this.readNumber(device, "battery.charge"),
batteryRuntime: this.readNumber(device, "battery.runtime"),
batteryVoltage: this.readNumber(device, "battery.voltage"),
load: this.readNumber(device, "ups.load"),
inputVoltage: this.readNumber(device, "input.voltage"),
outputVoltage: this.readNumber(device, "output.voltage"),
power: this.readNumber(device, "ups.realpower") ?? this.readNumber(device, "ups.power"),
temperature: this.readNumber(device, "ups.temperature"),
};
}
/**
* Resolves the raw NUT `ups.status` flags (e.g. "OL CHRG", "OB DISCHRG LB") into a single
* normalized status. The flags are checked in priority order so the most critical state wins.
*/
private parseStatus(rawStatus: string | null): UpsStatus {
const flags = (rawStatus ?? "").toUpperCase().split(/\s+/).filter(Boolean);
if (flags.includes("LB")) return "lowBattery";
if (flags.includes("OB")) return "onBattery";
if (flags.includes("OL")) {
return flags.includes("CHRG") ? "charging" : "online";
}
return "unknown";
}
private readString(device: PeaNutDevice, key: string): string | null {
const value = device[key];
if (value === undefined || value === null) return null;
const stringValue = String(value).trim();
return stringValue.length > 0 ? stringValue : null;
}
private readNumber(device: PeaNutDevice, key: string): number | null {
const value = device[key];
if (value === undefined || value === null || value === "") return null;
const numberValue = typeof value === "number" ? value : Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
private getAuthHeaders(): Record<string, string> {
if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
"base64",
);
return { Authorization: `Basic ${credentials}` };
}
return {};
}
}
@@ -1,10 +0,0 @@
import { z } from "zod/v4";
// PeaNUT returns each device as a flat map of NUT variables (e.g. "ups.status",
// "battery.charge"). Values are either strings or numbers depending on the
// variable, so we accept both and coerce them when mapping to a UpsSummary.
export const peaNutDeviceSchema = z.record(z.string(), z.union([z.string(), z.number()]));
export const peaNutDevicesSchema = z.array(peaNutDeviceSchema);
export type PeaNutDevice = z.infer<typeof peaNutDeviceSchema>;
@@ -1,166 +0,0 @@
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types";
import { PeaNutIntegration } from "../peanut-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://peanut.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const mockDevicesResponse = (devices: Record<string, string | number>[]) => {
mockFetch.mockImplementation(
() =>
Promise.resolve(
new Response(JSON.stringify(devices), {
status: 200,
headers: { "content-type": "application/json" },
}),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>,
);
};
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
new PeaNutIntegration({
id: "test-peanut",
name: "Test PeaNUT",
url: TEST_URL,
externalUrl: null,
decryptedSecrets,
});
beforeEach(() => {
mockFetch.mockReset();
});
describe("PeaNutIntegration getUpsSummariesAsync", () => {
test("maps NUT variables to a UPS summary and coerces string values to numbers", async () => {
mockDevicesResponse([
{
"device.mfr": "CyberPower",
"device.model": "CP1500EPFCLCD",
"device.serial": "ABC123",
"ups.status": "OL CHRG",
"ups.load": "23",
"ups.realpower": "120",
"ups.temperature": "31.5",
"battery.charge": "100",
"battery.runtime": "3720",
"battery.voltage": "27.3",
"input.voltage": "230",
"output.voltage": "229",
"peanut.device_id": "ups",
"peanut.server": "localhost:3493",
},
]);
const [summary] = await createIntegration().getUpsSummariesAsync();
expect(summary).toStrictEqual({
id: "ups",
name: "CyberPower CP1500EPFCLCD",
manufacturer: "CyberPower",
model: "CP1500EPFCLCD",
serial: "ABC123",
status: "charging",
batteryCharge: 100,
batteryRuntime: 3720,
batteryVoltage: 27.3,
load: 23,
inputVoltage: 230,
outputVoltage: 229,
power: 120,
temperature: 31.5,
});
});
test("falls back when optional variables are missing", async () => {
mockDevicesResponse([
{
"ups.status": "OB DISCHRG LB",
"ups.power": "90",
"battery.charge": "15",
},
]);
const [summary] = await createIntegration().getUpsSummariesAsync();
expect(summary).toMatchObject({
id: "ups-0",
name: "ups-0",
manufacturer: null,
model: null,
status: "lowBattery",
batteryCharge: 15,
batteryRuntime: null,
load: null,
inputVoltage: null,
outputVoltage: null,
power: 90,
temperature: null,
});
});
test("returns a summary per device", async () => {
mockDevicesResponse([
{ "peanut.device_id": "ups-a", "ups.status": "OL" },
{ "peanut.device_id": "ups-b", "ups.status": "OB" },
]);
const summaries = await createIntegration().getUpsSummariesAsync();
expect(summaries.map((summary) => summary.id)).toStrictEqual(["ups-a", "ups-b"]);
});
test("normalizes NUT status flags into a typed status", async () => {
mockDevicesResponse([
{ "ups.status": "OL" },
{ "ups.status": "OL CHRG" },
{ "ups.status": "OB DISCHRG" },
{ "ups.status": "OB DISCHRG LB" },
{ "ups.status": "OL LB" },
{ "ups.status": "" },
]);
const summaries = await createIntegration().getUpsSummariesAsync();
expect(summaries.map((summary) => summary.status)).toStrictEqual([
"online",
"charging",
"onBattery",
"lowBattery",
"lowBattery",
"unknown",
]);
});
});
describe("PeaNutIntegration authentication", () => {
test("sends a Basic auth header when username and password are configured", async () => {
mockDevicesResponse([]);
await createIntegration([
{ kind: "username", value: "admin" },
{ kind: "password", value: "secret" },
]).getUpsSummariesAsync();
const expected = `Basic ${Buffer.from("admin:secret").toString("base64")}`;
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
});
test("omits the auth header when no credentials are configured", async () => {
mockDevicesResponse([]);
await createIntegration().getUpsSummariesAsync();
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
expect(requestInit?.headers).toStrictEqual({});
});
});
@@ -1,79 +0,0 @@
import { describe, expect, test } from "vitest";
import { trueNasApis } from "../truenas-api";
describe("trueNasApis descriptors", () => {
test("expose the expected endpoints and handshake requirements", () => {
expect(trueNasApis.jsonRpc).toMatchObject({
kind: "jsonRpc",
path: "/api/current",
requiresSessionHandshake: false,
});
expect(trueNasApis.legacy).toMatchObject({
kind: "legacy",
path: "/websocket",
requiresSessionHandshake: true,
});
});
});
describe("jsonRpc api", () => {
const { buildRequest, matchResponse } = trueNasApis.jsonRpc;
test("builds a JSON-RPC 2.0 envelope", () => {
expect(buildRequest("id-1", "system.info", [])).toStrictEqual({
jsonrpc: "2.0",
id: "id-1",
method: "system.info",
params: [],
});
});
test("matches a result for the requested id", () => {
const result = matchResponse({ jsonrpc: "2.0", id: "id-1", result: true }, "id-1");
expect(result).toStrictEqual({ result: true });
});
test("returns the error when the response carries one", () => {
const error = { code: -32001, message: "Not authenticated" };
expect(matchResponse({ jsonrpc: "2.0", id: "id-1", error }, "id-1")).toStrictEqual({ error });
});
test("ignores responses for a different id", () => {
expect(matchResponse({ jsonrpc: "2.0", id: "other", result: true }, "id-1")).toBeUndefined();
});
test("ignores non JSON-RPC messages", () => {
expect(matchResponse({ msg: "result", id: "id-1", result: true }, "id-1")).toBeUndefined();
});
});
describe("legacy api", () => {
const { buildRequest, matchResponse } = trueNasApis.legacy;
test("builds a DDP method envelope", () => {
expect(buildRequest("id-1", "auth.login", ["user", "pass"])).toStrictEqual({
id: "id-1",
msg: "method",
method: "auth.login",
params: ["user", "pass"],
});
});
test("matches a result message for the requested id", () => {
expect(matchResponse({ msg: "result", id: "id-1", result: false }, "id-1")).toStrictEqual({ result: false });
});
test("returns the error when the result message carries one", () => {
const error = { error: 13, reason: "boom" };
expect(matchResponse({ msg: "result", id: "id-1", error }, "id-1")).toStrictEqual({ error });
});
test("ignores responses for a different id", () => {
expect(matchResponse({ msg: "result", id: "other", result: true }, "id-1")).toBeUndefined();
});
test("ignores non-result messages such as the session handshake", () => {
expect(matchResponse({ msg: "connected", session: "abc" }, "id-1")).toBeUndefined();
});
});
@@ -1,264 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { IntegrationRequestError } from "../../base/errors/http/integration-request-error";
import type { IntegrationSecret } from "../../base/types";
const ws = vi.hoisted(() => {
const sentPayloads: Record<string, unknown>[] = [];
const constructedUrls: string[] = [];
// A failure with a `code` emits a coded TLS-style error; without one it mimics a missing endpoint (HTTP 404).
const failures: { path: string; code?: string }[] = [];
let responder: (method: string, params: unknown[]) => unknown = () => undefined;
type Listener = (...args: unknown[]) => void;
class FakeWebSocket {
static readonly OPEN = 1;
public readyState = FakeWebSocket.OPEN;
private readonly listeners: Record<string, Listener[]> = {};
constructor(url: URL | string) {
constructedUrls.push(url.toString());
setTimeout(() => {
const failure = failures.find((candidate) => url.toString().includes(candidate.path));
if (failure) {
const error: Error & { code?: string } = new Error(failure.code ?? "Unexpected server response: 404");
if (failure.code) error.code = failure.code;
this.dispatch("error", error);
} else {
this.dispatch("open");
}
}, 0);
}
on(event: string, listener: Listener) {
(this.listeners[event] ??= []).push(listener);
return this;
}
once(event: string, listener: Listener) {
const wrapper: Listener = (...args) => {
this.off(event, wrapper);
listener(...args);
};
return this.on(event, wrapper);
}
off(event: string, listener: Listener) {
this.listeners[event] = (this.listeners[event] ?? []).filter((candidate) => candidate !== listener);
return this;
}
send(raw: string) {
const payload = JSON.parse(raw) as Record<string, unknown>;
sentPayloads.push(payload);
if (payload.msg === "connect") {
this.respond(JSON.stringify({ msg: "connected", session: "test-session" }));
return;
}
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
const envelope =
payload.jsonrpc === "2.0"
? { jsonrpc: "2.0", id: payload.id, result }
: { msg: "result", id: payload.id, result };
this.respond(JSON.stringify(envelope));
}
close() {
this.readyState = 3;
}
private respond(message: string) {
setTimeout(() => this.dispatch("message", message), 0);
}
private dispatch(event: string, ...args: unknown[]) {
// `off` replaces the array rather than mutating it, so iterating it directly is safe.
for (const listener of this.listeners[event] ?? []) listener(...args);
}
}
return {
FakeWebSocket,
sentPayloads,
constructedUrls,
failures,
setResponder: (fn: (method: string, params: unknown[]) => unknown) => {
responder = fn;
},
};
});
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
createCustomCheckServerIdentity: () => () => undefined,
}));
import { TrueNasIntegration } from "../truenas-integration";
const happyResponder = (method: string) => {
switch (method) {
case "auth.login":
case "auth.login_with_api_key":
return true;
case "system.info":
return {
version: "TrueNAS-SCALE-24.10",
hostname: "nas",
physmem: 16_000_000_000,
model: "Intel Xeon",
uptime_seconds: 3600,
};
case "reporting.get_data":
return [
{
name: "cpu",
identifier: "cpu",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 12]],
},
{
name: "memory",
identifier: "memory",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 8_000_000_000]],
},
{
name: "cputemp",
identifier: "cputemp",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 45]],
},
];
case "pool.query":
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
case "interface.query":
return [{ id: "eth0", name: "eth0" }];
case "reporting.netdata_get_data":
return [{ name: "interface", identifier: "eth0", data: [[1000, 100, 200]] }];
default:
return null;
}
};
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
let integrationCounter = 0;
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
new TrueNasIntegration({
id: `truenas-${(integrationCounter += 1)}`,
name: "TrueNAS Test",
url: "https://truenas.local",
externalUrl: null,
decryptedSecrets,
});
const credentials: IntegrationSecret[] = [
{ kind: "username", value: "root" },
{ kind: "password", value: "secret" },
];
describe("TrueNasIntegration", () => {
beforeEach(() => {
ws.sentPayloads.length = 0;
ws.constructedUrls.length = 0;
ws.failures.length = 0;
ws.setResponder(happyResponder);
});
test("fetches and maps system info over the JSON-RPC API", async () => {
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
// JSON-RPC does not use the legacy "connect" session handshake.
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
expect(result).toMatchObject({
version: "TrueNAS-SCALE-24.10",
cpuModelName: "Intel Xeon",
cpuTemp: 45,
memAvailableInBytes: 16_000_000_000,
memUsedInBytes: 8_000_000_000,
uptime: 3600,
rebootRequired: false,
availablePkgUpdates: 0,
loadAverage: null,
gpu: [],
network: { up: 20_000, down: 10_000 },
fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
});
});
test("authenticates with an API key when one is configured", async () => {
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
await integration.getSystemInfoAsync();
const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
expect(authPayload).toMatchObject({ method: "auth.login_with_api_key", params: ["api-key-123"] });
});
test("authenticates with username and password when no API key is configured", async () => {
const integration = createIntegration(credentials);
await integration.getSystemInfoAsync();
const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
expect(authPayload).toMatchObject({ method: "auth.login", params: ["root", "secret"] });
});
test("falls back to the legacy websocket API when the JSON-RPC endpoint is unavailable", async () => {
ws.failures.push({ path: "/api/current" });
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
expect(ws.constructedUrls).toContain("wss://truenas.local/websocket");
// The legacy API requires the "connect" session handshake before authenticating.
expect(ws.sentPayloads[0]).toMatchObject({ msg: "connect" });
expect(ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."))).toMatchObject({
msg: "method",
method: "auth.login",
});
expect(result.version).toBe("TrueNAS-SCALE-24.10");
});
test("rejects when authentication is not successful", async () => {
ws.setResponder((method) => (method.startsWith("auth.") ? false : happyResponder(method)));
const integration = createIntegration(credentials);
await expect(integration.getSystemInfoAsync()).rejects.toThrow();
});
test("surfaces a self-signed certificate as a certificate request error without falling back", async () => {
ws.failures.push({ path: "/api/current", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
ws.failures.push({ path: "/websocket", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
const integration = createIntegration(credentials);
const error = await integration.getSystemInfoAsync().catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(IntegrationRequestError);
expect((error as IntegrationRequestError).cause).toMatchObject({ type: "certificate", reason: "untrusted" });
// A certificate failure is endpoint-independent, so the legacy API must not be tried.
expect(ws.constructedUrls).not.toContain("wss://truenas.local/websocket");
});
});
@@ -1,50 +0,0 @@
export type TrueNasApiKind = "jsonRpc" | "legacy";
export type TrueNasResponse = { result: unknown } | { error: unknown };
export interface TrueNasApi {
kind: TrueNasApiKind;
/** Websocket path appended to the integration URL. */
path: `/${string}`;
/** The legacy DDP API requires a "connect" handshake before authentication, the JSON-RPC API does not. */
requiresSessionHandshake: boolean;
/** Builds the request envelope for a method call. */
buildRequest: (id: string, method: string, params: unknown[]) => Record<string, unknown>;
/**
* Returns the response payload when `message` is the reply to `id`,
* otherwise `undefined` so the caller keeps waiting for a later message.
*/
matchResponse: (message: Record<string, unknown>, id: string) => TrueNasResponse | undefined;
}
/**
* JSON-RPC 2.0 API introduced in TrueNAS SCALE 24.10 (Electric Eel) and the
* legacy DDP-style websocket API used before it. Both expose the same
* middleware methods, so only the transport envelope differs.
* @see https://www.truenas.com/docs/api/scale_rest_api.html
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
export const trueNasApis = {
jsonRpc: {
kind: "jsonRpc",
path: "/api/current",
requiresSessionHandshake: false,
buildRequest: (id: string, method: string, params: unknown[]) => ({ jsonrpc: "2.0", id, method, params }),
matchResponse: (message: Record<string, unknown>, id: string) => {
if (message.id !== id || message.jsonrpc !== "2.0") return undefined;
if (message.error != null) return { error: message.error };
return { result: message.result };
},
},
legacy: {
kind: "legacy",
path: "/websocket",
requiresSessionHandshake: true,
buildRequest: (id: string, method: string, params: unknown[]) => ({ id, msg: "method", method, params }),
matchResponse: (message: Record<string, unknown>, id: string) => {
if (message.id !== id || message.msg !== "result") return undefined;
if (message.error != null) return { error: message.error };
return { result: message.result };
},
},
} satisfies Record<TrueNasApiKind, TrueNasApi>;
@@ -1,315 +0,0 @@
import type { RawData } from "ws";
import { WebSocket } from "ws";
import z from "zod";
import { createId } from "@homarr/common";
import { matchErrorCode, RequestError, ResponseError } from "@homarr/common/server";
import {
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/core/infrastructure/certificates";
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import type { TrueNasApi } from "./truenas-api";
import { trueNasApis } from "./truenas-api";
const logger = createLogger({ module: "trueNasClient" });
const REQUEST_TIMEOUT_MS = 5000;
type CertificateOptions = IntegrationTestingInput["options"];
export type TrueNasCredentials = { apiKey: string } | { username: string; password: string };
/**
* Handles talking to a TrueNAS server: opening a websocket with Homarr's
* trusted certificates, negotiating the API variant, authenticating, and
* sending method requests. Connections are reused across the per-request
* integration instances and reconnected transparently when the server drops
* an idle socket.
*/
export class TrueNasClient {
// Keyed by integration id. The value is the in-flight connection promise so concurrent
// callers reuse a single socket instead of racing to open duplicates.
private static readonly connectionMap = new Map<string, Promise<TrueNasSocket>>();
private static readonly apiMap = new Map<string, TrueNasApi>();
constructor(
private readonly id: string,
private readonly webSocketUrl: (path: `/${string}`) => URL,
private readonly credentials: TrueNasCredentials,
) {}
/** Sends an authenticated request, reusing the cached connection while it is open. */
public async requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
const socket = await this.getSocketAsync();
return socket.requestAsync(method, params);
}
/**
* Opens a connection with the provided certificate options, authenticates,
* and closes it. Used by the connection test, which may run with updated
* credentials, so any cached connection is dropped and the API re-detected.
*/
public async testAsync(certificate: CertificateOptions): Promise<void> {
this.evictConnection();
TrueNasClient.apiMap.delete(this.id);
const socket = await this.openSocketAsync(certificate);
try {
await this.authenticateAsync(socket);
} finally {
socket.close();
}
}
private getSocketAsync(): Promise<TrueNasSocket> {
let connection = TrueNasClient.connectionMap.get(this.id);
if (!connection) {
connection = this.connectAndAuthenticateAsync();
TrueNasClient.connectionMap.set(this.id, connection);
const evict = () => {
if (TrueNasClient.connectionMap.get(this.id) === connection) {
TrueNasClient.connectionMap.delete(this.id);
}
};
// Reconnect on the next request once this attempt fails or the server drops the socket.
connection.then((socket) => socket.onClose(evict), evict);
}
return connection;
}
private async connectAndAuthenticateAsync(): Promise<TrueNasSocket> {
const socket = await this.openSocketAsync(await resolveCertificateOptionsAsync());
try {
await this.authenticateAsync(socket);
} catch (error) {
// Never keep an unauthenticated socket, it would be reused without re-authenticating.
socket.close();
throw error;
}
return socket;
}
private evictConnection() {
const connection = TrueNasClient.connectionMap.get(this.id);
if (!connection) return;
TrueNasClient.connectionMap.delete(this.id);
connection.then((socket) => socket.close()).catch(() => undefined);
}
/**
* Connects using the JSON-RPC API (TrueNAS 24.10+) when available and falls
* back to the legacy websocket API otherwise. A missing JSON-RPC endpoint
* surfaces as a plain protocol error; certificate, connection, DNS and
* timeout errors are endpoint-independent and are never retried.
*/
private async openSocketAsync(certificate: CertificateOptions): Promise<TrueNasSocket> {
const cachedApi = TrueNasClient.apiMap.get(this.id);
if (cachedApi) {
return openTrueNasSocketAsync(this.webSocketUrl(cachedApi.path), cachedApi, certificate);
}
try {
const socket = await openTrueNasSocketAsync(
this.webSocketUrl(trueNasApis.jsonRpc.path),
trueNasApis.jsonRpc,
certificate,
);
TrueNasClient.apiMap.set(this.id, trueNasApis.jsonRpc);
return socket;
} catch (error) {
if (error instanceof RequestError) throw error;
logger.debug("JSON-RPC API unavailable, falling back to the legacy websocket API", {
error: error instanceof Error ? error.message : undefined,
});
const socket = await openTrueNasSocketAsync(
this.webSocketUrl(trueNasApis.legacy.path),
trueNasApis.legacy,
certificate,
);
TrueNasClient.apiMap.set(this.id, trueNasApis.legacy);
return socket;
}
}
/**
* Authenticates with an API key when one is configured, otherwise with the
* username and password. Both methods work on either API.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async authenticateAsync(socket: TrueNasSocket): Promise<void> {
const response =
"apiKey" in this.credentials
? await socket.requestAsync("auth.login_with_api_key", [this.credentials.apiKey])
: await socket.requestAsync("auth.login", [this.credentials.username, this.credentials.password]);
const success = await z.boolean().parseAsync(response);
if (!success) throw new ResponseError({ status: 401 });
}
}
/**
* Wraps a single websocket connection and speaks whichever TrueNAS API
* ({@link trueNasApis}) was negotiated when it was opened.
*/
class TrueNasSocket {
constructor(
private readonly webSocket: WebSocket,
private readonly api: TrueNasApi,
) {}
public close() {
this.webSocket.close();
}
public onClose(listener: () => void) {
this.webSocket.once("close", listener);
}
/**
* The legacy DDP API requires a "connect" handshake to obtain a session
* before any method can be called.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
public registerSessionAsync(): Promise<void> {
return new Promise((resolve, reject) => {
const handler = (raw: RawData) => {
const message = parseMessage(raw);
if (message?.msg === "connected") {
this.webSocket.off("message", handler);
resolve();
} else if (message?.msg === "failed") {
this.webSocket.off("message", handler);
reject(new Error("Unable to establish TrueNAS session"));
}
};
this.webSocket.on("message", handler);
this.webSocket.send(JSON.stringify({ msg: "connect", version: "1", support: ["1"] }));
});
}
/**
* Sends a method request and resolves with its result. Rejects after
* {@link REQUEST_TIMEOUT_MS} when no matching response was received.
*/
public requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
return new Promise((resolve, reject) => {
const id = createId();
const cleanup = () => {
clearTimeout(timeoutId);
this.webSocket.off("message", handler);
this.webSocket.off("close", onClose);
this.webSocket.off("error", onError);
};
const handler = (raw: RawData) => {
const message = parseMessage(raw);
if (!message) return;
const response = this.api.matchResponse(message, id);
if (!response) return;
cleanup();
if ("error" in response) {
reject(new Error(`TrueNAS method "${method}" failed: ${JSON.stringify(response.error)}`));
return;
}
logger.debug("Received method response", { id, method, api: this.api.kind });
resolve(response.result);
};
// A dropped connection would otherwise leave the request hanging until the timeout below.
const onClose = () => {
cleanup();
reject(
new RequestError(
{ type: "connection", reason: "reset", code: "ECONNRESET" },
{ cause: new Error("Connection closed before a response was received") },
),
);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const timeoutId = setTimeout(() => {
cleanup();
reject(
new RequestError(
{
type: "timeout",
reason: "aborted",
code: "ECONNABORTED",
},
{ cause: new Error("Canceled request after 5 seconds") },
),
);
}, REQUEST_TIMEOUT_MS);
this.webSocket.on("message", handler);
this.webSocket.once("close", onClose);
this.webSocket.once("error", onError);
logger.debug("Sending method request", { id, method, api: this.api.kind });
this.webSocket.send(JSON.stringify(this.api.buildRequest(id, method, params)));
});
}
}
/**
* Opens a websocket using Homarr's trusted certificates and performs the
* session handshake when the API requires it. TLS, connection, DNS and timeout
* failures are translated into {@link RequestError}s so the connection test can
* offer to trust a self-signed certificate, exactly like the HTTP integrations.
*/
const openTrueNasSocketAsync = async (
url: URL,
api: TrueNasApi,
certificate: CertificateOptions,
): Promise<TrueNasSocket> => {
logger.debug("Connecting to TrueNAS websocket", { url: url.toString(), api: api.kind });
const webSocket = await new Promise<WebSocket>((resolve, reject) => {
const socket = new WebSocket(url, {
ca: certificate.ca,
// The @types/ws contract types this as returning a boolean, but ws forwards it to
// tls.connect which uses Node's `Error | undefined` contract that our helper follows.
checkServerIdentity: certificate.checkServerIdentity as never,
});
socket.once("open", () => resolve(socket));
socket.once("error", (error: Error & { code?: string }) => {
const matched = error.code ? matchErrorCode(error.code) : undefined;
reject(matched ? new RequestError(matched, { cause: error }) : error);
});
});
const socket = new TrueNasSocket(webSocket, api);
if (api.requiresSessionHandshake) await socket.registerSessionAsync();
return socket;
};
const resolveCertificateOptionsAsync = async (): Promise<CertificateOptions> => ({
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
});
const parseMessage = (raw: RawData): Record<string, unknown> | undefined => {
try {
const parsed: unknown = JSON.parse(raw.toString());
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
};
@@ -1,6 +1,8 @@
import dayjs from "dayjs";
import z from "zod";
import { createId } from "@homarr/common";
import { RequestError, ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
@@ -8,21 +10,252 @@ import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
import type { TrueNasCredentials } from "./truenas-client";
import { TrueNasClient } from "./truenas-client";
const logger = createLogger({ module: "trueNasIntegration" });
const NETWORK_MULTIPLIER = 100;
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private client?: TrueNasClient;
private static webSocketMap = new Map<string, WebSocket>();
private wsUrl() {
const url = super.url("/websocket");
url.protocol = url.protocol.replace("http", "ws");
return url;
}
private get webSocket() {
return TrueNasIntegration.webSocketMap.get(this.integration.id) ?? null;
}
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
const webSocket = await this.connectWebSocketAsync();
await this.registerSessionAsync(webSocket);
await this.authenticateWebSocketAsync(webSocket);
// Remove current socket connection so we can authenticate with updated credentials
TrueNasIntegration.webSocketMap.delete(this.integration.id);
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await this.getClient().testAsync(input.options);
return { success: true };
}
/**
* TrueNAS API uses WebSocket. This function connects to the socket
* and resolves the promise if the connection was successful.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html
*/
private async connectWebSocketAsync(): Promise<WebSocket> {
logger.debug("Connecting to websocket server", {
url: this.wsUrl(),
});
const webSocket = new WebSocket(this.wsUrl());
return new Promise((resolve, reject) => {
webSocket.onopen = () => {
logger.debug("Connected to websocket server", {
url: this.wsUrl(),
});
resolve(webSocket);
};
webSocket.onerror = () => {
reject(new Error("Failed to connect"));
};
});
}
/**
* Before authentication, a session must be obtained from the server using the "connect" event.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async registerSessionAsync(webSocket: WebSocket): Promise<void> {
return new Promise((resolve, reject) => {
const subscribe = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as { msg: string };
if (data.msg === "connected") {
webSocket.removeEventListener("message", subscribe);
resolve();
} else if (data.msg === "failed") {
webSocket.removeEventListener("message", subscribe);
reject(new Error("Unable to establish connection"));
}
};
webSocket.addEventListener("message", subscribe);
webSocket.send(
JSON.stringify({
msg: "connect",
version: "1", // this must be number, not string
support: ["1"], // this must be number, not string
}),
);
});
}
/**
* After a session was obtained, the session can be authenticated.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async authenticateWebSocketAsync(webSocket?: WebSocket): Promise<void> {
logger.debug("Authenticating with username and password", {
url: this.wsUrl(),
});
const response = await this.requestAsync(
"auth.login",
[this.getSecretValue("username"), this.getSecretValue("password")],
webSocket,
);
const result = await z.boolean().parseAsync(response);
if (!result) throw new ResponseError({ status: 401 });
logger.debug("Authenticated successfully with username and password", {
url: this.wsUrl(),
});
}
private async getPoolsAsync() {
logger.debug("Retrieving pools", {
url: this.wsUrl(),
});
const response = await this.requestAsync("pool.query", [
[],
{
extra: {
is_upgraded: true,
},
},
]);
const result = await poolSchema.parseAsync(response);
// Offline / exported pools have null values for allocated, size and free, so we filter them out
// See https://github.com/homarr-labs/homarr/issues/5194
const activePools = result
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
...pool,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.wsUrl(),
totalCount: result.length,
activeCount: activePools.length,
});
return activePools;
}
/**
* Retrieves data using the reporting method
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingAsync(): Promise<ReportingItem[]> {
logger.debug("Retrieving reporting data", {
url: this.wsUrl(),
});
const response = await this.requestAsync("reporting.get_data", [
[
{
name: "cpu",
},
{
name: "memory",
},
{
name: "cputemp",
},
],
{
aggregate: true,
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await z.array(reportingItemSchema).parseAsync(response);
logger.debug("Retrieved reporting data", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves a list of all available network interfaces
* @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
*/
private async getNetworkInterfacesAsync(): Promise<z.infer<typeof networkInterfaceSchema>> {
logger.debug("Retrieving available network-interfaces", {
url: this.wsUrl(),
});
const response = await this.requestAsync("interface.query", [
[], // no filters
{},
]);
const result = await networkInterfaceSchema.parseAsync(response);
logger.debug("Retrieved available network-interfaces", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves reporting network data of the last 5 minutes
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingNetdataAsync(): Promise<z.infer<typeof reportingNetDataSchema>> {
const networkInterfaces = await this.getNetworkInterfacesAsync();
logger.debug("Retrieving reporting network data", {
url: this.wsUrl(),
});
const response = await this.requestAsync("reporting.netdata_get_data", [
networkInterfaces.map((networkInterface) => ({
name: "interface",
identifier: networkInterface.id,
})),
{
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await reportingNetDataSchema.parseAsync(response);
logger.debug("Retrieved reporting-network-data", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves information about the system
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
*/
private async getSystemInformationAsync(): Promise<z.infer<typeof systemInfoSchema>> {
logger.debug("Retrieving system-information", {
url: this.wsUrl(),
});
const response = await this.requestAsync("system.info");
const result = await systemInfoSchema.parseAsync(response);
logger.debug("Retrieved system-information", {
url: this.wsUrl(),
});
return result;
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const systemInformation = await this.getSystemInformationAsync();
const reporting = await this.getReportingAsync();
@@ -39,7 +272,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return {
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)),
memAvailableInBytes: systemInformation.physmem,
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
fileSystem: datasets.map((dataset) => ({
@@ -68,164 +301,87 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
};
}
private getClient(): TrueNasClient {
return (this.client ??= this.createClient());
}
private createClient(): TrueNasClient {
const credentials: TrueNasCredentials = this.hasSecretValue("apiKey")
? { apiKey: this.getSecretValue("apiKey") }
: { username: this.getSecretValue("username"), password: this.getSecretValue("password") };
return new TrueNasClient(this.integration.id, (path) => this.webSocketUrl(path), credentials);
}
private async requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
return this.getClient().requestAsync(method, params);
}
private async getPoolsAsync() {
logger.debug("Retrieving pools", { url: this.integration.url });
const response = await this.requestAsync("pool.query", [
[],
{
extra: {
is_upgraded: true,
},
},
]);
const result = await poolSchema.parseAsync(response);
// Offline / exported pools have null values for allocated, size and free, so we filter them out
// See https://github.com/homarr-labs/homarr/issues/5194
const activePools = result
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
...pool,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.integration.url,
totalCount: result.length,
activeCount: activePools.length,
});
return activePools;
}
/**
* Retrieves data using the reporting method
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
* Send a request through websocket and return response
* Times out after 5 seconds when no response was received.
* @param method json-rpc method to call
* @param params array of parameters
* @param webSocketOverride override of webSocket, helpful for not storing the connection
* @returns result of json-rpc call
*/
private async getReportingAsync(): Promise<ReportingItem[]> {
logger.debug("Retrieving reporting data", { url: this.integration.url });
private async requestAsync(method: string, params: unknown[] = [], webSocketOverride?: WebSocket) {
let webSocket = webSocketOverride ?? this.webSocket;
if (webSocket?.readyState !== WebSocket.OPEN) {
logger.debug("Connecting to websocket", {
url: this.wsUrl(),
});
// We can only land here with static webSocket
webSocket = await this.connectWebSocketAsync();
await this.registerSessionAsync(webSocket);
const response = await this.requestAsync("reporting.get_data", [
[
{
name: "cpu",
},
{
name: "memory",
},
{
name: "cputemp",
},
],
{
aggregate: true,
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await z.array(reportingItemSchema).parseAsync(response);
TrueNasIntegration.webSocketMap.set(this.integration.id, webSocket);
await this.authenticateWebSocketAsync();
}
logger.debug("Retrieved reporting data", {
url: this.integration.url,
count: result.length,
return await new Promise((resolve, reject) => {
const id = createId();
const handler = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as Record<string, unknown>;
if (data.msg !== "result") return;
if (data.id !== id) return;
clearTimeout(timeoutId);
webSocket.removeEventListener("message", handler);
logger.debug("Received method response", {
id,
method,
url: this.wsUrl(),
});
resolve(data.result);
};
const timeoutId = setTimeout(() => {
webSocket.removeEventListener("message", handler);
reject(
new RequestError(
{
type: "timeout",
reason: "aborted",
code: "ECONNABORTED",
},
{ cause: new Error("Canceled request after 5 seconds") },
),
);
}, 5000);
webSocket.addEventListener("message", handler);
logger.debug("Sending method request", {
id,
method,
url: this.wsUrl(),
});
webSocket.send(
JSON.stringify({
id,
msg: "method",
method,
params,
}),
);
});
return result;
}
/**
* Retrieves a list of all available network interfaces
* @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
*/
private async getNetworkInterfacesAsync(): Promise<z.infer<typeof networkInterfaceSchema>> {
logger.debug("Retrieving available network-interfaces", { url: this.integration.url });
const response = await this.requestAsync("interface.query", [
[], // no filters
{},
]);
const result = await networkInterfaceSchema.parseAsync(response);
logger.debug("Retrieved available network-interfaces", {
url: this.integration.url,
count: result.length,
});
return result;
}
/**
* Retrieves reporting network data of the last 5 minutes
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingNetdataAsync(): Promise<z.infer<typeof reportingNetDataSchema>> {
const networkInterfaces = await this.getNetworkInterfacesAsync();
logger.debug("Retrieving reporting network data", { url: this.integration.url });
const response = await this.requestAsync("reporting.netdata_get_data", [
networkInterfaces.map((networkInterface) => ({
name: "interface",
identifier: networkInterface.id,
})),
{
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await reportingNetDataSchema.parseAsync(response);
logger.debug("Retrieved reporting-network-data", {
url: this.integration.url,
count: result.length,
});
return result;
}
/**
* Retrieves information about the system
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
*/
private async getSystemInformationAsync(): Promise<z.infer<typeof systemInfoSchema>> {
logger.debug("Retrieving system-information", { url: this.integration.url });
const response = await this.requestAsync("system.info");
const result = await systemInfoSchema.parseAsync(response);
logger.debug("Retrieved system-information", { url: this.integration.url });
return result;
}
private extractNetworkTrafficData = (data: z.infer<typeof reportingNetDataSchema>, index: 1 | 2) => {
private extractNetworkTrafficData = (data: z.infer<typeof reportingNetDataSchema>, index = 1 | 2) => {
return data.reduce((acc, current) => acc + (current.data.at(-1)?.at(index) ?? 0), 0);
};
private extractLatestReportingData(data: ReportingItem[], identifier: ReportingItem["identifier"]) {
const item = data.find((reportingItem) => reportingItem.identifier === identifier);
// Reporting rows are [timestamp, ...values] in ascending time order, so the last row is the most recent reading.
const latest = item?.data.at(-1);
if (!latest) throw new Error(`TrueNAS returned no "${identifier}" reporting data`);
return latest;
private extractLatestReportingData(data: ReportingItem[], key: ReportingItem["identifier"]) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const dataObject = data.find((item) => item.identifier === key)!;
// TODO: check why the below sorting is done, because right now it compares number[] with number[]?
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return dataObject.data.sort((item1, item2) => (item1 > item2 ? -1 : 1))[0]!;
}
}
-1
View File
@@ -3,7 +3,6 @@ export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
export * from "./interfaces/firewall-summary/firewall-summary-types";
export * from "./interfaces/health-monitoring/health-monitoring-types";
export * from "./interfaces/ups-summary/ups-summary-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
export * from "./base/searchable-integration";
+1 -8
View File
@@ -1,6 +1,6 @@
import type { NotificationData } from "@mantine/notifications";
import { notifications } from "@mantine/notifications";
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
import { IconCheck, IconX } from "@tabler/icons-react";
export const showSuccessNotification = (props: NotificationData) =>
notifications.show({
@@ -15,10 +15,3 @@ export const showErrorNotification = (props: NotificationData) =>
color: "red",
icon: <IconX size={20} />,
});
export const showWarningNotification = (props: NotificationData) =>
notifications.show({
...props,
color: "yellow",
icon: <IconInfoCircle size={20} />,
});
+2
View File
@@ -13,6 +13,8 @@ export {
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
createQueryCacheChannel,
getAllQueryCacheAsync,
} from "./lib/channel";
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
+43
View File
@@ -125,6 +125,49 @@ export const createGetSetChannel = <TData>(name: string) => {
};
};
interface QueryCacheChannelOptions {
userId: string;
boardId: string;
key: string;
ttlMs: number;
maxValueBytes: number;
}
const queryCacheHashKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`;
export const createQueryCacheChannel = ({ userId, boardId, key, ttlMs, maxValueBytes }: QueryCacheChannelOptions) => {
const hkey = queryCacheHashKey(userId, boardId);
return {
name: `${hkey}:${key}`,
getAsync: async () => {
return await getSetClient.hget(hkey, key);
},
setAsync: async (value: string) => {
if (Buffer.byteLength(value, "utf8") > maxValueBytes) {
logger.warn("Query cache value exceeded maximum size", {
channel: hkey,
valueBytes: Buffer.byteLength(value, "utf8"),
maxValueBytes,
});
return false;
}
await getSetClient.hset(hkey, key, value);
await getSetClient.pexpire(hkey, ttlMs);
return true;
},
removeAsync: async () => {
await getSetClient.hdel(hkey, key);
},
};
};
export const getAllQueryCacheAsync = async (userId: string, boardId: string): Promise<Record<string, string>> => {
const hkey = queryCacheHashKey(userId, boardId);
return await getSetClient.hgetall(hkey);
};
/**
* Creates a new cache channel.
* @param name name of the channel
+1 -158
View File
@@ -9,7 +9,7 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import { extractContainerImageName } from "@homarr/definitions";
import type { ContainerState, Port } from "@homarr/docker";
import type { ContainerState } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker";
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
@@ -17,167 +17,10 @@ import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-ha
const logger = createLogger({ module: "dockerRequestHandler" });
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
const port = (privatePort: number, publicPort: number, type: string): Port => ({
IP: "0.0.0.0",
PrivatePort: privatePort,
PublicPort: publicPort,
Type: type,
});
const mockContainers: {
id: string;
name: string;
host: string;
state: ContainerState;
image: string;
iconUrl: string;
cpuUsage: number;
memoryUsage: number;
ports: Port[];
}[] = [
{
id: "a1b2c3d4e5f6",
name: "sonarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/sonarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
cpuUsage: 2.3,
memoryUsage: 256 * 1024 * 1024,
ports: [port(8989, 8989, "tcp")],
},
{
id: "b2c3d4e5f6a7",
name: "radarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/radarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
cpuUsage: 1.8,
memoryUsage: 220 * 1024 * 1024,
ports: [port(7878, 7878, "tcp")],
},
{
id: "c3d4e5f6a7b8",
name: "plex",
host: "local",
state: "running",
image: "lscr.io/linuxserver/plex:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
cpuUsage: 12.5,
memoryUsage: 1024 * 1024 * 1024,
ports: [port(32400, 32400, "tcp")],
},
{
id: "d4e5f6a7b8c9",
name: "qbittorrent",
host: "local",
state: "running",
image: "lscr.io/linuxserver/qbittorrent:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
cpuUsage: 5.1,
memoryUsage: 380 * 1024 * 1024,
ports: [port(8080, 8080, "tcp")],
},
{
id: "e5f6a7b8c9d0",
name: "prowlarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/prowlarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
cpuUsage: 0.8,
memoryUsage: 120 * 1024 * 1024,
ports: [port(9696, 9696, "tcp")],
},
{
id: "f6a7b8c9d0e1",
name: "overseerr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/overseerr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
cpuUsage: 1.2,
memoryUsage: 180 * 1024 * 1024,
ports: [port(5055, 5055, "tcp")],
},
{
id: "a7b8c9d0e1f2",
name: "homarr",
host: "local",
state: "running",
image: "ghcr.io/homarr-labs/homarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
cpuUsage: 3.4,
memoryUsage: 290 * 1024 * 1024,
ports: [port(7575, 7575, "tcp")],
},
{
id: "b8c9d0e1f2a3",
name: "pihole",
host: "local",
state: "running",
image: "pihole/pihole:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
cpuUsage: 0.5,
memoryUsage: 95 * 1024 * 1024,
ports: [port(80, 80, "tcp"), port(53, 53, "udp")],
},
{
id: "c9d0e1f2a3b4",
name: "nginx-proxy",
host: "local",
state: "running",
image: "jc21/nginx-proxy-manager:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nginx-proxy-manager.svg",
cpuUsage: 0.3,
memoryUsage: 65 * 1024 * 1024,
ports: [port(443, 443, "tcp"), port(81, 81, "tcp")],
},
{
id: "d0e1f2a3b4c5",
name: "watchtower",
host: "local",
state: "running",
image: "containrrr/watchtower:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/watchtower.svg",
cpuUsage: 0.1,
memoryUsage: 30 * 1024 * 1024,
ports: [],
},
{
id: "e1f2a3b4c5d6",
name: "tdarr",
host: "local",
state: "exited",
image: "ghcr.io/haveagitgat/tdarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tdarr.svg",
cpuUsage: 0,
memoryUsage: 0,
ports: [],
},
{
id: "f2a3b4c5d6e7",
name: "bazarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/bazarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
cpuUsage: 0.6,
memoryUsage: 110 * 1024 * 1024,
ports: [port(6767, 6767, "tcp")],
},
];
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult",
widgetKind: "dockerContainers",
async requestAsync() {
if (isDemoMode) {
return mockContainers;
}
return await getContainersWithStatsAsync();
},
cacheDuration: dayjs.duration(20, "seconds"),
-20
View File
@@ -1,20 +0,0 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { UpsSummary } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const upsSummariesRequestHandler = createCachedIntegrationRequestHandler<
UpsSummary[],
IntegrationKindByCategory<"ups">,
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getUpsSummariesAsync();
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "upsSummaries",
});
@@ -88,7 +88,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
setMediaRequestSearchScope({ integrationIds });
setMode("media");
clearChildrenStack();
setChildrenStack([]);
setQuery(query ?? "");
spotlightActions.open();
setTimeout(() => selectAction(0, spotlightStore));
@@ -98,7 +98,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
return () => {
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
};
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
}, [setMediaRequestSearchScope, setMode, setQuery]);
useEffect(() => {
if (mode !== "media") {
@@ -55,7 +55,8 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
const { result } = option;
const hasAvailabilityLabel =
result.availability && (availabilityLabelKeys as readonly string[]).includes(result.availability);
result.availability &&
(availabilityLabelKeys as readonly string[]).includes(result.availability);
const badgeLabel = hasAvailabilityLabel
? tMedia(`availability.${result.availability as AvailabilityWithLabel}`)
: undefined;
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Contrasenya",
"suggestionsTitle": "",
"suggestion": {
"length": "Inclou com a mínim 8 caràcters",
"lowercase": "Inclou minúscules",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "密码",
"suggestionsTitle": "",
"suggestion": {
"length": "至少包含 8 个字符",
"lowercase": "包含小写字母",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "在新标签页中打开"
},
"withBorder": {
"label": ""
},
"items": {
"label": "书签",
"add": "添加书签"
+5 -9
View File
@@ -132,13 +132,12 @@
},
"password": {
"label": "crwdns26:0crwdne26:0",
"suggestionsTitle": "crwdns4261:0crwdne4261:0",
"suggestion": {
"length": "crwdns4263:0crwdne4263:0",
"lowercase": "crwdns4265:0crwdne4265:0",
"uppercase": "crwdns4267:0crwdne4267:0",
"number": "crwdns4269:0crwdne4269:0",
"special": "crwdns4271:0crwdne4271:0"
"length": "crwdns28:0crwdne28:0",
"lowercase": "crwdns30:0crwdne30:0",
"uppercase": "crwdns32:0crwdne32:0",
"number": "crwdns34:0crwdne34:0",
"special": "crwdns36:0crwdne36:0"
}
},
"passwordConfirm": {
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "crwdns2686:0crwdne2686:0"
},
"withBorder": {
"label": "crwdns4273:0crwdne4273:0"
},
"items": {
"label": "crwdns764:0crwdne764:0",
"add": "crwdns766:0crwdne766:0"
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Heslo",
"suggestionsTitle": "",
"suggestion": {
"length": "Obsahuje alespoň 8 znaků",
"lowercase": "Obsahuje malé písmeno",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Adgangskode",
"suggestionsTitle": "",
"suggestion": {
"length": "Inkluderer mindst 8 tegn",
"lowercase": "Inkluderer små bogstaver",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Åbn i nyt faneblad"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bogmærker",
"add": "Tilføj bogmærke"
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Passwort",
"suggestionsTitle": "",
"suggestion": {
"length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "In neuem Tab öffnen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Lesezeichen",
"add": "Lesezeichen hinzufügen"
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Passwort",
"suggestionsTitle": "",
"suggestion": {
"length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "In neuem Tab öffnen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Lesezeichen",
"add": "Lesezeichen hinzufügen"
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Κωδικός",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "Περιλαμβάνει πεζό γράμμα",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "",
"suggestion": {
"length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Open in new tab"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bookmarks",
"add": "Add bookmark"
-31
View File
@@ -2150,37 +2150,6 @@
"title": "Instances"
}
},
"ups": {
"name": "UPS",
"description": "Monitor the status of your UPS devices through a NUT server",
"option": {
"showBattery": {
"label": "Show battery"
},
"showLoad": {
"label": "Show load"
},
"showVoltage": {
"label": "Show voltage"
}
},
"status": {
"online": "Online",
"onBattery": "On battery",
"lowBattery": "Low battery",
"charging": "Charging",
"unknown": "Unknown"
},
"field": {
"runtime": "Runtime",
"load": "Load",
"input": "Input",
"output": "Output"
},
"error": {
"internalServerError": "Failed to fetch UPS data"
}
},
"tracearr": {
"name": "Tracearr",
"description": "Enhanced media server monitoring with detailed stream and transcoding data",
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Contraseña",
"suggestionsTitle": "",
"suggestion": {
"length": "Incluye al menos 8 caracteres",
"lowercase": "Incluye al menos una letra minúscula",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Abrir en una nueva pestaña"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Marcadores",
"add": "Añadir marcador"
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+1 -5
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Mot de passe",
"suggestionsTitle": "",
"suggestion": {
"length": "Contient au moins 8 caractères",
"lowercase": "Inclut une lettre minuscule",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Ouvrir dans un nouvel onglet"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Signets",
"add": "Ajouter un signet"
@@ -2092,7 +2088,7 @@
"empty": "",
"user": "Utilisateur",
"media": "Média",
"state": "État",
"state": "",
"quality": "Qualité",
"device": "Appareil",
"progress": "Progression",
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "סיסמה",
"suggestionsTitle": "",
"suggestion": {
"length": "כולל לפחות 8 תווים",
"lowercase": "אפשר אותיות קטנות",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "פתיחה בכרטיסיה חדשה"
},
"withBorder": {
"label": ""
},
"items": {
"label": "סימניות",
"add": "הוסף סימניה"
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Lozinka",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "Uključuje mala slova",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Jelszó",
"suggestionsTitle": "",
"suggestion": {
"length": "Tartalmazzon legalább 8 karaktert",
"lowercase": "Tartalmazzon kisbetűt",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Megnyitás új lapon"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Könyjelzők",
"add": "Könyvjelző hozzáadása"
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "Suggerimenti password",
"suggestion": {
"length": "Include almeno 8 caratteri",
"lowercase": "Include lettera minuscola",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Apri in una nuova scheda"
},
"withBorder": {
"label": "Mostra bordo"
},
"items": {
"label": "Segnalibri",
"add": "Aggiungi segnalibro"
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "パスワード",
"suggestionsTitle": "",
"suggestion": {
"length": "少なくとも 8 文字以上を含む",
"lowercase": "小文字を含む",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "新しいタブで開く"
},
"withBorder": {
"label": ""
},
"items": {
"label": "ブックマーク",
"add": "ブックマークに追加"
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "비밀번호",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "소문자 포함",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Slaptažodis",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Parole",
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "Ietver mazo burtu",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Wachtwoord",
"suggestionsTitle": "",
"suggestion": {
"length": "Bevat ten minste 8 tekens",
"lowercase": "Inclusief kleine letter",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "In nieuw tabblad openen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bladwijzers",
"add": "Bladwijzer toevoegen"
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Passord",
"suggestionsTitle": "",
"suggestion": {
"length": "Inkluderer minst 8 tegn",
"lowercase": "Inkluderer liten bokstav",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Åpne i ny fane"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bokmerke",
"add": "Legg til bokmerke"
-4
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Hasło",
"suggestionsTitle": "",
"suggestion": {
"length": "Zawiera co najmniej 8 znaków",
"lowercase": "Zawiera małą literę",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Otwórz w nowej karcie"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Zakładki",
"add": "Dodaj zakładkę"
+5 -9
View File
@@ -132,7 +132,6 @@
},
"password": {
"label": "Senha",
"suggestionsTitle": "",
"suggestion": {
"length": "Inclua pelo menos 8 caracteres",
"lowercase": "Inclui letras minúsculas",
@@ -1057,7 +1056,7 @@
}
},
"noResults": {
"title": "Nenhuma mídia enviada"
"title": ""
}
},
"common": {
@@ -1089,8 +1088,8 @@
"checkLogs": "Verifique os logs para mais detalhes",
"tryAgain": "Tente novamente",
"loading": "Carregando",
"show": "Mostrar",
"hide": "Esconder"
"show": "",
"hide": ""
},
"here": "aqui",
"iconPicker": {
@@ -1256,7 +1255,7 @@
"label": {
"create": "Nova categoria",
"changePosition": "Mudar de posição",
"settings": "Configurações"
"settings": ""
}
},
"openAllInNewTabs": {
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Abrir em nova guia"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Marcadores",
"add": "Adicionar marcador"
@@ -3463,7 +3459,7 @@
"private": "Este quadro é privado"
},
"noResults": {
"title": "Nenhum painel ainda"
"title": ""
},
"modal": {
"createBoard": {
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more