Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f136daea | ||
|
|
f351929e5b |
No files matched your search
@@ -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
|
|
||||||
|
|
||||||
```text
|
|
||||||
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<WidgetKind, ...>`). 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`
|
|
||||||
@@ -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<MyRow>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
header: "Name",
|
|
||||||
size: 200,
|
|
||||||
enableSorting: true,
|
|
||||||
Cell: ({ row }) => <Text>{row.original.name}</Text>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// AFTER (mantine-datatable)
|
|
||||||
const columns: DataTableColumn<MyRow>[] = [
|
|
||||||
{
|
|
||||||
accessor: "name", // not accessorKey
|
|
||||||
title: "Name", // not header
|
|
||||||
width: 200, // not size
|
|
||||||
sortable: true, // not enableSorting
|
|
||||||
render: (record) => <Text>{record.name}</Text>, // 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 <MantineReactTable table={table} />;
|
|
||||||
|
|
||||||
// AFTER
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
records={data}
|
|
||||||
columns={columns}
|
|
||||||
sortStatus={sortStatus}
|
|
||||||
onSortStatusChange={setSortStatus}
|
|
||||||
...
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sorting is manual
|
|
||||||
|
|
||||||
mantine-datatable does NOT sort for you. You must sort the data yourself:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<MyRow>>({
|
|
||||||
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
|
|
||||||
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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<string, (ctx: ColumnContext) => 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<MyRow>({ 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<string, number> = {};
|
|
||||||
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<ContextMenuState | null>(null);
|
|
||||||
|
|
||||||
const handleContextMenu = useCallback(
|
|
||||||
({ record, event }: { record: MyRow; event: React.MouseEvent }) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setContextMenu({ x: event.clientX, y: event.clientY, item: record });
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// On DataTable:
|
|
||||||
<DataTable onRowContextMenu={handleContextMenu} />
|
|
||||||
|
|
||||||
// Render:
|
|
||||||
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} />}
|
|
||||||
```
|
|
||||||
|
|
||||||
The menu component uses `Portal` for correct z-index:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
function RowContextMenu({
|
|
||||||
state,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
state: ContextMenuState;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Portal>
|
|
||||||
<Menu
|
|
||||||
opened
|
|
||||||
onClose={onClose}
|
|
||||||
closeOnItemClick={false}
|
|
||||||
position="right-start"
|
|
||||||
offset={0}
|
|
||||||
>
|
|
||||||
<Menu.Target>
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
left: state.x,
|
|
||||||
top: state.y,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => {
|
|
||||||
/* action */ onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Action
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**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
|
|
||||||
<DataTable
|
|
||||||
rowExpansion={{
|
|
||||||
trigger: "click",
|
|
||||||
allowMultiple: true,
|
|
||||||
collapseProps: {
|
|
||||||
transitionDuration: 200,
|
|
||||||
animateOpacity: true,
|
|
||||||
transitionTimingFunction: "ease-out",
|
|
||||||
},
|
|
||||||
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**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 `<Badge variant="dot" />` without children — it renders a pill with empty space. Use a plain `Box`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Box
|
|
||||||
w={8}
|
|
||||||
h={8}
|
|
||||||
style={{
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: `var(--mantine-color-${color}-filled)`,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
<Tooltip position="bottom-start" ...>
|
|
||||||
<Group style={{ overflow: "hidden" }}>
|
|
||||||
...
|
|
||||||
</Group>
|
|
||||||
</Tooltip>
|
|
||||||
```
|
|
||||||
|
|
||||||
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<T>(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;
|
|
||||||
|
|
||||||
<DataTable style={{ pointerEvents: tablePointerEvents }} />
|
|
||||||
```
|
|
||||||
|
|
||||||
## 12. DataTable Props Reference (Commonly Used)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
<DataTable
|
|
||||||
records={sortedData}
|
|
||||||
columns={effectiveColumns}
|
|
||||||
storeColumnsKey={storeKey}
|
|
||||||
withTableBorder={false}
|
|
||||||
borderRadius={0}
|
|
||||||
highlightOnHover
|
|
||||||
striped="odd"
|
|
||||||
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
|
||||||
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
|
||||||
verticalAlign="center"
|
|
||||||
fetching={isFetching && data.length === 0}
|
|
||||||
loaderBackgroundBlur={2}
|
|
||||||
fz={size.fontSize}
|
|
||||||
textSelectionDisabled
|
|
||||||
sortStatus={sortStatus}
|
|
||||||
onSortStatusChange={setSortStatus}
|
|
||||||
noRecordsText={t("errors.noItems")}
|
|
||||||
idAccessor={(record) => 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
|
|
||||||
@@ -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/<slug>/index.mdx` + `index.ts` |
|
|
||||||
| New widget | `apps/docs/docs/widgets/<slug>/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/<slug>/`:
|
|
||||||
|
|
||||||
- `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/<slug>/`:
|
|
||||||
|
|
||||||
- `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
|
|
||||||
@@ -1,110 +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.
|
|
||||||
|
|
||||||
Exposing a procedure makes it callable by any AI client with an API key. Before adding `.meta({ mcp })`, review what the tool exposes: authorization requirements, sensitive data, tenant isolation, auditability, and whether the action is destructive. Prefer exposing read-only queries over mutations unless the mutation is safe and permission-scoped. When in doubt, ask for a security review before shipping a new MCP tool.
|
|
||||||
|
|
||||||
## 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:**
|
|
||||||
|
|
||||||
```text
|
|
||||||
"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:**
|
|
||||||
|
|
||||||
```text
|
|
||||||
"Get calendar events"
|
|
||||||
```
|
|
||||||
|
|
||||||
**For tools that return permission fields**, explain what they mean:
|
|
||||||
|
|
||||||
```text
|
|
||||||
"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`
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../.agents/skills
|
|
||||||
@@ -3,6 +3,9 @@ version = 1
|
|||||||
[[analyzers]]
|
[[analyzers]]
|
||||||
name = "javascript"
|
name = "javascript"
|
||||||
|
|
||||||
[analyzers.meta]
|
[analyzers.meta]
|
||||||
plugins = ["react"]
|
plugins = ["react"]
|
||||||
environment = ["nodejs"]
|
environment = ["nodejs"]
|
||||||
|
|
||||||
|
[[transformers]]
|
||||||
|
name = "prettier"
|
||||||
@@ -6,20 +6,7 @@ npm-debug.log
|
|||||||
README.md
|
README.md
|
||||||
.next
|
.next
|
||||||
.turbo
|
.turbo
|
||||||
.pnpm-store
|
|
||||||
**/.pnpm-store
|
|
||||||
coverage
|
|
||||||
**/coverage
|
|
||||||
**/.docusaurus
|
|
||||||
**/build
|
|
||||||
.git
|
.git
|
||||||
.github
|
|
||||||
dev
|
dev
|
||||||
.build
|
.build
|
||||||
e2e
|
e2e
|
||||||
apps/docs/*
|
|
||||||
!apps/docs/package.json
|
|
||||||
apps/nextjs/.next
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
@@ -41,29 +41,8 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
|||||||
|
|
||||||
TURBO_TELEMETRY_DISABLED=1
|
TURBO_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Turborepo remote cache (optional, for faster CI/local builds)
|
|
||||||
# Uncomment and fill in to enable remote caching locally.
|
|
||||||
# CI uses GitHub secrets for these values automatically.
|
|
||||||
# TURBO_API=https://your-cache-worker.workers.dev
|
|
||||||
# TURBO_TEAM=team_your_team
|
|
||||||
# TURBO_TOKEN=your-turbo-token
|
|
||||||
# TURBO_REMOTE_CACHE_SIGNATURE_KEY=your-signature-key
|
|
||||||
|
|
||||||
# Comma-separated paths to Docker-compatible Unix sockets.
|
|
||||||
# Useful for connecting to multiple runtimes (Docker + Podman) simultaneously.
|
|
||||||
# When unset and no TCP hosts configured, defaults to /var/run/docker.sock.
|
|
||||||
# Example: DOCKER_SOCKET_PATHS=/var/run/docker.sock,/run/user/1000/podman/podman.sock
|
|
||||||
# DOCKER_SOCKET_PATHS=
|
|
||||||
|
|
||||||
# Enable kubernetes tool
|
# Enable kubernetes tool
|
||||||
# ENABLE_KUBERNETES=true
|
# ENABLE_KUBERNETES=true
|
||||||
|
|
||||||
# Enable mock integration
|
# Enable mock integration
|
||||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||||
|
|
||||||
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
|
|
||||||
# DEMO_MODE=true
|
|
||||||
|
|
||||||
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
|
|
||||||
# Set to false for writable preview deployments so the demo user can modify the dashboard.
|
|
||||||
# DEMO_READ_ONLY=false
|
|
||||||
@@ -33,24 +33,6 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
- 1.75.0
|
|
||||||
- 1.74.0
|
|
||||||
- 1.73.0
|
|
||||||
- 1.72.0
|
|
||||||
- 1.71.0
|
|
||||||
- 1.70.0
|
|
||||||
- 1.69.2
|
|
||||||
- 1.69.0
|
|
||||||
- 1.68.0
|
|
||||||
- 1.67.0
|
|
||||||
- 1.66.1
|
|
||||||
- 1.66.0
|
|
||||||
- 1.65.0
|
|
||||||
- 1.64.0
|
|
||||||
- 1.62.0
|
|
||||||
- 1.61.0
|
|
||||||
- 1.60.1-beta.1
|
|
||||||
- 1.60.0
|
|
||||||
- 1.59.3
|
- 1.59.3
|
||||||
- 1.59.2
|
- 1.59.2
|
||||||
- 1.59.1
|
- 1.59.1
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ body:
|
|||||||
label: Public API available?
|
label: Public API available?
|
||||||
description: Is there a public API available, that we can consume in Homarr?
|
description: Is there a public API available, that we can consume in Homarr?
|
||||||
options:
|
options:
|
||||||
- "Yes, available on a website"
|
- Yes, available on a website
|
||||||
- "Yes, available in the application itself"
|
- Yes, available in the application itself
|
||||||
- "No"
|
- No
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
attributes:
|
attributes:
|
||||||
label: Are you willing to contribute this yourself?
|
label: Are you willing to contribute this yourself?
|
||||||
options:
|
options:
|
||||||
- "Yes"
|
- Yes
|
||||||
- "No"
|
- No
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
@@ -24,7 +24,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Are you willing to contribute this yourself?
|
label: Are you willing to contribute this yourself?
|
||||||
options:
|
options:
|
||||||
- "Yes"
|
- Yes
|
||||||
- "No"
|
- No
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
.secrets
|
|
||||||
!.secrets.example
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
TURBO_API=
|
|
||||||
TURBO_TEAM=
|
|
||||||
TURBO_TOKEN=
|
|
||||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Running CI locally
|
|
||||||
|
|
||||||
Install [Act](https://nektosact.com/installation/index.html) and ensure Docker is running, then use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm ci:act:fast
|
|
||||||
pnpm ci:act:docker
|
|
||||||
pnpm ci:act:all
|
|
||||||
pnpm ci:act:cleanup
|
|
||||||
```
|
|
||||||
|
|
||||||
`fast` runs the quality gate. `docker` and `all` run the gate followed by the single container build, E2E suite, and memory regression test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
|
|
||||||
|
|
||||||
Remote caching is optional locally. Copy `.github/act/.secrets.example` to `.github/act/.secrets`, populate it with rotated credentials, and run with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ACT_SECRET_FILE=.github/act/.secrets pnpm ci:act:all
|
|
||||||
```
|
|
||||||
|
|
||||||
Act never publishes or deletes GHCR images. The secret file is ignored by Git.
|
|
||||||
|
|
||||||
## PR preview images
|
|
||||||
|
|
||||||
Successful non-draft pull requests from this repository publish an amd64 image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull ghcr.io/homarr-labs/homarr-test:pr-<number>
|
|
||||||
```
|
|
||||||
|
|
||||||
The tag is replaced when the pull request is updated and deleted when it closes. Production Homarr images remain the supported multi-architecture releases. The `homarr-test` package must be configured as public in GHCR for unauthenticated testers.
|
|
||||||
|
|
||||||
## Dokploy preview cache
|
|
||||||
|
|
||||||
Keep `needs-demo` as the Dokploy preview label. Under the application's **Preview Build-time Secrets**, configure `TURBO_API`, `TURBO_TEAM`, `TURBO_TOKEN`, and `TURBO_REMOTE_CACHE_SIGNATURE_KEY`. Dokploy passes these to the Dockerfile as BuildKit secrets. Do not configure the token or signature key as Docker build arguments.
|
|
||||||
|
|
||||||
Rotate any credentials that have appeared in chat, logs, or committed configuration before enabling the cache.
|
|
||||||
@@ -11,7 +11,7 @@ outputs:
|
|||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
|||||||
@@ -11,11 +11,3 @@
|
|||||||
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
|
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||||
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
|
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
|
||||||
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
|
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
|
||||||
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
|
|
||||||
|
|
||||||
**When contributing a new integration or widget:**
|
|
||||||
|
|
||||||
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
|
|
||||||
tests (e.g. what happens if it is offline)
|
|
||||||
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
|
|
||||||
provide support and maintain the integration (e.g. in case of breaking or failure)
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
extends: ["config:recommended", ":dependencyDashboardApproval"],
|
extends: ["config:recommended"],
|
||||||
packageRules: [
|
packageRules: [
|
||||||
{
|
{
|
||||||
matchPackageNames: ["^@homarr/"],
|
matchPackageNames: ["^@homarr/"],
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
automerge: true,
|
automerge: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// disallow automerge for allowBuilds dependencies (native builds need manual verification)
|
// disallow automerge for onlyBuiltDependencies
|
||||||
matchPackageNames: [
|
matchPackageNames: [
|
||||||
"@tree-sitter-grammars/tree-sitter-yaml",
|
"@tree-sitter-grammars/tree-sitter-yaml",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
updateInternalDeps: true,
|
updateInternalDeps: true,
|
||||||
rangeStrategy: "bump",
|
rangeStrategy: "bump",
|
||||||
automerge: false,
|
automerge: false,
|
||||||
baseBranchPatterns: ["dev"],
|
baseBranches: ["dev"],
|
||||||
dependencyDashboard: true,
|
dependencyDashboard: false,
|
||||||
// prevent special characters in branch names (otherwise git errors occur on windows)
|
// prevent special characters in branch names (otherwise git errors occur on windows)
|
||||||
branchNameStrict: true,
|
branchNameStrict: true,
|
||||||
}
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
name: Auto-fix lint and format
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened]
|
|
||||||
branches: ["*"]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
autofix:
|
|
||||||
name: Auto-fix
|
|
||||||
if: github.event.pull_request.head.repo.id == github.repository_id
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.head_ref }}
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Get changed files
|
|
||||||
id: changed
|
|
||||||
env:
|
|
||||||
BASE_REF: ${{ github.base_ref }}
|
|
||||||
run: |
|
|
||||||
git diff --name-only --diff-filter=ACMR "origin/${BASE_REF}...HEAD" -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
|
|
||||||
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Auto-fix lint + format
|
|
||||||
if: steps.changed.outputs.count != '0'
|
|
||||||
run: |
|
|
||||||
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
|
|
||||||
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
|
|
||||||
|
|
||||||
- name: Commit fixes
|
|
||||||
uses: stefanzweifel/git-auto-commit-action@v7
|
|
||||||
with:
|
|
||||||
commit_message: "style: auto-fix lint and format"
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: "[Dependency Updates] Auto Approve"
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
approve-automatic-prs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 2
|
||||||
|
# 158783068: homarr-renovate[bot]
|
||||||
|
# 190541745: homarr-crowdin[bot]
|
||||||
|
# 210161987: homarr-update-contributors[bot]
|
||||||
|
# 175486441: homarr-releases[bot]
|
||||||
|
# Extracted from https://api.github.com/users/<username>
|
||||||
|
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
- name: Obtain token
|
||||||
|
id: obtainToken
|
||||||
|
uses: actions/create-github-app-token@v3
|
||||||
|
with:
|
||||||
|
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||||
|
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||||
|
permission-pull-requests: write # required to approve pull request
|
||||||
|
|
||||||
|
- name: Install GitHub CLI
|
||||||
|
run: sudo apt-get install -y gh
|
||||||
|
- name: Approve automatic PRs
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
run: |
|
||||||
|
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
name: "[Bots] Reconcile Auto-merge"
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "*/30 * * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
reconcile:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Obtain token
|
|
||||||
id: obtainToken
|
|
||||||
uses: actions/create-github-app-token@v3
|
|
||||||
with:
|
|
||||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
|
||||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
|
||||||
permission-pull-requests: write # required to enable auto-merge
|
|
||||||
|
|
||||||
- name: Install GitHub CLI
|
|
||||||
run: sudo apt-get install -y gh
|
|
||||||
|
|
||||||
- name: Re-enable auto-merge for prepared bot PRs
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
|
||||||
run: |
|
|
||||||
# 158783068: homarr-renovate[bot]
|
|
||||||
# 190541745: homarr-crowdin[bot]
|
|
||||||
# 210161987: homarr-update-contributors[bot]
|
|
||||||
# 175441: homarr-releases[bot]
|
|
||||||
gh pr list --repo "${{ github.repository }}" --base dev --state open --limit 100 \
|
|
||||||
--json number,author,reviewDecision,mergeStateStatus,autoMergeRequest \
|
|
||||||
--jq '.[] | select(.author.login as $login | ["app/homarr-renovate", "app/homarr-crowdin", "app/homarr-update-contributors", "app/homarr-releases"] | index($login)) | select(.reviewDecision == "APPROVED") | select(.mergeStateStatus == "CLEAN") | select(.autoMergeRequest == null) | .number' \
|
|
||||||
| while read -r pr; do
|
|
||||||
echo "Re-enabling auto-merge on PR #$pr"
|
|
||||||
gh pr merge "$pr" --auto --squash
|
|
||||||
done
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
name: "[Bots] Approve and Auto-merge"
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
approve-and-automerge:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
# 158783068: homarr-renovate[bot]
|
|
||||||
# 190541745: homarr-crowdin[bot]
|
|
||||||
# 210161987: homarr-update-contributors[bot]
|
|
||||||
# 175441: homarr-releases[bot]
|
|
||||||
# Extracted from https://api.github.com/users/<username>
|
|
||||||
if: |
|
|
||||||
(github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175441) &&
|
|
||||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
|
||||||
github.event.pull_request.base.ref == 'dev'
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Obtain token
|
|
||||||
id: obtainToken
|
|
||||||
uses: actions/create-github-app-token@v3
|
|
||||||
with:
|
|
||||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
|
||||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
|
||||||
permission-pull-requests: write # required to approve a pull request and enable auto-merge
|
|
||||||
|
|
||||||
- name: Install GitHub CLI
|
|
||||||
run: sudo apt-get install -y gh
|
|
||||||
|
|
||||||
- name: Approve and enable auto-merge
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
|
||||||
run: |
|
|
||||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
|
||||||
if [ "$(gh pr view "$PR_NUMBER" --json reviewDecision --jq .reviewDecision)" != "APPROVED" ]; then
|
|
||||||
gh pr review "$PR_NUMBER" --approve --body "Automatically approved by Homarr bot policy"
|
|
||||||
fi
|
|
||||||
if [ "$(gh pr view "$PR_NUMBER" --json autoMergeRequest --jq '.autoMergeRequest != null')" != "true" ]; then
|
|
||||||
gh pr merge "$PR_NUMBER" --auto --squash
|
|
||||||
fi
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
branches: ["*"]
|
|
||||||
pull_request_target:
|
|
||||||
types: [closed]
|
|
||||||
branches: ["*"]
|
|
||||||
push:
|
|
||||||
branches: [main, dev, beta, next]
|
|
||||||
merge_group:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
operation:
|
|
||||||
description: Operation to run
|
|
||||||
required: true
|
|
||||||
default: validate
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- validate
|
|
||||||
- publish
|
|
||||||
- cleanup
|
|
||||||
pr_number:
|
|
||||||
description: PR number for publish or cleanup
|
|
||||||
required: false
|
|
||||||
type: number
|
|
||||||
ref:
|
|
||||||
description: Git ref to validate or publish
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_COLOR: 3
|
|
||||||
SKIP_ENV_VALIDATION: true
|
|
||||||
TURBO_TELEMETRY_DISABLED: 1
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
fast-gate:
|
|
||||||
name: Fast gate
|
|
||||||
if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
env:
|
|
||||||
TURBO_API: ${{ secrets.TURBO_API }}
|
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
|
||||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'HEAD~1' }}
|
|
||||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
|
||||||
TURBO_PLATFORM: linux/amd64
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
if: "!env.ACT"
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
|
||||||
|
|
||||||
- name: Validate manual inputs
|
|
||||||
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'publish'
|
|
||||||
env:
|
|
||||||
PR_NUMBER: ${{ inputs.pr_number }}
|
|
||||||
BUILD_REF: ${{ inputs.ref }}
|
|
||||||
run: |
|
|
||||||
test -n "$PR_NUMBER" || { echo "pr_number is required for publish"; exit 1; }
|
|
||||||
test -n "$BUILD_REF" || { echo "ref is required for publish"; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Copy environment defaults
|
|
||||||
run: cp .env.example .env
|
|
||||||
|
|
||||||
- name: Lint, typecheck, and build affected workspaces
|
|
||||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2
|
|
||||||
|
|
||||||
- name: Validate workspace configuration
|
|
||||||
run: pnpm lint:ws
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: pnpm test
|
|
||||||
|
|
||||||
container:
|
|
||||||
name: Container and E2E
|
|
||||||
needs: fast-gate
|
|
||||||
if: |
|
|
||||||
github.event_name == 'merge_group' ||
|
|
||||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
if: "!env.ACT"
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
if: "!env.ACT"
|
|
||||||
uses: docker/setup-buildx-action@v4
|
|
||||||
|
|
||||||
- name: Build container once
|
|
||||||
if: "!env.ACT"
|
|
||||||
uses: docker/build-push-action@v7
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
network: host
|
|
||||||
platforms: linux/amd64
|
|
||||||
load: true
|
|
||||||
push: false
|
|
||||||
tags: |
|
|
||||||
homarr-ci
|
|
||||||
homarr-e2e
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
|
||||||
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
|
|
||||||
cache-from: type=gha,scope=homarr-linux-amd64
|
|
||||||
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
|
||||||
secrets: |
|
|
||||||
TURBO_API=${{ secrets.TURBO_API }}
|
|
||||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
|
||||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
|
||||||
|
|
||||||
- name: Build container once with Act
|
|
||||||
if: env.ACT
|
|
||||||
env:
|
|
||||||
TURBO_API: ${{ secrets.TURBO_API }}
|
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
|
||||||
run: |
|
|
||||||
secret_args=()
|
|
||||||
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
|
|
||||||
if [ -n "${!secret:-}" ]; then
|
|
||||||
secret_args+=(--secret "id=${secret},env=${secret}")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
|
|
||||||
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
|
|
||||||
|
|
||||||
- name: Cache Playwright browsers
|
|
||||||
if: "!env.ACT"
|
|
||||||
id: playwright-cache
|
|
||||||
uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: ~/.cache/ms-playwright
|
|
||||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
|
||||||
|
|
||||||
- name: Install Playwright Chromium
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
|
|
||||||
run: pnpm exec playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Install Playwright system deps
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
|
||||||
run: pnpm exec playwright install-deps chromium
|
|
||||||
|
|
||||||
- name: Run E2E tests
|
|
||||||
run: pnpm test:e2e
|
|
||||||
|
|
||||||
- name: Upload E2E artifacts
|
|
||||||
if: failure() && !env.ACT
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: e2e/test-results/
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Determine preview tag
|
|
||||||
id: preview
|
|
||||||
if: |
|
|
||||||
!env.ACT &&
|
|
||||||
(
|
|
||||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
|
||||||
)
|
|
||||||
env:
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
|
|
||||||
run: echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
if: steps.preview.outputs.tag != ''
|
|
||||||
uses: docker/login-action@v4
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Push preview image
|
|
||||||
if: steps.preview.outputs.tag != ''
|
|
||||||
env:
|
|
||||||
PREVIEW_TAG: ${{ steps.preview.outputs.tag }}
|
|
||||||
run: |
|
|
||||||
docker tag homarr-ci "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
|
||||||
docker push "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
|
||||||
|
|
||||||
- name: Show Act publication result
|
|
||||||
if: env.ACT
|
|
||||||
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
|
|
||||||
|
|
||||||
cleanup:
|
|
||||||
name: Clean up preview image
|
|
||||||
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
env:
|
|
||||||
IMAGE_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
|
|
||||||
steps:
|
|
||||||
- name: Validate cleanup input
|
|
||||||
if: github.event_name == 'workflow_dispatch'
|
|
||||||
env:
|
|
||||||
PR_NUMBER: ${{ inputs.pr_number }}
|
|
||||||
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
|
|
||||||
|
|
||||||
- name: Delete GHCR package version
|
|
||||||
if: "!env.ACT"
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const packageName = "homarr-test";
|
|
||||||
const org = context.repo.owner;
|
|
||||||
const tag = process.env.IMAGE_TAG;
|
|
||||||
const versions = await github.paginate(
|
|
||||||
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
|
|
||||||
{ package_type: "container", package_name: packageName, org, per_page: 100 },
|
|
||||||
);
|
|
||||||
const target = versions.find((version) =>
|
|
||||||
version.metadata?.container?.tags?.includes(tag),
|
|
||||||
);
|
|
||||||
if (!target) {
|
|
||||||
core.info(`No package version found with tag "${tag}"`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await github.rest.packages.deletePackageVersionForOrg({
|
|
||||||
package_type: "container",
|
|
||||||
package_name: packageName,
|
|
||||||
org,
|
|
||||||
package_version_id: target.id,
|
|
||||||
});
|
|
||||||
core.info(`Deleted package version ${target.id} (${tag})`);
|
|
||||||
|
|
||||||
- name: Show Act cleanup result
|
|
||||||
if: env.ACT
|
|
||||||
run: echo "Act validated cleanup for ${IMAGE_TAG}; external GHCR writes are disabled locally."
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
name: "[Quality] Code Analysis"
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: ["*"]
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
merge_group:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||||
|
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_COLOR: 3
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Copy env
|
||||||
|
shell: bash
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint && pnpm lint:ws
|
||||||
|
|
||||||
|
format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Format
|
||||||
|
run: pnpm format
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: "Report Coverage"
|
||||||
|
# Set if: always() to also generate the report if tests are failing
|
||||||
|
# Only works if you set `reportOnFailure: true` in your vite config as specified above
|
||||||
|
if: always()
|
||||||
|
uses: davelosert/vitest-coverage-report-action@v2
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
- name: Build docker image
|
||||||
|
id: build-docker-image
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
tags: homarr-e2e
|
||||||
|
network: host
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
- name: Install playwright browsers
|
||||||
|
run: pnpm exec playwright install chromium
|
||||||
|
- name: Run E2E Tests
|
||||||
|
shell: bash
|
||||||
|
run: pnpm test:e2e
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
- name: Copy env
|
||||||
|
shell: bash
|
||||||
|
run: cp .env.example .env
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
@@ -15,14 +15,14 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Obtain token
|
- name: Obtain token
|
||||||
id: obtainToken
|
id: obtainToken
|
||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
|
||||||
client-id: ${{ secrets.CROWDIN_APP_ID }}
|
app-id: ${{ secrets.CROWDIN_APP_ID }}
|
||||||
permission-contents: write # required to commit to crowdin branch
|
permission-contents: write # required to commit to crowdin branch
|
||||||
permission-pull-requests: write # required to create pull request
|
permission-pull-requests: write # required to create pull request
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Upload Crowdin translations
|
- name: Upload Crowdin translations
|
||||||
uses: crowdin/github-action@v2
|
uses: crowdin/github-action@v2
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
name: "[Deployment] Beta Release"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
send-notifications:
|
|
||||||
type: boolean
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
description: Send notifications
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
create-and-merge-pr:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 2
|
|
||||||
steps:
|
|
||||||
- name: Discord notification
|
|
||||||
if: ${{ github.event.inputs.send-notifications }}
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
uses: Ilshidur/action-discord@0.4.0
|
|
||||||
with:
|
|
||||||
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Get Next Version
|
|
||||||
id: semver
|
|
||||||
uses: ietf-tools/semver-action@v1
|
|
||||||
with:
|
|
||||||
token: ${{ github.token }}
|
|
||||||
branch: next
|
|
||||||
- name: Create pull request
|
|
||||||
id: create-pull-request
|
|
||||||
run: 'gh pr create --title "chore(release): release ${{ steps.semver.outputs.next }}-beta" --body "Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}-beta``" --base beta --head next --label automerge'
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
- name: Discord notification
|
|
||||||
if: ${{ github.event.inputs.send-notifications }}
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
uses: Ilshidur/action-discord@0.4.0
|
|
||||||
with:
|
|
||||||
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }}-beta (new behaviour: ${{ steps.semver.outputs.bump }})"
|
|
||||||
- name: Obtain token
|
|
||||||
id: obtainApprovalToken
|
|
||||||
uses: actions/create-github-app-token@v3
|
|
||||||
with:
|
|
||||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
|
||||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
|
||||||
permission-pull-requests: write
|
|
||||||
- name: Approve PR
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
|
|
||||||
run: |
|
|
||||||
gh pr review --approve --body "Automatically approved by GitHub Action"
|
|
||||||
- name: Obtain token
|
|
||||||
id: obtainMergeToken
|
|
||||||
uses: actions/create-github-app-token@v3
|
|
||||||
with:
|
|
||||||
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
|
||||||
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
|
||||||
permission-contents: write # write to beta branch (due to merge)
|
|
||||||
permission-pull-requests: write # merge pull request
|
|
||||||
- id: automerge
|
|
||||||
if: ${{ steps.semver.outputs.bump != 'major' }}
|
|
||||||
name: automerge
|
|
||||||
uses: "pascalgn/automerge-action@v0.16.4"
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
|
|
||||||
MERGE_METHOD: merge # we prefer merge commits for merging to beta
|
|
||||||
MERGE_COMMIT_MESSAGE: "chore(release): release ${{ steps.semver.outputs.next }}-beta"
|
|
||||||
MERGE_DELETE_BRANCH: false # never set to true!
|
|
||||||
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
|
|
||||||
MERGE_RETRIES: 20 # 20 retries * MERGE_RETRY_SLEEP until step fails
|
|
||||||
MERGE_RETRY_SLEEP: 10000 # 10 seconds * MERGE_RETRIES until step fails
|
|
||||||
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
|
||||||
|
|
||||||
- name: Merged Discord notification
|
|
||||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
uses: Ilshidur/action-discord@0.4.0
|
|
||||||
with:
|
|
||||||
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
|
|
||||||
- name: Major Bump Discord notification
|
|
||||||
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
uses: Ilshidur/action-discord@0.4.0
|
|
||||||
with:
|
|
||||||
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
|
|
||||||
- name: Discord Fail Notification
|
|
||||||
if: failure() && github.event.inputs.send-notifications
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
uses: Ilshidur/action-discord@0.4.0
|
|
||||||
with:
|
|
||||||
args: "The beta release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
|
|
||||||
@@ -5,7 +5,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- beta
|
- beta
|
||||||
- next
|
|
||||||
- dev
|
- dev
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -35,7 +34,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
env:
|
env:
|
||||||
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' || github.ref_name == 'next' }}
|
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' }}
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }}
|
version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }}
|
||||||
git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }}
|
git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }}
|
||||||
@@ -54,10 +53,10 @@ jobs:
|
|||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
||||||
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||||
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release
|
permission-contents: write # required to commit package.json & changelog changes, merge them to dev and publish the release
|
||||||
|
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == 'false'
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -66,8 +65,8 @@ jobs:
|
|||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == 'false'
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.0
|
node-version: 24.15.0
|
||||||
package-manager-cache: false
|
cache: "pnpm"
|
||||||
- run: npm i -g pnpm
|
- run: npm i -g pnpm
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == 'false'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -96,21 +95,20 @@ jobs:
|
|||||||
id: read-git-ref
|
id: read-git-ref
|
||||||
run: |
|
run: |
|
||||||
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
- name: Update dev / next branch
|
- name: Update dev branch
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == 'false'
|
||||||
continue-on-error: true # Prevent pipeline from failing when merge fails
|
continue-on-error: true # Prevent pipeline from failing when merge fails
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
UPSTREAM_BRANCH: ${{ github.ref_name == 'main' && 'dev' || 'next' }}
|
|
||||||
run: |
|
run: |
|
||||||
git config user.name "Releases Homarr"
|
git config user.name "Releases Homarr"
|
||||||
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
|
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
|
||||||
git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git
|
git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git
|
||||||
git fetch origin ${{ env.UPSTREAM_BRANCH }}
|
git fetch origin dev
|
||||||
git checkout ${{ env.UPSTREAM_BRANCH }}
|
git checkout dev
|
||||||
git pull origin ${{ env.UPSTREAM_BRANCH }}
|
git pull origin dev
|
||||||
git rebase ${{ github.ref_name }}
|
git rebase ${{ github.ref_name }}
|
||||||
git push origin ${{ env.UPSTREAM_BRANCH }}
|
git push origin dev
|
||||||
build-amd64:
|
build-amd64:
|
||||||
name: Build docker image for amd64
|
name: Build docker image for amd64
|
||||||
needs: release
|
needs: release
|
||||||
@@ -119,7 +117,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
digest: ${{ steps.build.outputs.digest }}
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.release.outputs.git_ref }}
|
ref: ${{ needs.release.outputs.git_ref }}
|
||||||
|
|
||||||
@@ -148,11 +146,6 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
secrets: |
|
|
||||||
TURBO_API=${{ secrets.TURBO_API }}
|
|
||||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
|
||||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
build-arm64:
|
build-arm64:
|
||||||
@@ -163,7 +156,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
digest: ${{ steps.build.outputs.digest }}
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.release.outputs.git_ref }}
|
ref: ${{ needs.release.outputs.git_ref }}
|
||||||
|
|
||||||
@@ -192,18 +185,13 @@ jobs:
|
|||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
secrets: |
|
|
||||||
TURBO_API=${{ secrets.TURBO_API }}
|
|
||||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
|
||||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
extract-asset-amd64:
|
extract-asset-amd64:
|
||||||
name: Extract amd64 asset from docker image
|
name: Extract amd64 asset from docker image
|
||||||
needs: [release, build-amd64]
|
needs: [release, build-amd64]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 2
|
||||||
steps:
|
steps:
|
||||||
- name: Extract amd64
|
- name: Extract amd64
|
||||||
if: needs.release.outputs.skipped == 'false'
|
if: needs.release.outputs.skipped == 'false'
|
||||||
@@ -219,7 +207,7 @@ jobs:
|
|||||||
name: Extract arm64 asset from docker image
|
name: Extract arm64 asset from docker image
|
||||||
needs: [release, build-arm64]
|
needs: [release, build-arm64]
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
timeout-minutes: 5
|
timeout-minutes: 2
|
||||||
steps:
|
steps:
|
||||||
- name: Extract arm64
|
- name: Extract arm64
|
||||||
if: needs.release.outputs.skipped == 'false'
|
if: needs.release.outputs.skipped == 'false'
|
||||||
@@ -246,7 +234,7 @@ jobs:
|
|||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
||||||
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||||
permission-contents: write # required to publish the release
|
permission-contents: write # required to publish the release
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
@@ -286,6 +274,6 @@ jobs:
|
|||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@0.4.0
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Successfully deployed images for branch **${{ github.ref_name }}**. Tagged as **${{env.NEXT_VERSION}}**."
|
args: "Successfully deployed images for branch **${{ github.ref_name }}**. Tagged as **${{env.NEXT_VERSION}}**."
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
name: "[Deployment] Documentation"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["dev"]
|
|
||||||
paths:
|
|
||||||
- "apps/docs/**"
|
|
||||||
- "packages/definitions/src/docs/**"
|
|
||||||
- ".github/workflows/deployment-docs.yml"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: "pages"
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
SKIP_ENV_VALIDATION: true
|
|
||||||
TURBO_API: ${{ secrets.TURBO_API }}
|
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
- name: Build docs
|
|
||||||
run: pnpm turbo build --filter=@homarr/docs
|
|
||||||
env:
|
|
||||||
CI: "true"
|
|
||||||
- name: Setup Pages
|
|
||||||
uses: actions/configure-pages@v6
|
|
||||||
- name: Upload pages artifact
|
|
||||||
uses: actions/upload-pages-artifact@v5
|
|
||||||
with:
|
|
||||||
path: "apps/docs/build"
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
steps:
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v5
|
|
||||||
@@ -3,7 +3,6 @@ name: "[Deployment] Automatic Weekly Release"
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
||||||
timezone: Europe/Zurich
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
send-notifications:
|
send-notifications:
|
||||||
@@ -22,14 +21,14 @@ jobs:
|
|||||||
timeout-minutes: 2
|
timeout-minutes: 2
|
||||||
steps:
|
steps:
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.event.inputs.send-notifications }}
|
if: ${{ github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@0.4.0
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Get Next Version
|
- name: Get Next Version
|
||||||
@@ -39,15 +38,14 @@ jobs:
|
|||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
branch: dev
|
branch: dev
|
||||||
- name: Create pull request
|
- name: Create pull request
|
||||||
id: create-pull-request
|
|
||||||
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge'
|
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge'
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
if: ${{ github.event.inputs.send-notifications }}
|
if: ${{ github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@0.4.0
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
|
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
|
||||||
- name: Obtain token
|
- name: Obtain token
|
||||||
@@ -55,7 +53,7 @@ jobs:
|
|||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||||
permission-pull-requests: write
|
permission-pull-requests: write
|
||||||
- name: Approve PR
|
- name: Approve PR
|
||||||
env:
|
env:
|
||||||
@@ -67,7 +65,7 @@ jobs:
|
|||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
||||||
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||||
permission-contents: write # write to main branch (due to merge)
|
permission-contents: write # write to main branch (due to merge)
|
||||||
permission-pull-requests: write # merge pull request
|
permission-pull-requests: write # merge pull request
|
||||||
- id: automerge
|
- id: automerge
|
||||||
@@ -76,7 +74,7 @@ jobs:
|
|||||||
uses: "pascalgn/automerge-action@v0.16.4"
|
uses: "pascalgn/automerge-action@v0.16.4"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
|
||||||
MERGE_METHOD: merge # we prefer merge commits for merging to main
|
MERGE_METHOD: merge # we prefer merge commits for merging to master
|
||||||
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
||||||
MERGE_DELETE_BRANCH: false # never set to true!
|
MERGE_DELETE_BRANCH: false # never set to true!
|
||||||
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
|
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
|
||||||
@@ -85,23 +83,23 @@ jobs:
|
|||||||
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
||||||
|
|
||||||
- name: Merged Discord notification
|
- name: Merged Discord notification
|
||||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
|
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@0.4.0
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
||||||
- name: Major Bump Discord notification
|
- name: Major Bump Discord notification
|
||||||
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
|
if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@0.4.0
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
||||||
- name: Discord Fail Notification
|
- name: Discord Fail Notification
|
||||||
if: failure() && github.event.inputs.send-notifications
|
if: failure() && github.events.inputs.send-notifications
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@0.4.0
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
|
args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
name: "[Docs] Algolia Recrawl"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["[Deployment] Documentation"]
|
|
||||||
types: [completed]
|
|
||||||
branches: [dev]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
recrawl:
|
|
||||||
if: >-
|
|
||||||
${{
|
|
||||||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'pull_request')
|
|
||||||
|| github.event_name == 'workflow_dispatch'
|
|
||||||
}}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Check Algolia crawler secrets
|
|
||||||
run: |
|
|
||||||
missing=0
|
|
||||||
for secret in ALGOLIA_CRAWLER_ID ALGOLIA_CRAWLER_USER_ID ALGOLIA_CRAWLER_API_KEY; do
|
|
||||||
if [ -z "${!secret}" ]; then
|
|
||||||
echo "::warning::Missing repository secret: $secret"
|
|
||||||
missing=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$missing" -eq 1 ]; then
|
|
||||||
echo "Add crawler secrets to enable automatic recrawls."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
env:
|
|
||||||
ALGOLIA_CRAWLER_ID: ${{ secrets.ALGOLIA_CRAWLER_ID }}
|
|
||||||
ALGOLIA_CRAWLER_USER_ID: ${{ secrets.ALGOLIA_CRAWLER_USER_ID }}
|
|
||||||
ALGOLIA_CRAWLER_API_KEY: ${{ secrets.ALGOLIA_CRAWLER_API_KEY }}
|
|
||||||
|
|
||||||
- name: Wait for GitHub Pages to serve the new deployment
|
|
||||||
run: sleep 60
|
|
||||||
|
|
||||||
- name: Trigger Algolia DocSearch recrawl
|
|
||||||
uses: cssnr/algolia-crawler-action@v2
|
|
||||||
with:
|
|
||||||
crawler_id: ${{ secrets.ALGOLIA_CRAWLER_ID }}
|
|
||||||
crawler_user_id: ${{ secrets.ALGOLIA_CRAWLER_USER_ID }}
|
|
||||||
crawler_api_key: ${{ secrets.ALGOLIA_CRAWLER_API_KEY }}
|
|
||||||
|
|
||||||
- name: Verify search index
|
|
||||||
working-directory: apps/docs
|
|
||||||
run: pnpm verify:search
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
name: "[Docs] Update Contributors"
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 12 * * FRI"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
HOMARR_CROWDIN_TOKEN: "${{ secrets.HOMARR_CROWDIN_TOKEN }}"
|
|
||||||
HOMARR_LABS_CROWDIN_TOKEN: "${{ secrets.HOMARR_LABS_CROWDIN_TOKEN }}"
|
|
||||||
SKIP_ENV_VALIDATION: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
dump:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
ref: dev
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Dump contributors
|
|
||||||
working-directory: apps/docs
|
|
||||||
run: pnpm api-dump-contributors
|
|
||||||
|
|
||||||
- name: Commit updated contributors
|
|
||||||
uses: EndBug/add-and-commit@v10
|
|
||||||
with:
|
|
||||||
add: "apps/docs/static"
|
|
||||||
default_author: github_actor
|
|
||||||
message: "chore(docs): update contributors"
|
|
||||||
push: true
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
name: "[Docs] Update OpenAPI Spec"
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 0 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
SKIP_ENV_VALIDATION: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-spec:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
ref: dev
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Copy env
|
|
||||||
run: cp .env.example .env
|
|
||||||
|
|
||||||
- name: Start dev server
|
|
||||||
run: pnpm --filter @homarr/nextjs dev &
|
|
||||||
env:
|
|
||||||
TURBO_TELEMETRY_DISABLED: 1
|
|
||||||
DISABLE_TELEMETRY: 1
|
|
||||||
|
|
||||||
- name: Wait for server and fetch spec
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if curl -fsS http://localhost:3000/api/openapi -o /tmp/openapi.json 2>/dev/null; then
|
|
||||||
echo "Server is up, spec fetched"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "Waiting for server... ($i/30)"
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ ! -f /tmp/openapi.json ]; then
|
|
||||||
echo "::error::Server failed to start within timeout"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
jq -e '.openapi and .paths' /tmp/openapi.json > /dev/null || {
|
|
||||||
echo "::error::Fetched file is not a valid OpenAPI spec"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
cp /tmp/openapi.json apps/docs/static/api/open-api-schema.json
|
|
||||||
echo "OpenAPI spec updated ($(jq '.paths | length' /tmp/openapi.json) paths)"
|
|
||||||
|
|
||||||
- name: Commit updated spec
|
|
||||||
uses: EndBug/add-and-commit@v10
|
|
||||||
with:
|
|
||||||
add: "apps/docs/static/api/"
|
|
||||||
default_author: github_actor
|
|
||||||
message: "chore(docs): update OpenAPI spec"
|
|
||||||
push: true
|
|
||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Validate prebuilt dependencies for amd64
|
- name: Validate prebuilt dependencies for amd64
|
||||||
id: validate-amd64
|
id: validate-amd64
|
||||||
uses: ./.github/actions/prebuilt-debian
|
uses: ./.github/actions/prebuilt-debian
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Validate prebuilt dependencies for arm64
|
- name: Validate prebuilt dependencies for arm64
|
||||||
id: validate-arm64
|
id: validate-arm64
|
||||||
uses: ./.github/actions/prebuilt-debian
|
uses: ./.github/actions/prebuilt-debian
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 2
|
timeout-minutes: 2
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- run: |
|
- run: |
|
||||||
npx --yes --package renovate -- \
|
npx --yes --package renovate -- \
|
||||||
renovate-config-validator --strict .github/renovate.json5
|
renovate-config-validator --strict .github/renovate.json5
|
||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
private-key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }}
|
||||||
client-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }}
|
app-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }}
|
||||||
owner: homarr-labs
|
owner: homarr-labs
|
||||||
repositories: |
|
repositories: |
|
||||||
documentation
|
documentation
|
||||||
@@ -48,11 +48,11 @@ jobs:
|
|||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
||||||
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||||
permission-contents: write # required to commit to branch
|
permission-contents: write # required to commit to branch
|
||||||
permission-pull-requests: write # required to create pr & enable automerge
|
permission-pull-requests: write # required to create pr & enable automerge
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.obtainToken.outputs.token }}
|
token: ${{ steps.obtainToken.outputs.token }}
|
||||||
ref: dev
|
ref: dev
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ jobs:
|
|||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
||||||
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||||
permission-contents: write # required to commit to branch
|
permission-contents: write # required to commit to branch
|
||||||
permission-pull-requests: write # required to create pr & enable automerge
|
permission-pull-requests: write # required to create pr & enable automerge
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ jobs:
|
|||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
||||||
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||||
permission-contents: write # required to commit to branch
|
permission-contents: write # required to commit to branch
|
||||||
permission-pull-requests: write # required to create pr & enable automerge
|
permission-pull-requests: write # required to create pr & enable automerge
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
- name: Setup
|
- name: Setup
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
node_modules
|
||||||
.pnpm-store/
|
|
||||||
.pnp
|
.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
.idea/
|
.idea/
|
||||||
@@ -67,16 +66,9 @@ e2e/shared/tmp
|
|||||||
#personal backgrounds
|
#personal backgrounds
|
||||||
apps/nextjs/public/images/background.png
|
apps/nextjs/public/images/background.png
|
||||||
|
|
||||||
|
# next-intl
|
||||||
|
en.d.json.ts
|
||||||
|
|
||||||
# personal agent config
|
# personal agent config
|
||||||
|
AGENTS.md
|
||||||
.opencode
|
.opencode
|
||||||
.agents/*
|
|
||||||
!.agents/skills/
|
|
||||||
skills-lock.json
|
|
||||||
|
|
||||||
# docusaurus
|
|
||||||
apps/docs/.docusaurus
|
|
||||||
apps/docs/build
|
|
||||||
.pnpm-store
|
|
||||||
benchmark-results
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"mantine": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "@mantine/mcp-server"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[tools]
|
|
||||||
node = "24.18.0"
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
# pnpm v11: non-auth settings moved to pnpm-workspace.yaml
|
# Expo doesn't play nice with pnpm by default.
|
||||||
# Keep this file for any future registry/auth configuration
|
# The symbolic links of pnpm break the rules of Expo monorepos.
|
||||||
|
# @link https://docs.expo.dev/guides/monorepos/#common-issues
|
||||||
|
node-linker=hoisted
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
engine-strict=true
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
|
||||||
"printWidth": 120,
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"sortPackageJson": true,
|
|
||||||
"ignorePatterns": ["**/*.cjs", "**/lang/**", "static-data/**", "CHANGELOG.md", "apps/docs/.docusaurus/**", "apps/docs/build/**", "apps/docs/blog/**"]
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
||||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "import", "jsx-a11y"],
|
|
||||||
"categories": {
|
|
||||||
"correctness": "error",
|
|
||||||
"suspicious": "warn",
|
|
||||||
"pedantic": "off",
|
|
||||||
"perf": "off",
|
|
||||||
"style": "off",
|
|
||||||
"restriction": "off",
|
|
||||||
"nursery": "off"
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
|
||||||
"typescript/consistent-type-imports": ["warn", { "prefer": "type-imports", "fixStyle": "separate-type-imports" }],
|
|
||||||
"typescript/no-non-null-assertion": "error",
|
|
||||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
|
||||||
"unicorn/no-new-array": "warn",
|
|
||||||
"unicorn/no-useless-spread": "warn",
|
|
||||||
"unicorn/no-useless-length-check": "warn",
|
|
||||||
"unicorn/no-useless-fallback-in-spread": "warn",
|
|
||||||
"jsx-a11y/prefer-tag-over-role": "warn",
|
|
||||||
"oxc/const-comparisons": "warn",
|
|
||||||
"react-hooks/exhaustive-deps": "warn",
|
|
||||||
"react/no-children-prop": "warn",
|
|
||||||
"jsx-a11y/no-autofocus": "warn",
|
|
||||||
"jsx-a11y/img-redundant-alt": "warn",
|
|
||||||
"react/react-in-jsx-scope": "off"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"reportUnusedDisableDirectives": "off"
|
|
||||||
},
|
|
||||||
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", "**/lang/*.d.json.ts", ".next/**", "apps/docs/.docusaurus/**", "apps/docs/build/**"],
|
|
||||||
"settings": {
|
|
||||||
"react": { "version": "19" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 46 KiB |