diff --git a/.cursor/rules/codebase-context.mdc b/.agents/skills/codebase-context/SKILL.md similarity index 89% rename from .cursor/rules/codebase-context.mdc rename to .agents/skills/codebase-context/SKILL.md index b04b1db1d..83301b2a4 100644 --- a/.cursor/rules/codebase-context.mdc +++ b/.agents/skills/codebase-context/SKILL.md @@ -1,6 +1,6 @@ --- -description: Comprehensive Homarr codebase architecture and conventions reference -alwaysApply: true +name: codebase-context +description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code. --- # Homarr Codebase Context @@ -66,8 +66,6 @@ homarr/ │ ├── analytics/ # Server-side analytics (Umami) │ ├── server-settings/ # Server setting keys/types │ ├── settings/ # User-facing settings UI context -│ ├── old-import/ # Legacy Homarr import -│ ├── old-schema/ # Legacy Homarr zod schemas │ └── cli/ # Node CLI for ops (brocli) ├── tooling/ │ ├── typescript/ # Base tsconfig @@ -79,11 +77,11 @@ homarr/ ## Three Runtime Services -| Service | Port | Role | -|---------|------|------| -| Next.js | 3000 | Main web app (SSR + API routes) | -| WebSocket | 3001 | tRPC subscriptions via `ws` | -| Tasks | 3002 | Cron job runner + management API via Fastify | +| Service | Port | Role | +| --------- | ---- | -------------------------------------------- | +| Next.js | 3000 | Main web app (SSR + API routes) | +| WebSocket | 3001 | tRPC subscriptions via `ws` | +| Tasks | 3002 | Cron job runner + management API via Fastify | In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header. @@ -112,6 +110,7 @@ In Docker, nginx listens on **7575** and proxies to all three. The tasks service ### Multi-Database Support Drizzle schemas exist in three parallel implementations: + - `packages/db/schema/sqlite.ts` - `packages/db/schema/mysql.ts` - `packages/db/schema/postgresql.ts` @@ -121,6 +120,7 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos ### Widget System Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record`). Each widget: + - Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })` - Uses `optionsBuilder.from(factory => ({ ... }))` for typed options - Loads dynamically via `next/dynamic` @@ -133,6 +133,7 @@ Integrations are defined in `packages/definitions/src/integration.ts` (`integrat ### Cron Job System Jobs defined in `packages/cron-jobs/src/jobs/`. Each job: + - Uses `createCronJob` with a cron expression - Publishes results to Redis channels - Widget subscriptions consume those channels via tRPC subscriptions @@ -160,6 +161,7 @@ Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `o ## Environment Variables Key env vars (from `.env.example`): + - `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres` - `DB_URL`: Database connection string - `AUTH_SECRET`: NextAuth secret @@ -191,16 +193,3 @@ Key env vars (from `.env.example`): - Drag-and-drop via `@dnd-kit/*` - `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`) - `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom` - -## Documentation (`apps/docs`) - -Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`. - -- Run with `pnpm dev:docs` from root -- Build with `pnpm turbo build --filter=@homarr/docs` -- Uses Tailwind CSS 3 for custom pages (not Mantine) -- Imports `@homarr/definitions` for type-safe integration/widget metadata -- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/` -- Integration docs: `apps/docs/docs/integrations//index.mdx` + `index.ts` -- Widget docs: `apps/docs/docs/widgets//index.mdx` + `index.ts` -- Strict link checking: broken links/anchors cause build failure diff --git a/.cursor/rules/mantine-datatable-migration.mdc b/.agents/skills/datatable-migration/SKILL.md similarity index 80% rename from .cursor/rules/mantine-datatable-migration.mdc rename to .agents/skills/datatable-migration/SKILL.md index 08d2b8202..62bbc94c3 100644 --- a/.cursor/rules/mantine-datatable-migration.mdc +++ b/.agents/skills/datatable-migration/SKILL.md @@ -1,3 +1,8 @@ +--- +name: datatable-migration +description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets. +--- + # Migrating Tables from mantine-react-table to mantine-datatable This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables. @@ -7,10 +12,12 @@ This document captures every pattern, fix, and decision from the downloads widge Current `mantine-react-table` consumers that could be migrated: **Widgets (in `packages/widgets/`):** + - `docker/component.tsx` — Docker container table - `media-server/component.tsx` — Media server sessions **Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):** + - `tools/docker/docker-table.tsx` - `tools/tasks/_components/tasks-table.tsx` - `tools/api/components/api-keys.tsx` @@ -19,12 +26,12 @@ Current `mantine-react-table` consumers that could be migrated: - `tools/kubernetes/` — 8 Kubernetes resource tables **Modals:** + - `packages/modals-collection/src/search-engines/request-media-modal.tsx` **Shared hook to remove once all migrated:** -- `packages/ui/src/hooks/use-translated-mantine-react-table.ts` ---- +- `packages/ui/src/hooks/use-translated-mantine-react-table.ts` ## 1. Core Migration: mantine-react-table → mantine-datatable @@ -67,6 +74,7 @@ const columns: DataTableColumn[] = [ ``` Key differences: + - `accessorKey` → `accessor` - `header` → `title` - `size` → `width` @@ -118,8 +126,6 @@ const sortedData = useMemo(() => { ``` ---- - ## 2. Making Tables Transparent (Widget Context) Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque. @@ -144,13 +150,21 @@ Create a CSS file alongside the component: /* Hover: translucent instead of opaque */ .my-table .mantine-datatable-table tbody tr:hover { - background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; + background-color: color-mix( + in srgb, + var(--mantine-color-default-hover) 40%, + transparent + ) !important; } /* Headers: frosted glass effect */ .my-table th { white-space: nowrap; - background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; + background-color: color-mix( + in srgb, + var(--mantine-color-body) 60%, + transparent + ) !important; backdrop-filter: blur(8px); } @@ -172,14 +186,16 @@ Create a CSS file alongside the component: /* Row expansion area */ .my-table .mantine-datatable-row-expansion-cell { - background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important; + background-color: color-mix( + in srgb, + var(--mantine-color-body) 40%, + transparent + ) !important; } ``` Import it: `import "./styles.css";` and apply via `className="my-table"`. ---- - ## 3. Responsive Column Visibility Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop. @@ -195,11 +211,38 @@ interface SizeConfig { } const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [ - { maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } }, - { maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } }, + { + maxWidth: 300, + config: { + fontSize: "xs", + iconSize: 12, + cellPadding: 2, + showSpeedColumns: false, + showTimeColumn: false, + showStateColumn: false, + }, + }, + { + maxWidth: 500, + config: { + fontSize: "xs", + iconSize: 14, + cellPadding: 4, + showSpeedColumns: false, + showTimeColumn: true, + showStateColumn: false, + }, + }, ]; -const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true }; +const DEFAULT_SIZE_CONFIG: SizeConfig = { + fontSize: "sm", + iconSize: 14, + cellPadding: 4, + showSpeedColumns: true, + showTimeColumn: true, + showStateColumn: true, +}; function getSizeConfig(width: number): SizeConfig { for (const { maxWidth, config } of SIZE_BREAKPOINTS) { @@ -215,7 +258,9 @@ const size = useMemo(() => getSizeConfig(width), [width]); Then use a visibility check lookup table for columns: ```typescript -const columnVisibilityChecks: Partial boolean>> = { +const columnVisibilityChecks: Partial< + Record boolean> +> = { upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, downSpeed: (ctx) => ctx.size.showSpeedColumns, time: (ctx) => ctx.size.showTimeColumn, @@ -230,8 +275,6 @@ function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean { } ``` ---- - ## 4. Column Persistence (Drag/Resize → Server) `useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices. @@ -263,15 +306,6 @@ Even hidden options need translation keys or the translation test fails: "widget.mywidget.option.columnWidths.label": "Column widths" ``` -### Old-import mappings - -Add to `packages/old-import/src/widgets/options.ts`: - -```typescript -columnOrder: () => undefined, -columnWidths: () => undefined, -``` - ### Dynamic storeKey Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding: @@ -285,8 +319,13 @@ const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back: ```typescript -const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = - useDataTableColumns({ key: storeKey, columns }); +const { + effectiveColumns, + columnsOrder, + columnsWidth, + setColumnsOrder, + setMultipleColumnWidths, +} = useDataTableColumns({ key: storeKey, columns }); const lastStoreKey = useRef(storeKey); const hydrated = useRef(false); @@ -300,11 +339,22 @@ useEffect(() => { if (savedOrder.length > 0) setColumnsOrder(savedOrder); if (Object.keys(savedWidths).length > 0) { setMultipleColumnWidths( - Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })), + Object.entries(savedWidths).map(([accessor, w]) => ({ + accessor, + width: w, + })), ); } - requestAnimationFrame(() => { hydrated.current = true; }); -}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]); + requestAnimationFrame(() => { + hydrated.current = true; + }); +}, [ + storeKey, + savedOrder, + savedWidths, + setColumnsOrder, + setMultipleColumnWidths, +]); ``` ### Persist localStorage → server @@ -317,22 +367,26 @@ const prevWidths = useRef(columnsWidth); useEffect(() => { if (!hydrated.current) return; - const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); - const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); + const orderChanged = + JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); + const widthsChanged = + JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); prevOrder.current = columnsOrder; prevWidths.current = columnsWidth; if (!orderChanged && !widthsChanged) return; - if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) }); + if (orderChanged) + persistOption({ columnOrder: JSON.stringify(columnsOrder) }); if (widthsChanged) { const widthMap: Record = {}; for (const entry of columnsWidth) { const key = Object.keys(entry)[0]; if (!key) continue; - const w = entry[key as keyof typeof entry]; - if (typeof w === "number") widthMap[key] = w; - else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10); + const v = entry[key as keyof typeof entry]; + if (typeof v === "number") widthMap[key] = v; + else if (typeof v === "string" && v.endsWith("px")) + widthMap[key] = parseInt(v, 10); } persistOption({ columnWidths: JSON.stringify(widthMap) }); } @@ -341,8 +395,6 @@ useEffect(() => { The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation). ---- - ## 5. Context Menu (Right-Click) Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly: @@ -374,15 +426,41 @@ const handleContextMenu = useCallback( The menu component uses `Portal` for correct z-index: ```tsx -function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) { +function RowContextMenu({ + state, + onClose, +}: { + state: ContextMenuState; + onClose: () => void; +}) { return ( - + - + - { /* action */ onClose(); }}>Action + { + /* action */ onClose(); + }} + > + Action + @@ -392,8 +470,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: **Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`. ---- - ## 6. Row Expansion ```typescript @@ -413,8 +489,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: **Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work. ---- - ## 7. Common Pitfalls & Fixes ### Status indicator dots @@ -475,8 +549,11 @@ Always wrap in try/catch: ```typescript function parseJsonOption(value: string | undefined, fallback: T): T { if (!value) return fallback; - try { return JSON.parse(value) as T; } - catch { return fallback; } + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } } ``` @@ -494,20 +571,19 @@ If the widget has configurable default sort, sync it: ```typescript useEffect(() => { - setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection }); + setSortStatus({ + columnAccessor: options.defaultSort, + direction: defaultSortDirection, + }); }, [options.defaultSort, defaultSortDirection]); ``` ---- - ## 8. Accessibility - All `ActionIcon` components need `aria-label` - Filter toggles (client badges, status badges) need `aria-pressed={isActive}` - `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize ---- - ## 9. Error Handling - Every mutation needs an `onError` callback with `showErrorNotification` @@ -518,12 +594,14 @@ useEffect(() => { ```typescript const mutationOptions = { onSettled: () => void utils.myQuery.invalidate(), - onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }), + onError: () => + showErrorNotification({ + title: t("errors.actionFailed"), + message: t("errors.actionFailedMessage"), + }), }; ``` ---- - ## 10. i18n - No hardcoded strings. Every user-visible string goes through `useScopedI18n` @@ -534,8 +612,6 @@ const mutationOptions = { - Errors: `t("errors.{errorKey}")` - Hidden options still need `.label` translation keys ---- - ## 11. Edit Mode Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop: @@ -550,8 +626,6 @@ if (isEditMode) rowContextMenuHandler = undefined; ``` ---- - ## 12. DataTable Props Reference (Commonly Used) ```typescript @@ -590,8 +664,6 @@ if (isEditMode) rowContextMenuHandler = undefined; /> ``` ---- - ## 13. Layout Stability - Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic @@ -600,8 +672,6 @@ if (isEditMode) rowContextMenuHandler = undefined; - Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length - `flexShrink: 0` on badges/icons prevents them from being squeezed by long names ---- - ## 14. Checklist for Each Migration 1. Replace imports (`mantine-react-table` → `mantine-datatable`) @@ -610,13 +680,12 @@ if (isEditMode) rowContextMenuHandler = undefined; 4. Add CSS file for transparent backgrounds (if widget context) 5. Add responsive column visibility (if widget context) 6. Add hidden options + hydration logic (if column persistence needed) -7. Add old-import mappings for new options -8. Add translation keys (including hidden option labels) -9. Add context menu if row actions exist -10. Add row expansion if detail view exists -11. Add error handling on all mutations -12. Add accessibility attributes -13. Test at multiple widget widths -14. Update docs in `apps/docs/docs/widgets/` if applicable -15. Run `pnpm turbo typecheck` — catches old-import mismatches -16. Run translation spec — catches missing keys +7. Add translation keys (including hidden option labels) +8. Add context menu if row actions exist +9. Add row expansion if detail view exists +10. Add error handling on all mutations +11. Add accessibility attributes +12. Test at multiple widget widths +13. Update docs in `apps/docs/docs/widgets/` if applicable +14. Run `pnpm turbo typecheck` +15. Run translation spec — catches missing keys diff --git a/.agents/skills/documentation-sync/SKILL.md b/.agents/skills/documentation-sync/SKILL.md new file mode 100644 index 000000000..48aed0e7e --- /dev/null +++ b/.agents/skills/documentation-sync/SKILL.md @@ -0,0 +1,44 @@ +--- +name: documentation-sync +description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern. +--- + +# Documentation Sync + +When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`. + +## What triggers a doc update + +| Code change | Doc location | +| ---------------------- | ----------------------------------------------------------- | +| New integration | `apps/docs/docs/integrations//index.mdx` + `index.ts` | +| New widget | `apps/docs/docs/widgets//index.mdx` + `index.ts` | +| Changed API surface | `apps/docs/docs/management/api.mdx` | +| New/changed env vars | `apps/docs/docs/advanced/` | +| New CLI commands | `apps/docs/docs/advanced/command-line/` | +| Changed auth providers | `apps/docs/docs/advanced/` SSO pages | +| UI/UX changes | Relevant getting-started or management pages | +| New cron job | `apps/docs/docs/management/tasks.mdx` | +| Changed permissions | `apps/docs/docs/management/users.mdx` | + +## Integration doc pattern + +Each integration has a folder under `apps/docs/docs/integrations//`: + +- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path +- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets` +- Import metadata from `@homarr/definitions` where possible + +## Widget doc pattern + +Each widget has a folder under `apps/docs/docs/widgets//`: + +- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration +- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding` + +## Development + +- Run docs locally: `pnpm dev:docs` from root +- Build docs: `pnpm turbo build --filter=@homarr/docs` +- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata +- Docusaurus strict mode throws on broken links — fix any broken references before committing diff --git a/.cursor/rules/mcp-integration.mdc b/.agents/skills/mcp-integration/SKILL.md similarity index 89% rename from .cursor/rules/mcp-integration.mdc rename to .agents/skills/mcp-integration/SKILL.md index 989b0c4f6..b1eb8f611 100644 --- a/.cursor/rules/mcp-integration.mdc +++ b/.agents/skills/mcp-integration/SKILL.md @@ -1,10 +1,9 @@ --- -description: How to expose tRPC procedures as MCP tools for AI clients -globs: packages/api/src/router/**/*.ts -alwaysApply: false +name: mcp-integration +description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip. --- -# MCP Integration Guide +# MCP Integration Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr. @@ -16,7 +15,7 @@ Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure cha // Read-only tool (query) myProcedure: protectedProcedure .meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } }) - .input(z.object({ limit: z.number().default(50) })) + .input(z.object({ limit: z.number().default(10) })) .query(async ({ ctx, input }) => { ... }) // Write tool (mutation) @@ -38,22 +37,26 @@ The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()` The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr. **Must include:** + - What the tool returns or does - Which integrations/services it works with (if applicable) - How to get required IDs (e.g., "Use integration_all to get the integrationId") - Permission requirements (if non-obvious) **Good:** + ``` "Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all" ``` **Bad:** + ``` "Get calendar events" ``` **For tools that return permission fields**, explain what they mean: + ``` "List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error" ``` @@ -77,11 +80,13 @@ export const mcpRouter = createTRPCRouter({ ## What to expose vs. what to skip **Expose:** + - Data queries (list, search, get by ID) - Actions users would ask an AI to do (toggle, create, delete, approve) - Status checks (health, stats, summaries) **Skip:** + - Subscriptions (WebSocket-only, not supported by MCP) - Internal procedures (session management, onboarding steps) - File upload/download procedures diff --git a/.claude/skills/codebase-context/SKILL.md b/.claude/skills/codebase-context/SKILL.md new file mode 100644 index 000000000..83301b2a4 --- /dev/null +++ b/.claude/skills/codebase-context/SKILL.md @@ -0,0 +1,195 @@ +--- +name: codebase-context +description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code. +--- + +# Homarr Codebase Context + +Homarr is an open-source, self-hosted dashboard for managing homelab services. It integrates with 50+ self-hosted apps (media servers, download clients, DNS, NAS, etc.) and provides a drag-and-drop widget-based UI. + +## Tech Stack + +- **Monorepo**: pnpm workspaces + Turborepo +- **Framework**: Next.js (App Router, `output: "standalone"`) — T3 Stack +- **Language**: TypeScript throughout +- **ORM**: Drizzle (supports SQLite via better-sqlite3, MySQL, PostgreSQL) +- **API**: tRPC (HTTP + WebSocket subscriptions) with superjson + OpenAPI bridge via `trpc-to-openapi` +- **Auth**: NextAuth v5 (database sessions, Credentials/LDAP/OIDC providers) +- **UI**: Mantine v9 (not Tailwind) + Tabler icons +- **State**: Jotai (atoms), TanStack Query (server state via tRPC) +- **Realtime**: WebSocket server (`ws`) for tRPC subscriptions, backed by Redis pub/sub +- **Cron**: `node-cron` in a standalone Fastify service (`apps/tasks`) +- **i18n**: next-intl with `[locale]` dynamic segment (prefix mode: `"never"`, locale from cookie) +- **Testing**: Vitest + jsdom, Playwright for E2E +- **Lint/Format**: oxlint + oxfmt (not ESLint/Prettier) +- **Docs**: Docusaurus 3 in `apps/docs/` (`@homarr/docs`) +- **Package manager**: pnpm 10.34.1, Node >= 24.16.0 + +## Repository Structure + +``` +homarr/ +├── apps/ +│ ├── nextjs/ # Main Next.js application (port 3000) +│ ├── docs/ # Docusaurus 3 documentation site (@homarr/docs) +│ ├── tasks/ # Cron job runner + Fastify tRPC API (port 3002) +│ └── websocket/ # Standalone tRPC WebSocket server (port 3001) +├── packages/ +│ ├── api/ # tRPC appRouter, procedures, OpenAPI +│ ├── auth/ # NextAuth config, providers, session, API keys +│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries +│ ├── core/ # Env validation, DB/Redis driver factories, logging +│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions +│ ├── widgets/ # All 39 dashboard widgets (definitions + components) +│ ├── integrations/ # Integration classes (HTTP clients to external apps) +│ ├── redis/ # Redis pub/sub channels, caching abstractions +│ ├── translation/ # next-intl setup, locale configs, lang JSON files +│ ├── ui/ # Shared Mantine components, theme, hooks +│ ├── validation/ # Shared zod schemas for API/forms +│ ├── common/ # Shared utilities, IDs, errors +│ ├── cron-jobs/ # Cron job implementations (25+ jobs) +│ ├── cron-jobs-core/ # Cron scheduling primitives +│ ├── cron-job-api/ # tRPC router for job management (start/stop/trigger) +│ ├── cron-job-status/ # Cron status via Redis +│ ├── boards/ # Board context, edit mode, cache updater +│ ├── modals/ # Modal primitives on Mantine +│ ├── modals-collection/ # Feature modals (apps, boards, docker, etc.) +│ ├── form/ # useZodForm (Mantine + zod resolver) +│ ├── forms-collection/# Reusable form UIs (new app, icon picker, upload) +│ ├── spotlight/ # Command palette / search with multiple modes +│ ├── request-handler/ # Server request handlers (feeds, integrations) +│ ├── notifications/ # Mantine notifications wrapper +│ ├── docker/ # Dockerode-based Docker access +│ ├── icons/ # Icon DB/repo integration +│ ├── image-proxy/ # Image proxy + caching +│ ├── ping/ # Reachability / ping utilities +│ ├── analytics/ # Server-side analytics (Umami) +│ ├── server-settings/ # Server setting keys/types +│ ├── settings/ # User-facing settings UI context +│ └── cli/ # Node CLI for ops (brocli) +├── tooling/ +│ ├── typescript/ # Base tsconfig +│ └── github/ # CI setup action +├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL) +├── e2e/ # E2E test specs +└── Dockerfile # Multi-stage production build +``` + +## Three Runtime Services + +| Service | Port | Role | +| --------- | ---- | -------------------------------------------- | +| Next.js | 3000 | Main web app (SSR + API routes) | +| WebSocket | 3001 | tRPC subscriptions via `ws` | +| Tasks | 3002 | Cron job runner + management API via Fastify | + +In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header. + +## Package Dependency Layers + +1. **Foundation**: `core` (env, DB drivers, Redis client, logging) → no `@homarr` deps +2. **Domain**: `definitions` → `common` → `core` +3. **Data**: `db` → `core`, `definitions`, `common` +4. **Auth**: `auth` → `db`, `definitions`, `core`, `validation` +5. **Cross-cutting**: `translation`, `validation`, `redis`, `server-settings` +6. **Feature backends**: `integrations`, `request-handler`, `cron-jobs`, `docker`, `ping` +7. **API surface**: `api` → pulls in auth, db, redis, integrations, etc. +8. **UI stack**: `ui`, `modals`, `form`, `notifications`, `boards`, `spotlight` +9. **Composed features**: `widgets`, `modals-collection`, `forms-collection` + +## Key Patterns + +### tRPC + +- **Server caller** (RSC): `import { api } from "@homarr/api/server"` → cached caller with auth context +- **Client hooks**: `import { clientApi } from "@homarr/api/client"` → React Query hooks +- **Router structure**: `packages/api/src/root.ts` defines top-level namespaces (`user`, `board`, `widget`, `integration`, `docker`, `kubernetes`, etc.) +- **Procedures**: `publicProcedure`, `protectedProcedure`, `permissionRequiredProcedure`, `onboardingProcedure` +- **WebSocket subscriptions**: Client uses `wsLink` when `type === "subscription"`, connects to port 3001 + +### Multi-Database Support + +Drizzle schemas exist in three parallel implementations: + +- `packages/db/schema/sqlite.ts` +- `packages/db/schema/mysql.ts` +- `packages/db/schema/postgresql.ts` + +Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-postgres`). Migrations are per-engine in `packages/db/migrations/{sqlite,mysql,postgresql}/`. + +### Widget System + +Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record`). Each widget: + +- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })` +- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options +- Loads dynamically via `next/dynamic` +- Fetches data via tRPC client hooks (backed by cron job caches) + +### Integration System + +Integrations are defined in `packages/definitions/src/integration.ts` (`integrationDefs`) and instantiated via `createIntegrationAsync` in `packages/integrations/src/base/creator.ts`. Each extends abstract `Integration` class with `testConnectionAsync` and typed methods. + +### Cron Job System + +Jobs defined in `packages/cron-jobs/src/jobs/`. Each job: + +- Uses `createCronJob` with a cron expression +- Publishes results to Redis channels +- Widget subscriptions consume those channels via tRPC subscriptions +- Job management (start/stop/trigger) via `cronJobApi` HTTP client to port 3002 + +### Modals + +Created with `createModal(component).withOptions({ defaultTitle })` from `@homarr/modals`. Opened via `useModalAction(SomeModal)`. Feature modals live in `packages/modals-collection/`. + +### Forms + +`useZodForm` from `@homarr/form` wraps Mantine `useForm` with zod validation. Shared form UIs in `packages/forms-collection/`. + +### Routing (App Router) + +- `apps/nextjs/src/app/[locale]/` — all UI routes under locale segment +- Route groups: `(home)`, `(content)`, `(board)` — don't affect URLs +- Key routes: `boards/[name]`, `manage/...` (admin), `auth/login`, `init` (onboarding), `widgets/[kind]` +- Locale driven by cookie (no URL prefix) + +### Auth Providers + +Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `oidc`. Database sessions with httpOnly cookies. API key auth for programmatic access. + +## Environment Variables + +Key env vars (from `.env.example`): + +- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres` +- `DB_URL`: Database connection string +- `AUTH_SECRET`: NextAuth secret +- `SECRET_ENCRYPTION_KEY`: AES-256-CBC for integration secrets +- `AUTH_PROVIDERS`: Comma-separated (`credentials`, `ldap`, `oidc`) +- `CRON_JOB_API_KEY`: Shared key between Next.js and tasks service +- `REDIS_*`: Redis connection (host, port, password) +- `ENABLE_KUBERNETES`: Optional Kubernetes integration + +## Development Commands + +- `pnpm dev` — runs all three services in parallel via Turborepo +- `pnpm docker:dev` — starts Redis + MySQL + PostgreSQL containers +- `pnpm db:push` — push schema to SQLite (dev) +- `pnpm db:studio` — Drizzle Studio +- `pnpm test` — Vitest unit tests +- `pnpm test:e2e` — Playwright E2E +- `pnpm lint` / `pnpm format` — oxlint + oxfmt +- `pnpm dev:docs` — Docusaurus docs site (port 3003) +- `pnpm typecheck` — tsc --noEmit across all packages + +## Conventions + +- Workspace packages use `@homarr/` scope +- All versions managed via pnpm `catalog:` in workspace +- Path alias `~/*` → `./src/*` in Next.js app +- Mantine for all UI (no Tailwind) — primary color is red, `autoContrast: true` +- Icons from `@tabler/icons-react` +- Drag-and-drop via `@dnd-kit/*` +- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`) +- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom` diff --git a/.claude/skills/datatable-migration/SKILL.md b/.claude/skills/datatable-migration/SKILL.md new file mode 100644 index 000000000..62bbc94c3 --- /dev/null +++ b/.claude/skills/datatable-migration/SKILL.md @@ -0,0 +1,691 @@ +--- +name: datatable-migration +description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets. +--- + +# Migrating Tables from mantine-react-table to mantine-datatable + +This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables. + +## Migration Candidates + +Current `mantine-react-table` consumers that could be migrated: + +**Widgets (in `packages/widgets/`):** + +- `docker/component.tsx` — Docker container table +- `media-server/component.tsx` — Media server sessions + +**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):** + +- `tools/docker/docker-table.tsx` +- `tools/tasks/_components/tasks-table.tsx` +- `tools/api/components/api-keys.tsx` +- `users/_components/user-list.tsx` +- `users/invites/_components/invite-list.tsx` +- `tools/kubernetes/` — 8 Kubernetes resource tables + +**Modals:** + +- `packages/modals-collection/src/search-engines/request-media-modal.tsx` + +**Shared hook to remove once all migrated:** + +- `packages/ui/src/hooks/use-translated-mantine-react-table.ts` + +## 1. Core Migration: mantine-react-table → mantine-datatable + +### Import changes + +```typescript +// BEFORE +import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; +import type { MRT_ColumnDef } from "mantine-react-table"; + +// AFTER +import { DataTable, useDataTableColumns } from "mantine-datatable"; +import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; +``` + +### Column definition shape + +```typescript +// BEFORE (MRT) +const columns: MRT_ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + size: 200, + enableSorting: true, + Cell: ({ row }) => {row.original.name}, + }, +]; + +// AFTER (mantine-datatable) +const columns: DataTableColumn[] = [ + { + accessor: "name", // not accessorKey + title: "Name", // not header + width: 200, // not size + sortable: true, // not enableSorting + render: (record) => {record.name}, // not Cell + }, +]; +``` + +Key differences: + +- `accessorKey` → `accessor` +- `header` → `title` +- `size` → `width` +- `Cell: ({ row }) =>` → `render: (record) =>` +- `enableSorting` → `sortable` +- `enableColumnActions` → does not exist (no column action menus) +- `mantineTableBodyCellProps` → `cellsStyle` in `defaultColumnProps` + +### Table component + +```typescript +// BEFORE +const table = useTranslatedMantineReactTable({ columns, data, ... }); +return ; + +// AFTER +return ( + +); +``` + +### Sorting is manual + +mantine-datatable does NOT sort for you. You must sort the data yourself: + +```typescript +const [sortStatus, setSortStatus] = useState>({ + columnAccessor: "name", + direction: "asc", +}); + +const sortedData = useMemo(() => { + const mult = sortStatus.direction === "desc" ? -1 : 1; + return [...data].toSorted((a, b) => { + const aVal = a[sortStatus.columnAccessor]; + const bVal = b[sortStatus.columnAccessor]; + if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult; + return ((aVal as number) - (bVal as number)) * mult; + }); +}, [data, sortStatus]); + +// Pass sortedData to records, not data + +``` + +## 2. Making Tables Transparent (Widget Context) + +Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque. + +Create a CSS file alongside the component: + +```css +/* styles.css */ +.my-table, +.my-table .mantine-datatable-table, +.my-table .mantine-datatable-table thead, +.my-table .mantine-datatable-table tbody, +.my-table .mantine-datatable-table tfoot, +.my-table th, +.my-table td { + background-color: transparent !important; +} + +.my-table .mantine-datatable-table tr { + background-color: transparent; +} + +/* Hover: translucent instead of opaque */ +.my-table .mantine-datatable-table tbody tr:hover { + background-color: color-mix( + in srgb, + var(--mantine-color-default-hover) 40%, + transparent + ) !important; +} + +/* Headers: frosted glass effect */ +.my-table th { + white-space: nowrap; + background-color: color-mix( + in srgb, + var(--mantine-color-body) 60%, + transparent + ) !important; + backdrop-filter: blur(8px); +} + +/* Cell overflow */ +.my-table td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Hide sort icon until hover */ +.my-table .mantine-datatable-header-cell-sortable-icon { + opacity: 0; + transition: opacity 150ms; +} +.my-table th:hover .mantine-datatable-header-cell-sortable-icon { + opacity: 1; +} + +/* Row expansion area */ +.my-table .mantine-datatable-row-expansion-cell { + background-color: color-mix( + in srgb, + var(--mantine-color-body) 40%, + transparent + ) !important; +} +``` + +Import it: `import "./styles.css";` and apply via `className="my-table"`. + +## 3. Responsive Column Visibility + +Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop. + +```typescript +interface SizeConfig { + fontSize: "xs" | "sm"; + iconSize: number; + cellPadding: number; + showSpeedColumns: boolean; + showTimeColumn: boolean; + showStateColumn: boolean; +} + +const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [ + { + maxWidth: 300, + config: { + fontSize: "xs", + iconSize: 12, + cellPadding: 2, + showSpeedColumns: false, + showTimeColumn: false, + showStateColumn: false, + }, + }, + { + maxWidth: 500, + config: { + fontSize: "xs", + iconSize: 14, + cellPadding: 4, + showSpeedColumns: false, + showTimeColumn: true, + showStateColumn: false, + }, + }, +]; + +const DEFAULT_SIZE_CONFIG: SizeConfig = { + fontSize: "sm", + iconSize: 14, + cellPadding: 4, + showSpeedColumns: true, + showTimeColumn: true, + showStateColumn: true, +}; + +function getSizeConfig(width: number): SizeConfig { + for (const { maxWidth, config } of SIZE_BREAKPOINTS) { + if (width < maxWidth) return config; + } + return DEFAULT_SIZE_CONFIG; +} + +// Usage: memoize it +const size = useMemo(() => getSizeConfig(width), [width]); +``` + +Then use a visibility check lookup table for columns: + +```typescript +const columnVisibilityChecks: Partial< + Record boolean> +> = { + upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, + downSpeed: (ctx) => ctx.size.showSpeedColumns, + time: (ctx) => ctx.size.showTimeColumn, + state: (ctx) => ctx.size.showStateColumn, +}; + +function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean { + if (!ctx.optionsColumnSet.has(accessor)) return false; + const check = columnVisibilityChecks[accessor]; + if (check && !check(ctx)) return false; + return true; +} +``` + +## 4. Column Persistence (Drag/Resize → Server) + +`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices. + +### Hidden widget options + +In `index.ts`, add hidden text options: + +```typescript +columnOrder: factory.text({ defaultValue: "" }), +columnWidths: factory.text({ defaultValue: "" }), +``` + +With visibility override: + +```typescript +{ + columnOrder: { shouldHide: () => true }, + columnWidths: { shouldHide: () => true }, +} +``` + +### Translation keys required + +Even hidden options need translation keys or the translation test fails: + +```json +"widget.mywidget.option.columnOrder.label": "Column order", +"widget.mywidget.option.columnWidths.label": "Column widths" +``` + +### Dynamic storeKey + +Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding: + +```typescript +const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`; +``` + +### Hydration from server → localStorage + +On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back: + +```typescript +const { + effectiveColumns, + columnsOrder, + columnsWidth, + setColumnsOrder, + setMultipleColumnWidths, +} = useDataTableColumns({ key: storeKey, columns }); + +const lastStoreKey = useRef(storeKey); +const hydrated = useRef(false); + +useEffect(() => { + if (lastStoreKey.current !== storeKey) { + lastStoreKey.current = storeKey; + hydrated.current = false; + } + if (hydrated.current) return; + if (savedOrder.length > 0) setColumnsOrder(savedOrder); + if (Object.keys(savedWidths).length > 0) { + setMultipleColumnWidths( + Object.entries(savedWidths).map(([accessor, w]) => ({ + accessor, + width: w, + })), + ); + } + requestAnimationFrame(() => { + hydrated.current = true; + }); +}, [ + storeKey, + savedOrder, + savedWidths, + setColumnsOrder, + setMultipleColumnWidths, +]); +``` + +### Persist localStorage → server + +Watch for changes and write back: + +```typescript +const prevOrder = useRef(columnsOrder); +const prevWidths = useRef(columnsWidth); + +useEffect(() => { + if (!hydrated.current) return; + const orderChanged = + JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); + const widthsChanged = + JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); + prevOrder.current = columnsOrder; + prevWidths.current = columnsWidth; + + if (!orderChanged && !widthsChanged) return; + + if (orderChanged) + persistOption({ columnOrder: JSON.stringify(columnsOrder) }); + if (widthsChanged) { + const widthMap: Record = {}; + for (const entry of columnsWidth) { + const key = Object.keys(entry)[0]; + if (!key) continue; + const v = entry[key as keyof typeof entry]; + if (typeof v === "number") widthMap[key] = v; + else if (typeof v === "string" && v.endsWith("px")) + widthMap[key] = parseInt(v, 10); + } + persistOption({ columnWidths: JSON.stringify(widthMap) }); + } +}, [columnsOrder, columnsWidth, persistOption]); +``` + +The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation). + +## 5. Context Menu (Right-Click) + +Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly: + +```typescript +interface ContextMenuState { + x: number; + y: number; + item: MyRow; +} + +const [contextMenu, setContextMenu] = useState(null); + +const handleContextMenu = useCallback( + ({ record, event }: { record: MyRow; event: React.MouseEvent }) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, item: record }); + }, + [], +); + +// On DataTable: + + +// Render: +{contextMenu && } +``` + +The menu component uses `Portal` for correct z-index: + +```tsx +function RowContextMenu({ + state, + onClose, +}: { + state: ContextMenuState; + onClose: () => void; +}) { + return ( + + + + + + + { + /* action */ onClose(); + }} + > + Action + + + + + ); +} +``` + +**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`. + +## 6. Row Expansion + +```typescript + , + }} +/> +``` + +**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work. + +## 7. Common Pitfalls & Fixes + +### Status indicator dots + +Don't use `` without children — it renders a pill with empty space. Use a plain `Box`: + +```tsx + +``` + +### Tooltip overflow on long text columns + +When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix: + +```tsx + + + ... + + +``` + +Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally. + +### Dark mode tooltips + +Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets. + +### Division by zero + +Guard all ratio calculations: + +```typescript +if (item.size > 0) ratio = item.sent / item.size; +if (totalDown > 0) ratio = totalUp / totalDown; +``` + +### Composite idAccessor + +When rows can come from multiple integrations, IDs may collide. Use a composite key: + +```typescript +idAccessor={(record) => `${record.integration.id}:${record.id}`} +``` + +### JSON option parsing + +Always wrap in try/catch: + +```typescript +function parseJsonOption(value: string | undefined, fallback: T): T { + if (!value) return fallback; + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} +``` + +### Clipboard API + +`navigator.clipboard.writeText()` can throw in non-HTTPS contexts. Always catch: + +```typescript +void navigator.clipboard.writeText(text).catch(() => {}); +``` + +### Sync sortStatus with widget options + +If the widget has configurable default sort, sync it: + +```typescript +useEffect(() => { + setSortStatus({ + columnAccessor: options.defaultSort, + direction: defaultSortDirection, + }); +}, [options.defaultSort, defaultSortDirection]); +``` + +## 8. Accessibility + +- All `ActionIcon` components need `aria-label` +- Filter toggles (client badges, status badges) need `aria-pressed={isActive}` +- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize + +## 9. Error Handling + +- Every mutation needs an `onError` callback with `showErrorNotification` +- The `saveItemOptions` mutation (column persistence) also needs `onError` +- Check `isError` on the main query and show a fallback UI +- Use a shared `mutationOptions` object to avoid repeating `onSettled`/`onError`: + +```typescript +const mutationOptions = { + onSettled: () => void utils.myQuery.invalidate(), + onError: () => + showErrorNotification({ + title: t("errors.actionFailed"), + message: t("errors.actionFailedMessage"), + }), +}; +``` + +## 10. i18n + +- No hardcoded strings. Every user-visible string goes through `useScopedI18n` +- Column titles: `t("items.{accessor}.columnTitle")` +- Detail labels: `t("items.{accessor}.detailsTitle")` +- States: `t("states.{state}")` +- Actions: `t("actions.{action}")` +- Errors: `t("errors.{errorKey}")` +- Hidden options still need `.label` translation keys + +## 11. Edit Mode + +Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop: + +```typescript +let tablePointerEvents: React.CSSProperties["pointerEvents"]; +if (isEditMode) tablePointerEvents = "none"; + +let rowContextMenuHandler = handleContextMenu; +if (isEditMode) rowContextMenuHandler = undefined; + + +``` + +## 12. DataTable Props Reference (Commonly Used) + +```typescript + record.uniqueKey} + height="100%" + className="my-table" + defaultColumnProps={{ + noWrap: true, + draggable: true, + resizable: true, + cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }), + }} + scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} + rowBackgroundColor={(record) => conditionalBgMap[record.state]} + onRowContextMenu={handleContextMenu} + rowExpansion={{ ... }} + onScroll={() => { if (contextMenu) closeContextMenu(); }} +/> +``` + +## 13. Layout Stability + +- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic +- Give non-name columns explicit `width` values so only the name column flexes +- Use `ellipsis: true` on the name column — it handles `text-overflow: ellipsis` internally +- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length +- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names + +## 14. Checklist for Each Migration + +1. Replace imports (`mantine-react-table` → `mantine-datatable`) +2. Convert column definitions (`accessorKey` → `accessor`, `header` → `title`, `Cell` → `render`, etc.) +3. Implement manual sorting with `useState` + `useMemo` +4. Add CSS file for transparent backgrounds (if widget context) +5. Add responsive column visibility (if widget context) +6. Add hidden options + hydration logic (if column persistence needed) +7. Add translation keys (including hidden option labels) +8. Add context menu if row actions exist +9. Add row expansion if detail view exists +10. Add error handling on all mutations +11. Add accessibility attributes +12. Test at multiple widget widths +13. Update docs in `apps/docs/docs/widgets/` if applicable +14. Run `pnpm turbo typecheck` +15. Run translation spec — catches missing keys diff --git a/.claude/skills/documentation-sync/SKILL.md b/.claude/skills/documentation-sync/SKILL.md new file mode 100644 index 000000000..48aed0e7e --- /dev/null +++ b/.claude/skills/documentation-sync/SKILL.md @@ -0,0 +1,44 @@ +--- +name: documentation-sync +description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern. +--- + +# Documentation Sync + +When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`. + +## What triggers a doc update + +| Code change | Doc location | +| ---------------------- | ----------------------------------------------------------- | +| New integration | `apps/docs/docs/integrations//index.mdx` + `index.ts` | +| New widget | `apps/docs/docs/widgets//index.mdx` + `index.ts` | +| Changed API surface | `apps/docs/docs/management/api.mdx` | +| New/changed env vars | `apps/docs/docs/advanced/` | +| New CLI commands | `apps/docs/docs/advanced/command-line/` | +| Changed auth providers | `apps/docs/docs/advanced/` SSO pages | +| UI/UX changes | Relevant getting-started or management pages | +| New cron job | `apps/docs/docs/management/tasks.mdx` | +| Changed permissions | `apps/docs/docs/management/users.mdx` | + +## Integration doc pattern + +Each integration has a folder under `apps/docs/docs/integrations//`: + +- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path +- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets` +- Import metadata from `@homarr/definitions` where possible + +## Widget doc pattern + +Each widget has a folder under `apps/docs/docs/widgets//`: + +- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration +- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding` + +## Development + +- Run docs locally: `pnpm dev:docs` from root +- Build docs: `pnpm turbo build --filter=@homarr/docs` +- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata +- Docusaurus strict mode throws on broken links — fix any broken references before committing diff --git a/.claude/skills/mcp-integration/SKILL.md b/.claude/skills/mcp-integration/SKILL.md new file mode 100644 index 000000000..b1eb8f611 --- /dev/null +++ b/.claude/skills/mcp-integration/SKILL.md @@ -0,0 +1,108 @@ +--- +name: mcp-integration +description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip. +--- + +# MCP Integration + +Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr. + +## Adding MCP to a procedure + +Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain: + +```typescript +// Read-only tool (query) +myProcedure: protectedProcedure + .meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } }) + .input(z.object({ limit: z.number().default(10) })) + .query(async ({ ctx, input }) => { ... }) + +// Write tool (mutation) +myAction: protectedProcedure + .meta({ mcp: { enabled: true, description: "Delete an item by ID. Requires interact permission on the integration" } }) + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { ... }) + +// No-input procedure (z.void) — works fine, no .input() needed +getAll: protectedProcedure + .meta({ mcp: { enabled: true, description: "List all resources" } }) + .query(async ({ ctx }) => { ... }) +``` + +The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()` in the chain. + +## Writing good descriptions + +The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr. + +**Must include:** + +- What the tool returns or does +- Which integrations/services it works with (if applicable) +- How to get required IDs (e.g., "Use integration_all to get the integrationId") +- Permission requirements (if non-obvious) + +**Good:** + +``` +"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all" +``` + +**Bad:** + +``` +"Get calendar events" +``` + +**For tools that return permission fields**, explain what they mean: + +``` +"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error" +``` + +## Registering in the MCP router + +After adding `.meta()` to procedures, you must register the router in `packages/api/src/mcp.ts`. This file uses **eager imports** (no `lazy()`) because MCP tool extraction requires synchronous access to all procedure definitions. + +```typescript +// packages/api/src/mcp.ts +import { myNewRouter } from "./router/my-new-feature"; + +export const mcpRouter = createTRPCRouter({ + // ... existing routers + myFeature: myNewRouter, +}); +``` + +**If your router is nested** (e.g., widget sub-routers), import and register the sub-router directly — do not import a parent that uses `lazy()`. + +## What to expose vs. what to skip + +**Expose:** + +- Data queries (list, search, get by ID) +- Actions users would ask an AI to do (toggle, create, delete, approve) +- Status checks (health, stats, summaries) + +**Skip:** + +- Subscriptions (WebSocket-only, not supported by MCP) +- Internal procedures (session management, onboarding steps) +- File upload/download procedures +- Procedures that return non-serializable data (streams, blobs) + +## Input schema rules + +- Use `z.object({...})` for inputs — this maps cleanly to MCP tool parameters +- `z.void()` (no `.input()`) works — the MCP handler patches these to accept empty objects +- Avoid `z.union()` or `z.discriminatedUnion()` at the top level — AI clients struggle with these +- Use `.default()` on optional fields so the AI doesn't need to guess values + +## Checklist for new features + +1. Add `.meta({ mcp: { enabled: true, description: "..." } })` to relevant procedures +2. Import and register the router in `packages/api/src/mcp.ts` (eager, no lazy) +3. Write descriptions that explain what the tool does, what it needs, and what it returns +4. If the tool requires an ID from another tool, say which one in the description +5. Test with `curl` against `/api/mcp/mcp` to verify the tool appears in `tools/list` diff --git a/.cursor/rules/documentation-sync.mdc b/.cursor/rules/documentation-sync.mdc deleted file mode 100644 index a05ca5d84..000000000 --- a/.cursor/rules/documentation-sync.mdc +++ /dev/null @@ -1,50 +0,0 @@ ---- -description: Keep documentation in sync with code changes -globs: - - packages/definitions/src/** - - packages/widgets/src/** - - packages/integrations/src/** - - packages/api/src/** - - apps/nextjs/src/** - - apps/docs/** ---- - -# Documentation Sync Rule - -When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`. - -## What triggers a doc update - -| Code change | Doc location | -|---|---| -| New integration | `apps/docs/docs/integrations//index.mdx` + `index.ts` | -| New widget | `apps/docs/docs/widgets//index.mdx` + `index.ts` | -| Changed API surface | `apps/docs/docs/management/api.mdx` | -| New/changed env vars | `apps/docs/docs/advanced/` | -| New CLI commands | `apps/docs/docs/advanced/command-line/` | -| Changed auth providers | `apps/docs/docs/advanced/` SSO pages | -| UI/UX changes | Relevant getting-started or management pages | -| New cron job | `apps/docs/docs/management/tasks.mdx` | -| Changed permissions | `apps/docs/docs/management/users.mdx` | - -## Integration doc pattern - -Each integration has a folder under `apps/docs/docs/integrations//`: - -- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path -- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets` -- Import metadata from `@homarr/definitions` where possible - -## Widget doc pattern - -Each widget has a folder under `apps/docs/docs/widgets//`: - -- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration -- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding` - -## Development - -- Run docs locally: `pnpm dev:docs` from root -- Build docs: `pnpm turbo build --filter=@homarr/docs` -- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata -- Docusaurus strict mode throws on broken links — fix any broken references before committing diff --git a/.cursor/skills/codebase-context/SKILL.md b/.cursor/skills/codebase-context/SKILL.md new file mode 100644 index 000000000..83301b2a4 --- /dev/null +++ b/.cursor/skills/codebase-context/SKILL.md @@ -0,0 +1,195 @@ +--- +name: codebase-context +description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code. +--- + +# Homarr Codebase Context + +Homarr is an open-source, self-hosted dashboard for managing homelab services. It integrates with 50+ self-hosted apps (media servers, download clients, DNS, NAS, etc.) and provides a drag-and-drop widget-based UI. + +## Tech Stack + +- **Monorepo**: pnpm workspaces + Turborepo +- **Framework**: Next.js (App Router, `output: "standalone"`) — T3 Stack +- **Language**: TypeScript throughout +- **ORM**: Drizzle (supports SQLite via better-sqlite3, MySQL, PostgreSQL) +- **API**: tRPC (HTTP + WebSocket subscriptions) with superjson + OpenAPI bridge via `trpc-to-openapi` +- **Auth**: NextAuth v5 (database sessions, Credentials/LDAP/OIDC providers) +- **UI**: Mantine v9 (not Tailwind) + Tabler icons +- **State**: Jotai (atoms), TanStack Query (server state via tRPC) +- **Realtime**: WebSocket server (`ws`) for tRPC subscriptions, backed by Redis pub/sub +- **Cron**: `node-cron` in a standalone Fastify service (`apps/tasks`) +- **i18n**: next-intl with `[locale]` dynamic segment (prefix mode: `"never"`, locale from cookie) +- **Testing**: Vitest + jsdom, Playwright for E2E +- **Lint/Format**: oxlint + oxfmt (not ESLint/Prettier) +- **Docs**: Docusaurus 3 in `apps/docs/` (`@homarr/docs`) +- **Package manager**: pnpm 10.34.1, Node >= 24.16.0 + +## Repository Structure + +``` +homarr/ +├── apps/ +│ ├── nextjs/ # Main Next.js application (port 3000) +│ ├── docs/ # Docusaurus 3 documentation site (@homarr/docs) +│ ├── tasks/ # Cron job runner + Fastify tRPC API (port 3002) +│ └── websocket/ # Standalone tRPC WebSocket server (port 3001) +├── packages/ +│ ├── api/ # tRPC appRouter, procedures, OpenAPI +│ ├── auth/ # NextAuth config, providers, session, API keys +│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries +│ ├── core/ # Env validation, DB/Redis driver factories, logging +│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions +│ ├── widgets/ # All 39 dashboard widgets (definitions + components) +│ ├── integrations/ # Integration classes (HTTP clients to external apps) +│ ├── redis/ # Redis pub/sub channels, caching abstractions +│ ├── translation/ # next-intl setup, locale configs, lang JSON files +│ ├── ui/ # Shared Mantine components, theme, hooks +│ ├── validation/ # Shared zod schemas for API/forms +│ ├── common/ # Shared utilities, IDs, errors +│ ├── cron-jobs/ # Cron job implementations (25+ jobs) +│ ├── cron-jobs-core/ # Cron scheduling primitives +│ ├── cron-job-api/ # tRPC router for job management (start/stop/trigger) +│ ├── cron-job-status/ # Cron status via Redis +│ ├── boards/ # Board context, edit mode, cache updater +│ ├── modals/ # Modal primitives on Mantine +│ ├── modals-collection/ # Feature modals (apps, boards, docker, etc.) +│ ├── form/ # useZodForm (Mantine + zod resolver) +│ ├── forms-collection/# Reusable form UIs (new app, icon picker, upload) +│ ├── spotlight/ # Command palette / search with multiple modes +│ ├── request-handler/ # Server request handlers (feeds, integrations) +│ ├── notifications/ # Mantine notifications wrapper +│ ├── docker/ # Dockerode-based Docker access +│ ├── icons/ # Icon DB/repo integration +│ ├── image-proxy/ # Image proxy + caching +│ ├── ping/ # Reachability / ping utilities +│ ├── analytics/ # Server-side analytics (Umami) +│ ├── server-settings/ # Server setting keys/types +│ ├── settings/ # User-facing settings UI context +│ └── cli/ # Node CLI for ops (brocli) +├── tooling/ +│ ├── typescript/ # Base tsconfig +│ └── github/ # CI setup action +├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL) +├── e2e/ # E2E test specs +└── Dockerfile # Multi-stage production build +``` + +## Three Runtime Services + +| Service | Port | Role | +| --------- | ---- | -------------------------------------------- | +| Next.js | 3000 | Main web app (SSR + API routes) | +| WebSocket | 3001 | tRPC subscriptions via `ws` | +| Tasks | 3002 | Cron job runner + management API via Fastify | + +In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header. + +## Package Dependency Layers + +1. **Foundation**: `core` (env, DB drivers, Redis client, logging) → no `@homarr` deps +2. **Domain**: `definitions` → `common` → `core` +3. **Data**: `db` → `core`, `definitions`, `common` +4. **Auth**: `auth` → `db`, `definitions`, `core`, `validation` +5. **Cross-cutting**: `translation`, `validation`, `redis`, `server-settings` +6. **Feature backends**: `integrations`, `request-handler`, `cron-jobs`, `docker`, `ping` +7. **API surface**: `api` → pulls in auth, db, redis, integrations, etc. +8. **UI stack**: `ui`, `modals`, `form`, `notifications`, `boards`, `spotlight` +9. **Composed features**: `widgets`, `modals-collection`, `forms-collection` + +## Key Patterns + +### tRPC + +- **Server caller** (RSC): `import { api } from "@homarr/api/server"` → cached caller with auth context +- **Client hooks**: `import { clientApi } from "@homarr/api/client"` → React Query hooks +- **Router structure**: `packages/api/src/root.ts` defines top-level namespaces (`user`, `board`, `widget`, `integration`, `docker`, `kubernetes`, etc.) +- **Procedures**: `publicProcedure`, `protectedProcedure`, `permissionRequiredProcedure`, `onboardingProcedure` +- **WebSocket subscriptions**: Client uses `wsLink` when `type === "subscription"`, connects to port 3001 + +### Multi-Database Support + +Drizzle schemas exist in three parallel implementations: + +- `packages/db/schema/sqlite.ts` +- `packages/db/schema/mysql.ts` +- `packages/db/schema/postgresql.ts` + +Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-postgres`). Migrations are per-engine in `packages/db/migrations/{sqlite,mysql,postgresql}/`. + +### Widget System + +Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record`). Each widget: + +- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })` +- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options +- Loads dynamically via `next/dynamic` +- Fetches data via tRPC client hooks (backed by cron job caches) + +### Integration System + +Integrations are defined in `packages/definitions/src/integration.ts` (`integrationDefs`) and instantiated via `createIntegrationAsync` in `packages/integrations/src/base/creator.ts`. Each extends abstract `Integration` class with `testConnectionAsync` and typed methods. + +### Cron Job System + +Jobs defined in `packages/cron-jobs/src/jobs/`. Each job: + +- Uses `createCronJob` with a cron expression +- Publishes results to Redis channels +- Widget subscriptions consume those channels via tRPC subscriptions +- Job management (start/stop/trigger) via `cronJobApi` HTTP client to port 3002 + +### Modals + +Created with `createModal(component).withOptions({ defaultTitle })` from `@homarr/modals`. Opened via `useModalAction(SomeModal)`. Feature modals live in `packages/modals-collection/`. + +### Forms + +`useZodForm` from `@homarr/form` wraps Mantine `useForm` with zod validation. Shared form UIs in `packages/forms-collection/`. + +### Routing (App Router) + +- `apps/nextjs/src/app/[locale]/` — all UI routes under locale segment +- Route groups: `(home)`, `(content)`, `(board)` — don't affect URLs +- Key routes: `boards/[name]`, `manage/...` (admin), `auth/login`, `init` (onboarding), `widgets/[kind]` +- Locale driven by cookie (no URL prefix) + +### Auth Providers + +Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `oidc`. Database sessions with httpOnly cookies. API key auth for programmatic access. + +## Environment Variables + +Key env vars (from `.env.example`): + +- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres` +- `DB_URL`: Database connection string +- `AUTH_SECRET`: NextAuth secret +- `SECRET_ENCRYPTION_KEY`: AES-256-CBC for integration secrets +- `AUTH_PROVIDERS`: Comma-separated (`credentials`, `ldap`, `oidc`) +- `CRON_JOB_API_KEY`: Shared key between Next.js and tasks service +- `REDIS_*`: Redis connection (host, port, password) +- `ENABLE_KUBERNETES`: Optional Kubernetes integration + +## Development Commands + +- `pnpm dev` — runs all three services in parallel via Turborepo +- `pnpm docker:dev` — starts Redis + MySQL + PostgreSQL containers +- `pnpm db:push` — push schema to SQLite (dev) +- `pnpm db:studio` — Drizzle Studio +- `pnpm test` — Vitest unit tests +- `pnpm test:e2e` — Playwright E2E +- `pnpm lint` / `pnpm format` — oxlint + oxfmt +- `pnpm dev:docs` — Docusaurus docs site (port 3003) +- `pnpm typecheck` — tsc --noEmit across all packages + +## Conventions + +- Workspace packages use `@homarr/` scope +- All versions managed via pnpm `catalog:` in workspace +- Path alias `~/*` → `./src/*` in Next.js app +- Mantine for all UI (no Tailwind) — primary color is red, `autoContrast: true` +- Icons from `@tabler/icons-react` +- Drag-and-drop via `@dnd-kit/*` +- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`) +- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom` diff --git a/.cursor/skills/datatable-migration/SKILL.md b/.cursor/skills/datatable-migration/SKILL.md new file mode 100644 index 000000000..62bbc94c3 --- /dev/null +++ b/.cursor/skills/datatable-migration/SKILL.md @@ -0,0 +1,691 @@ +--- +name: datatable-migration +description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets. +--- + +# Migrating Tables from mantine-react-table to mantine-datatable + +This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables. + +## Migration Candidates + +Current `mantine-react-table` consumers that could be migrated: + +**Widgets (in `packages/widgets/`):** + +- `docker/component.tsx` — Docker container table +- `media-server/component.tsx` — Media server sessions + +**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):** + +- `tools/docker/docker-table.tsx` +- `tools/tasks/_components/tasks-table.tsx` +- `tools/api/components/api-keys.tsx` +- `users/_components/user-list.tsx` +- `users/invites/_components/invite-list.tsx` +- `tools/kubernetes/` — 8 Kubernetes resource tables + +**Modals:** + +- `packages/modals-collection/src/search-engines/request-media-modal.tsx` + +**Shared hook to remove once all migrated:** + +- `packages/ui/src/hooks/use-translated-mantine-react-table.ts` + +## 1. Core Migration: mantine-react-table → mantine-datatable + +### Import changes + +```typescript +// BEFORE +import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; +import type { MRT_ColumnDef } from "mantine-react-table"; + +// AFTER +import { DataTable, useDataTableColumns } from "mantine-datatable"; +import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; +``` + +### Column definition shape + +```typescript +// BEFORE (MRT) +const columns: MRT_ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + size: 200, + enableSorting: true, + Cell: ({ row }) => {row.original.name}, + }, +]; + +// AFTER (mantine-datatable) +const columns: DataTableColumn[] = [ + { + accessor: "name", // not accessorKey + title: "Name", // not header + width: 200, // not size + sortable: true, // not enableSorting + render: (record) => {record.name}, // not Cell + }, +]; +``` + +Key differences: + +- `accessorKey` → `accessor` +- `header` → `title` +- `size` → `width` +- `Cell: ({ row }) =>` → `render: (record) =>` +- `enableSorting` → `sortable` +- `enableColumnActions` → does not exist (no column action menus) +- `mantineTableBodyCellProps` → `cellsStyle` in `defaultColumnProps` + +### Table component + +```typescript +// BEFORE +const table = useTranslatedMantineReactTable({ columns, data, ... }); +return ; + +// AFTER +return ( + +); +``` + +### Sorting is manual + +mantine-datatable does NOT sort for you. You must sort the data yourself: + +```typescript +const [sortStatus, setSortStatus] = useState>({ + columnAccessor: "name", + direction: "asc", +}); + +const sortedData = useMemo(() => { + const mult = sortStatus.direction === "desc" ? -1 : 1; + return [...data].toSorted((a, b) => { + const aVal = a[sortStatus.columnAccessor]; + const bVal = b[sortStatus.columnAccessor]; + if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult; + return ((aVal as number) - (bVal as number)) * mult; + }); +}, [data, sortStatus]); + +// Pass sortedData to records, not data + +``` + +## 2. Making Tables Transparent (Widget Context) + +Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque. + +Create a CSS file alongside the component: + +```css +/* styles.css */ +.my-table, +.my-table .mantine-datatable-table, +.my-table .mantine-datatable-table thead, +.my-table .mantine-datatable-table tbody, +.my-table .mantine-datatable-table tfoot, +.my-table th, +.my-table td { + background-color: transparent !important; +} + +.my-table .mantine-datatable-table tr { + background-color: transparent; +} + +/* Hover: translucent instead of opaque */ +.my-table .mantine-datatable-table tbody tr:hover { + background-color: color-mix( + in srgb, + var(--mantine-color-default-hover) 40%, + transparent + ) !important; +} + +/* Headers: frosted glass effect */ +.my-table th { + white-space: nowrap; + background-color: color-mix( + in srgb, + var(--mantine-color-body) 60%, + transparent + ) !important; + backdrop-filter: blur(8px); +} + +/* Cell overflow */ +.my-table td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Hide sort icon until hover */ +.my-table .mantine-datatable-header-cell-sortable-icon { + opacity: 0; + transition: opacity 150ms; +} +.my-table th:hover .mantine-datatable-header-cell-sortable-icon { + opacity: 1; +} + +/* Row expansion area */ +.my-table .mantine-datatable-row-expansion-cell { + background-color: color-mix( + in srgb, + var(--mantine-color-body) 40%, + transparent + ) !important; +} +``` + +Import it: `import "./styles.css";` and apply via `className="my-table"`. + +## 3. Responsive Column Visibility + +Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop. + +```typescript +interface SizeConfig { + fontSize: "xs" | "sm"; + iconSize: number; + cellPadding: number; + showSpeedColumns: boolean; + showTimeColumn: boolean; + showStateColumn: boolean; +} + +const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [ + { + maxWidth: 300, + config: { + fontSize: "xs", + iconSize: 12, + cellPadding: 2, + showSpeedColumns: false, + showTimeColumn: false, + showStateColumn: false, + }, + }, + { + maxWidth: 500, + config: { + fontSize: "xs", + iconSize: 14, + cellPadding: 4, + showSpeedColumns: false, + showTimeColumn: true, + showStateColumn: false, + }, + }, +]; + +const DEFAULT_SIZE_CONFIG: SizeConfig = { + fontSize: "sm", + iconSize: 14, + cellPadding: 4, + showSpeedColumns: true, + showTimeColumn: true, + showStateColumn: true, +}; + +function getSizeConfig(width: number): SizeConfig { + for (const { maxWidth, config } of SIZE_BREAKPOINTS) { + if (width < maxWidth) return config; + } + return DEFAULT_SIZE_CONFIG; +} + +// Usage: memoize it +const size = useMemo(() => getSizeConfig(width), [width]); +``` + +Then use a visibility check lookup table for columns: + +```typescript +const columnVisibilityChecks: Partial< + Record boolean> +> = { + upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, + downSpeed: (ctx) => ctx.size.showSpeedColumns, + time: (ctx) => ctx.size.showTimeColumn, + state: (ctx) => ctx.size.showStateColumn, +}; + +function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean { + if (!ctx.optionsColumnSet.has(accessor)) return false; + const check = columnVisibilityChecks[accessor]; + if (check && !check(ctx)) return false; + return true; +} +``` + +## 4. Column Persistence (Drag/Resize → Server) + +`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices. + +### Hidden widget options + +In `index.ts`, add hidden text options: + +```typescript +columnOrder: factory.text({ defaultValue: "" }), +columnWidths: factory.text({ defaultValue: "" }), +``` + +With visibility override: + +```typescript +{ + columnOrder: { shouldHide: () => true }, + columnWidths: { shouldHide: () => true }, +} +``` + +### Translation keys required + +Even hidden options need translation keys or the translation test fails: + +```json +"widget.mywidget.option.columnOrder.label": "Column order", +"widget.mywidget.option.columnWidths.label": "Column widths" +``` + +### Dynamic storeKey + +Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding: + +```typescript +const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`; +``` + +### Hydration from server → localStorage + +On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back: + +```typescript +const { + effectiveColumns, + columnsOrder, + columnsWidth, + setColumnsOrder, + setMultipleColumnWidths, +} = useDataTableColumns({ key: storeKey, columns }); + +const lastStoreKey = useRef(storeKey); +const hydrated = useRef(false); + +useEffect(() => { + if (lastStoreKey.current !== storeKey) { + lastStoreKey.current = storeKey; + hydrated.current = false; + } + if (hydrated.current) return; + if (savedOrder.length > 0) setColumnsOrder(savedOrder); + if (Object.keys(savedWidths).length > 0) { + setMultipleColumnWidths( + Object.entries(savedWidths).map(([accessor, w]) => ({ + accessor, + width: w, + })), + ); + } + requestAnimationFrame(() => { + hydrated.current = true; + }); +}, [ + storeKey, + savedOrder, + savedWidths, + setColumnsOrder, + setMultipleColumnWidths, +]); +``` + +### Persist localStorage → server + +Watch for changes and write back: + +```typescript +const prevOrder = useRef(columnsOrder); +const prevWidths = useRef(columnsWidth); + +useEffect(() => { + if (!hydrated.current) return; + const orderChanged = + JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); + const widthsChanged = + JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); + prevOrder.current = columnsOrder; + prevWidths.current = columnsWidth; + + if (!orderChanged && !widthsChanged) return; + + if (orderChanged) + persistOption({ columnOrder: JSON.stringify(columnsOrder) }); + if (widthsChanged) { + const widthMap: Record = {}; + for (const entry of columnsWidth) { + const key = Object.keys(entry)[0]; + if (!key) continue; + const v = entry[key as keyof typeof entry]; + if (typeof v === "number") widthMap[key] = v; + else if (typeof v === "string" && v.endsWith("px")) + widthMap[key] = parseInt(v, 10); + } + persistOption({ columnWidths: JSON.stringify(widthMap) }); + } +}, [columnsOrder, columnsWidth, persistOption]); +``` + +The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation). + +## 5. Context Menu (Right-Click) + +Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly: + +```typescript +interface ContextMenuState { + x: number; + y: number; + item: MyRow; +} + +const [contextMenu, setContextMenu] = useState(null); + +const handleContextMenu = useCallback( + ({ record, event }: { record: MyRow; event: React.MouseEvent }) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, item: record }); + }, + [], +); + +// On DataTable: + + +// Render: +{contextMenu && } +``` + +The menu component uses `Portal` for correct z-index: + +```tsx +function RowContextMenu({ + state, + onClose, +}: { + state: ContextMenuState; + onClose: () => void; +}) { + return ( + + + + + + + { + /* action */ onClose(); + }} + > + Action + + + + + ); +} +``` + +**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`. + +## 6. Row Expansion + +```typescript + , + }} +/> +``` + +**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work. + +## 7. Common Pitfalls & Fixes + +### Status indicator dots + +Don't use `` without children — it renders a pill with empty space. Use a plain `Box`: + +```tsx + +``` + +### Tooltip overflow on long text columns + +When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix: + +```tsx + + + ... + + +``` + +Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally. + +### Dark mode tooltips + +Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets. + +### Division by zero + +Guard all ratio calculations: + +```typescript +if (item.size > 0) ratio = item.sent / item.size; +if (totalDown > 0) ratio = totalUp / totalDown; +``` + +### Composite idAccessor + +When rows can come from multiple integrations, IDs may collide. Use a composite key: + +```typescript +idAccessor={(record) => `${record.integration.id}:${record.id}`} +``` + +### JSON option parsing + +Always wrap in try/catch: + +```typescript +function parseJsonOption(value: string | undefined, fallback: T): T { + if (!value) return fallback; + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} +``` + +### Clipboard API + +`navigator.clipboard.writeText()` can throw in non-HTTPS contexts. Always catch: + +```typescript +void navigator.clipboard.writeText(text).catch(() => {}); +``` + +### Sync sortStatus with widget options + +If the widget has configurable default sort, sync it: + +```typescript +useEffect(() => { + setSortStatus({ + columnAccessor: options.defaultSort, + direction: defaultSortDirection, + }); +}, [options.defaultSort, defaultSortDirection]); +``` + +## 8. Accessibility + +- All `ActionIcon` components need `aria-label` +- Filter toggles (client badges, status badges) need `aria-pressed={isActive}` +- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize + +## 9. Error Handling + +- Every mutation needs an `onError` callback with `showErrorNotification` +- The `saveItemOptions` mutation (column persistence) also needs `onError` +- Check `isError` on the main query and show a fallback UI +- Use a shared `mutationOptions` object to avoid repeating `onSettled`/`onError`: + +```typescript +const mutationOptions = { + onSettled: () => void utils.myQuery.invalidate(), + onError: () => + showErrorNotification({ + title: t("errors.actionFailed"), + message: t("errors.actionFailedMessage"), + }), +}; +``` + +## 10. i18n + +- No hardcoded strings. Every user-visible string goes through `useScopedI18n` +- Column titles: `t("items.{accessor}.columnTitle")` +- Detail labels: `t("items.{accessor}.detailsTitle")` +- States: `t("states.{state}")` +- Actions: `t("actions.{action}")` +- Errors: `t("errors.{errorKey}")` +- Hidden options still need `.label` translation keys + +## 11. Edit Mode + +Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop: + +```typescript +let tablePointerEvents: React.CSSProperties["pointerEvents"]; +if (isEditMode) tablePointerEvents = "none"; + +let rowContextMenuHandler = handleContextMenu; +if (isEditMode) rowContextMenuHandler = undefined; + + +``` + +## 12. DataTable Props Reference (Commonly Used) + +```typescript + record.uniqueKey} + height="100%" + className="my-table" + defaultColumnProps={{ + noWrap: true, + draggable: true, + resizable: true, + cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }), + }} + scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} + rowBackgroundColor={(record) => conditionalBgMap[record.state]} + onRowContextMenu={handleContextMenu} + rowExpansion={{ ... }} + onScroll={() => { if (contextMenu) closeContextMenu(); }} +/> +``` + +## 13. Layout Stability + +- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic +- Give non-name columns explicit `width` values so only the name column flexes +- Use `ellipsis: true` on the name column — it handles `text-overflow: ellipsis` internally +- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length +- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names + +## 14. Checklist for Each Migration + +1. Replace imports (`mantine-react-table` → `mantine-datatable`) +2. Convert column definitions (`accessorKey` → `accessor`, `header` → `title`, `Cell` → `render`, etc.) +3. Implement manual sorting with `useState` + `useMemo` +4. Add CSS file for transparent backgrounds (if widget context) +5. Add responsive column visibility (if widget context) +6. Add hidden options + hydration logic (if column persistence needed) +7. Add translation keys (including hidden option labels) +8. Add context menu if row actions exist +9. Add row expansion if detail view exists +10. Add error handling on all mutations +11. Add accessibility attributes +12. Test at multiple widget widths +13. Update docs in `apps/docs/docs/widgets/` if applicable +14. Run `pnpm turbo typecheck` +15. Run translation spec — catches missing keys diff --git a/.cursor/skills/documentation-sync/SKILL.md b/.cursor/skills/documentation-sync/SKILL.md new file mode 100644 index 000000000..48aed0e7e --- /dev/null +++ b/.cursor/skills/documentation-sync/SKILL.md @@ -0,0 +1,44 @@ +--- +name: documentation-sync +description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern. +--- + +# Documentation Sync + +When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`. + +## What triggers a doc update + +| Code change | Doc location | +| ---------------------- | ----------------------------------------------------------- | +| New integration | `apps/docs/docs/integrations//index.mdx` + `index.ts` | +| New widget | `apps/docs/docs/widgets//index.mdx` + `index.ts` | +| Changed API surface | `apps/docs/docs/management/api.mdx` | +| New/changed env vars | `apps/docs/docs/advanced/` | +| New CLI commands | `apps/docs/docs/advanced/command-line/` | +| Changed auth providers | `apps/docs/docs/advanced/` SSO pages | +| UI/UX changes | Relevant getting-started or management pages | +| New cron job | `apps/docs/docs/management/tasks.mdx` | +| Changed permissions | `apps/docs/docs/management/users.mdx` | + +## Integration doc pattern + +Each integration has a folder under `apps/docs/docs/integrations//`: + +- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path +- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets` +- Import metadata from `@homarr/definitions` where possible + +## Widget doc pattern + +Each widget has a folder under `apps/docs/docs/widgets//`: + +- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration +- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding` + +## Development + +- Run docs locally: `pnpm dev:docs` from root +- Build docs: `pnpm turbo build --filter=@homarr/docs` +- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata +- Docusaurus strict mode throws on broken links — fix any broken references before committing diff --git a/.cursor/skills/mcp-integration/SKILL.md b/.cursor/skills/mcp-integration/SKILL.md new file mode 100644 index 000000000..b1eb8f611 --- /dev/null +++ b/.cursor/skills/mcp-integration/SKILL.md @@ -0,0 +1,108 @@ +--- +name: mcp-integration +description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip. +--- + +# MCP Integration + +Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr. + +## Adding MCP to a procedure + +Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain: + +```typescript +// Read-only tool (query) +myProcedure: protectedProcedure + .meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } }) + .input(z.object({ limit: z.number().default(10) })) + .query(async ({ ctx, input }) => { ... }) + +// Write tool (mutation) +myAction: protectedProcedure + .meta({ mcp: { enabled: true, description: "Delete an item by ID. Requires interact permission on the integration" } }) + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { ... }) + +// No-input procedure (z.void) — works fine, no .input() needed +getAll: protectedProcedure + .meta({ mcp: { enabled: true, description: "List all resources" } }) + .query(async ({ ctx }) => { ... }) +``` + +The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()` in the chain. + +## Writing good descriptions + +The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr. + +**Must include:** + +- What the tool returns or does +- Which integrations/services it works with (if applicable) +- How to get required IDs (e.g., "Use integration_all to get the integrationId") +- Permission requirements (if non-obvious) + +**Good:** + +``` +"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all" +``` + +**Bad:** + +``` +"Get calendar events" +``` + +**For tools that return permission fields**, explain what they mean: + +``` +"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error" +``` + +## Registering in the MCP router + +After adding `.meta()` to procedures, you must register the router in `packages/api/src/mcp.ts`. This file uses **eager imports** (no `lazy()`) because MCP tool extraction requires synchronous access to all procedure definitions. + +```typescript +// packages/api/src/mcp.ts +import { myNewRouter } from "./router/my-new-feature"; + +export const mcpRouter = createTRPCRouter({ + // ... existing routers + myFeature: myNewRouter, +}); +``` + +**If your router is nested** (e.g., widget sub-routers), import and register the sub-router directly — do not import a parent that uses `lazy()`. + +## What to expose vs. what to skip + +**Expose:** + +- Data queries (list, search, get by ID) +- Actions users would ask an AI to do (toggle, create, delete, approve) +- Status checks (health, stats, summaries) + +**Skip:** + +- Subscriptions (WebSocket-only, not supported by MCP) +- Internal procedures (session management, onboarding steps) +- File upload/download procedures +- Procedures that return non-serializable data (streams, blobs) + +## Input schema rules + +- Use `z.object({...})` for inputs — this maps cleanly to MCP tool parameters +- `z.void()` (no `.input()`) works — the MCP handler patches these to accept empty objects +- Avoid `z.union()` or `z.discriminatedUnion()` at the top level — AI clients struggle with these +- Use `.default()` on optional fields so the AI doesn't need to guess values + +## Checklist for new features + +1. Add `.meta({ mcp: { enabled: true, description: "..." } })` to relevant procedures +2. Import and register the router in `packages/api/src/mcp.ts` (eager, no lazy) +3. Write descriptions that explain what the tool does, what it needs, and what it returns +4. If the tool requires an ID from another tool, say which one in the description +5. Test with `curl` against `/api/mcp/mcp` to verify the tool appears in `tools/list` diff --git a/.gitignore b/.gitignore index 71a27829a..932d6c9c8 100644 --- a/.gitignore +++ b/.gitignore @@ -71,7 +71,8 @@ apps/nextjs/public/images/background.png # personal agent config .opencode -.agents/ +.agents/* +!.agents/skills/ skills-lock.json # docusaurus diff --git a/AGENTS.md b/AGENTS.md index 6c3d36da4..d4c5c58a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,12 @@ When modifying user-facing code, you MUST also update the corresponding document - Lint: oxlint (not ESLint) - Format: oxfmt (not Prettier) - UI: Mantine (not Tailwind) — Tailwind is only used in docs app +- Mantine: use the `mantine` MCP server (`get_item_doc` / `get_item_props` / `search_docs`) for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox` for selects, the polymorphic `component` prop, `@mantine/hooks`) over hand-rolled equivalents. Check `packages/ui/` for existing conventions first. + +## Agent Skills + +Portable agent skills live in `.agents/skills/` (the Agent Skills open standard) and are mirrored into `.cursor/skills/` and `.claude/skills/` so they work across Claude Code, Cursor, Codex, and OpenCode. Install at `.agents/skills//SKILL.md`; mirror new skills into the other two directories. See `.agents/skills/` for available skills (documentation-sync, mcp-integration, codebase-context, datatable-migration). + - Icons: @tabler/icons-react - Docs app can import from `@homarr/definitions` for shared types - Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.