diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.claude/skills/codebase-context/SKILL.md b/.claude/skills/codebase-context/SKILL.md deleted file mode 100644 index 83301b2a4..000000000 --- a/.claude/skills/codebase-context/SKILL.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -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 deleted file mode 100644 index 62bbc94c3..000000000 --- a/.claude/skills/datatable-migration/SKILL.md +++ /dev/null @@ -1,691 +0,0 @@ ---- -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 deleted file mode 100644 index 48aed0e7e..000000000 --- a/.claude/skills/documentation-sync/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -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 deleted file mode 100644 index b1eb8f611..000000000 --- a/.claude/skills/mcp-integration/SKILL.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -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/skills/codebase-context/SKILL.md b/.cursor/skills/codebase-context/SKILL.md deleted file mode 100644 index 83301b2a4..000000000 --- a/.cursor/skills/codebase-context/SKILL.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -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 deleted file mode 100644 index 62bbc94c3..000000000 --- a/.cursor/skills/datatable-migration/SKILL.md +++ /dev/null @@ -1,691 +0,0 @@ ---- -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 deleted file mode 100644 index 48aed0e7e..000000000 --- a/.cursor/skills/documentation-sync/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -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 deleted file mode 100644 index b1eb8f611..000000000 --- a/.cursor/skills/mcp-integration/SKILL.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -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/AGENTS.md b/AGENTS.md index d4c5c58a9..a64de00ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ When modifying user-facing code, you MUST also update the corresponding document ## 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). +Portable agent skills live in `.agents/skills/` (the Agent Skills open standard). Cursor, Codex, and OpenCode read `.agents/skills/` natively. Claude Code does not scan it, so `.claude/skills/` is a symlink back to `.agents/skills/`. Install skills at `.agents/skills//SKILL.md` — that single copy is used everywhere. 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