Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f136daea | ||
|
|
f351929e5b |
No files matched your search
@@ -1,197 +0,0 @@
|
|||||||
---
|
|
||||||
name: Revert Demo MCP
|
|
||||||
overview: Revert the demo-specific MCP accommodations (API key bypass, read-only filtering, demo UI) since demo MCP is an edge case with poor ROI. Replace with a clear "MCP not available in demo mode" message. Keep the core MCP feature intact for real instances.
|
|
||||||
todos:
|
|
||||||
- id: revert-backend
|
|
||||||
content: Revert demoSafe procedure, apiKeys demo bypass, isDemoMode export from mcp.ts
|
|
||||||
status: pending
|
|
||||||
- id: revert-mcp-route
|
|
||||||
content: Remove isDemoMode filtering and demo SERVER_INSTRUCTIONS from MCP route
|
|
||||||
status: pending
|
|
||||||
- id: revert-ui
|
|
||||||
content: Remove isDemoMode prop plumbing; add demo-not-supported Alert via info.isDemoMode
|
|
||||||
status: pending
|
|
||||||
- id: verify
|
|
||||||
content: Run lint + typecheck; smoke-test demo vs non-demo MCP tab
|
|
||||||
status: pending
|
|
||||||
isProject: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Revert Demo MCP Accommodations
|
|
||||||
|
|
||||||
## Why Docker works but integrations do not
|
|
||||||
|
|
||||||
Demo mode has **two different mock mechanisms**:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph dockerPath [Docker - works in demo]
|
|
||||||
MCP1[MCP docker_getContainers]
|
|
||||||
Handler[docker.ts request handler]
|
|
||||||
MockContainers[Hardcoded mockContainers]
|
|
||||||
MCP1 --> Handler
|
|
||||||
Handler -->|"if DEMO_MODE"| MockContainers
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph integrationPath [Integrations - confusing in demo]
|
|
||||||
MCP2[MCP widget tools e.g. calendar_findAllEvents]
|
|
||||||
TRPC[tRPC procedure]
|
|
||||||
RH[cached-integration-request-handler]
|
|
||||||
Kind{integration.kind}
|
|
||||||
Mock[MockIntegration fake data]
|
|
||||||
Real[Sonarr/Radarr HTTP - fails]
|
|
||||||
MCP2 --> TRPC --> RH --> Kind
|
|
||||||
Kind -->|mock| Mock
|
|
||||||
Kind -->|sonarr/radarr| Real
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Docker**:
|
|
||||||
[`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts)
|
|
||||||
checks `DEMO_MODE` directly and returns inline fake containers. No integration
|
|
||||||
record needed.
|
|
||||||
- **Everything else**: Demo seed creates **one** integration with `kind: "mock"`
|
|
||||||
([`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts)), not
|
|
||||||
Sonarr/Radarr/Overseerr.
|
|
||||||
[`MockIntegration`](packages/integrations/src/mock/mock-integration.ts) can
|
|
||||||
serve calendar, downloads, health, DNS, media server, etc. **if** callers pass
|
|
||||||
that integration ID.
|
|
||||||
- **Why it felt broken**: MCP tool descriptions tell AI clients to look for
|
|
||||||
"Overseerr/Sonarr IDs". Demo has none. Tools like
|
|
||||||
`integration_searchMediaRequests` hard-filter to `mediaSearch` kinds
|
|
||||||
(`overseerr`, `jellyseerr`, `seerr`) — `mock` is excluded from that category
|
|
||||||
in
|
|
||||||
[`packages/definitions/src/integration.ts`](packages/definitions/src/integration.ts).
|
|
||||||
|
|
||||||
Fixing this properly means extending the mock integration system (new
|
|
||||||
`searchAsync`, category changes, middleware relaxations, instruction rewrites)
|
|
||||||
for a feature almost nobody will use on the public demo.
|
|
||||||
|
|
||||||
## Option comparison
|
|
||||||
|
|
||||||
| | **Revert (chosen)** | **Minimal fix** | **Full fix** |
|
|
||||||
| -------------------- | --------------------------- | ---------------------------------------------- | --------------------------------------------------- |
|
|
||||||
| **Effort** | ~1 hour, ~100 lines removed | ~2 hours | 1-2 days |
|
|
||||||
| **Files touched** | 8 MCP/demo-specific files | 8 + route instructions | 15+ across integrations, definitions, seed, routers |
|
|
||||||
| **Risk** | Low | Low-medium | Medium (mock/search semantics) |
|
|
||||||
| **Demo MCP outcome** | Clearly unsupported | Partial (widget reads only, no search/request) | Near-full read coverage |
|
|
||||||
| **Maintenance** | None ongoing | Instructions drift with tool changes | Mock parity with real integrations |
|
|
||||||
|
|
||||||
**Recommendation: Revert.** The demo accommodation code added special-case
|
|
||||||
plumbing (`demoSafePermissionRequiredProcedure`, dual mutation guards, hardcoded
|
|
||||||
UI strings) for a workflow that still cannot deliver a convincing "homelab AI"
|
|
||||||
experience without substantial integration-layer work.
|
|
||||||
|
|
||||||
## What to revert (demo MCP accommodations only)
|
|
||||||
|
|
||||||
These changes were added specifically for demo + MCP. **Do not revert** the core
|
|
||||||
MCP server, OAuth, Scalar docs, tool descriptions, or `normalizeSchema`.
|
|
||||||
|
|
||||||
### Backend (3 files)
|
|
||||||
|
|
||||||
1. [`packages/api/src/trpc.ts`](packages/api/src/trpc.ts)
|
|
||||||
- Remove `demoSafePermissionRequiredProcedure` export (~12 lines)
|
|
||||||
- Keep existing `enforceDemoModeReadOnly` (pre-dates MCP; used by whole app)
|
|
||||||
|
|
||||||
2. [`packages/api/src/router/apiKeys.ts`](packages/api/src/router/apiKeys.ts)
|
|
||||||
- Change `create` back to `permissionRequiredProcedure` (mutations blocked in
|
|
||||||
demo again)
|
|
||||||
- Remove `isDemoMode` guard on `delete` (global demo middleware already
|
|
||||||
blocks delete mutations)
|
|
||||||
- Remove `TRPCError` / `isDemoMode` imports if unused
|
|
||||||
|
|
||||||
3. [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts)
|
|
||||||
- Stop exporting `isDemoMode`; keep only `createTRPCContext` and `mcpRouter`
|
|
||||||
|
|
||||||
### MCP route (1 file)
|
|
||||||
|
|
||||||
4. [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts)
|
|
||||||
- Remove `isDemoMode` import and all demo branches:
|
|
||||||
- `ListToolsRequestSchema` mutation filter
|
|
||||||
- `CallToolRequestSchema` mutation rejection
|
|
||||||
- `SERVER_INSTRUCTIONS` demo section
|
|
||||||
- Keep `getToolType` / `buildToolTypeMap` only if still needed elsewhere;
|
|
||||||
otherwise remove dead code from demo-only filtering
|
|
||||||
|
|
||||||
### UI (4 files)
|
|
||||||
|
|
||||||
5. [`apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx)
|
|
||||||
- Remove `isDemoMode` import from `@homarr/api/mcp`
|
|
||||||
- Stop passing `isDemoMode` to child components
|
|
||||||
|
|
||||||
6. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx)
|
|
||||||
- Remove `isDemoMode` prop from interface and `McpInstructions` call
|
|
||||||
|
|
||||||
7. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx)
|
|
||||||
- Remove `isDemoMode` prop
|
|
||||||
- Replace current demo banner with a single **Alert**: MCP requires a
|
|
||||||
self-hosted instance; demo mode does not support MCP (API key creation and
|
|
||||||
write tools are disabled)
|
|
||||||
- Use `clientApi.info.isDemoMode.useQuery()` or pass `isDemoMode` from server
|
|
||||||
via existing `api.info.isDemoMode()` tRPC query (already exists in
|
|
||||||
[`packages/api/src/router/info.ts`](packages/api/src/router/info.ts)) —
|
|
||||||
avoids re-exporting from `@homarr/api/mcp`
|
|
||||||
|
|
||||||
8. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx)
|
|
||||||
- Remove `isDemoMode` prop and conditional delete-column spread
|
|
||||||
- Delete button hidden automatically in demo because `apiKeys.delete`
|
|
||||||
mutation is blocked by global `enforceDemoModeReadOnly`
|
|
||||||
|
|
||||||
## What to add instead
|
|
||||||
|
|
||||||
- **MCP tab**: When `info.isDemoMode` is true, show a prominent notice at the
|
|
||||||
top of
|
|
||||||
[`mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx)
|
|
||||||
explaining MCP is for self-hosted instances only.
|
|
||||||
- **Optional**: Disable/hide the "Create API Key" button on the MCP tab in demo
|
|
||||||
(mutations will fail anyway).
|
|
||||||
- **No changes** to integrations, definitions, seed, or mock services.
|
|
||||||
|
|
||||||
## Files NOT impacted by revert
|
|
||||||
|
|
||||||
Core MCP feature stays as-is:
|
|
||||||
|
|
||||||
- [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts)
|
|
||||||
— auth, rate limiting, `normalizeSchema`, `SERVER_INSTRUCTIONS` (minus demo
|
|
||||||
section)
|
|
||||||
- OAuth routes under `apps/nextjs/src/app/api/mcp/oauth/`
|
|
||||||
- All MCP-enabled routers in `packages/api/src/router/`
|
|
||||||
- [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts) — eager router (minus
|
|
||||||
`isDemoMode` export)
|
|
||||||
- Scalar UI, dynamic tools table,
|
|
||||||
[`.cursor/rules/mcp-integration.mdc`](.cursor/rules/mcp-integration.mdc)
|
|
||||||
|
|
||||||
Pre-existing demo infrastructure (unchanged):
|
|
||||||
|
|
||||||
- [`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts)
|
|
||||||
— Docker mock containers
|
|
||||||
- [`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts) — mock
|
|
||||||
integration + demo board
|
|
||||||
- [`packages/integrations/src/mock/`](packages/integrations/src/mock/) — mock
|
|
||||||
services for widgets on demo board UI
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. `pnpm lint` and `pnpm typecheck` pass
|
|
||||||
2. Demo mode (`DEMO_MODE=true`):
|
|
||||||
- MCP tab shows "not supported" notice
|
|
||||||
- API key create fails with existing "Mutations are disabled in demo mode"
|
|
||||||
- `docker_getContainers` still works on demo board UI (unchanged; separate
|
|
||||||
code path)
|
|
||||||
3. Non-demo mode: MCP unchanged (all tools listed, mutations work with
|
|
||||||
permissions)
|
|
||||||
|
|
||||||
## Commit strategy
|
|
||||||
|
|
||||||
Single focused commit on `feat/add-mcp-server`:
|
|
||||||
|
|
||||||
```
|
|
||||||
revert(mcp): drop demo-mode accommodations for MCP
|
|
||||||
|
|
||||||
MCP is intended for self-hosted instances. Remove demo-specific API key
|
|
||||||
bypass, read-only tool filtering, and demo UI plumbing. Show a clear
|
|
||||||
notice on the MCP tab when demo mode is active.
|
|
||||||
```
|
|
||||||
|
|
||||||
Then push to the existing PR (no force-push needed unless you want another
|
|
||||||
squash).
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
---
|
|
||||||
description: Comprehensive Homarr codebase architecture and conventions reference
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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 v7 (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
|
|
||||||
- **Package manager**: pnpm 10.33.2, Node >= 24.15.0
|
|
||||||
|
|
||||||
## Repository Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
homarr/
|
|
||||||
├── apps/
|
|
||||||
│ ├── nextjs/ # Main Next.js application (port 3000)
|
|
||||||
│ ├── 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
|
|
||||||
│ ├── old-import/ # Legacy Homarr import
|
|
||||||
│ ├── old-schema/ # Legacy Homarr zod schemas
|
|
||||||
│ └── cli/ # Node CLI for ops (brocli)
|
|
||||||
├── tooling/
|
|
||||||
│ ├── eslint/ # Shared ESLint configs
|
|
||||||
│ ├── prettier/ # Shared Prettier config
|
|
||||||
│ ├── 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` — ESLint + Prettier
|
|
||||||
- `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,103 +0,0 @@
|
|||||||
---
|
|
||||||
description: How to expose tRPC procedures as MCP tools for AI clients
|
|
||||||
globs: packages/api/src/router/**/*.ts
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# MCP Integration Guide
|
|
||||||
|
|
||||||
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
|
|
||||||
|
|
||||||
## Adding MCP to a procedure
|
|
||||||
|
|
||||||
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Read-only tool (query)
|
|
||||||
myProcedure: protectedProcedure
|
|
||||||
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
|
|
||||||
.input(z.object({ limit: z.number().default(50) }))
|
|
||||||
.query(async ({ ctx, input }) => { ... })
|
|
||||||
|
|
||||||
// Write tool (mutation)
|
|
||||||
myAction: protectedProcedure
|
|
||||||
.meta({ mcp: { enabled: true, description: "Delete an item by ID. Requires interact permission on the integration" } })
|
|
||||||
.input(z.object({ id: z.string() }))
|
|
||||||
.mutation(async ({ ctx, input }) => { ... })
|
|
||||||
|
|
||||||
// No-input procedure (z.void) — works fine, no .input() needed
|
|
||||||
getAll: protectedProcedure
|
|
||||||
.meta({ mcp: { enabled: true, description: "List all resources" } })
|
|
||||||
.query(async ({ ctx }) => { ... })
|
|
||||||
```
|
|
||||||
|
|
||||||
The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()` in the chain.
|
|
||||||
|
|
||||||
## Writing good descriptions
|
|
||||||
|
|
||||||
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
|
|
||||||
|
|
||||||
**Must include:**
|
|
||||||
- What the tool returns or does
|
|
||||||
- Which integrations/services it works with (if applicable)
|
|
||||||
- How to get required IDs (e.g., "Use integration_all to get the integrationId")
|
|
||||||
- Permission requirements (if non-obvious)
|
|
||||||
|
|
||||||
**Good:**
|
|
||||||
```
|
|
||||||
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Bad:**
|
|
||||||
```
|
|
||||||
"Get calendar events"
|
|
||||||
```
|
|
||||||
|
|
||||||
**For tools that return permission fields**, explain what they mean:
|
|
||||||
```
|
|
||||||
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Registering in the MCP router
|
|
||||||
|
|
||||||
After adding `.meta()` to procedures, you must register the router in `packages/api/src/mcp.ts`. This file uses **eager imports** (no `lazy()`) because MCP tool extraction requires synchronous access to all procedure definitions.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// packages/api/src/mcp.ts
|
|
||||||
import { myNewRouter } from "./router/my-new-feature";
|
|
||||||
|
|
||||||
export const mcpRouter = createTRPCRouter({
|
|
||||||
// ... existing routers
|
|
||||||
myFeature: myNewRouter,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**If your router is nested** (e.g., widget sub-routers), import and register the sub-router directly — do not import a parent that uses `lazy()`.
|
|
||||||
|
|
||||||
## What to expose vs. what to skip
|
|
||||||
|
|
||||||
**Expose:**
|
|
||||||
- Data queries (list, search, get by ID)
|
|
||||||
- Actions users would ask an AI to do (toggle, create, delete, approve)
|
|
||||||
- Status checks (health, stats, summaries)
|
|
||||||
|
|
||||||
**Skip:**
|
|
||||||
- Subscriptions (WebSocket-only, not supported by MCP)
|
|
||||||
- Internal procedures (session management, onboarding steps)
|
|
||||||
- File upload/download procedures
|
|
||||||
- Procedures that return non-serializable data (streams, blobs)
|
|
||||||
|
|
||||||
## Input schema rules
|
|
||||||
|
|
||||||
- Use `z.object({...})` for inputs — this maps cleanly to MCP tool parameters
|
|
||||||
- `z.void()` (no `.input()`) works — the MCP handler patches these to accept empty objects
|
|
||||||
- Avoid `z.union()` or `z.discriminatedUnion()` at the top level — AI clients struggle with these
|
|
||||||
- Use `.default()` on optional fields so the AI doesn't need to guess values
|
|
||||||
|
|
||||||
## Checklist for new features
|
|
||||||
|
|
||||||
1. Add `.meta({ mcp: { enabled: true, description: "..." } })` to relevant procedures
|
|
||||||
2. Import and register the router in `packages/api/src/mcp.ts` (eager, no lazy)
|
|
||||||
3. Write descriptions that explain what the tool does, what it needs, and what it returns
|
|
||||||
4. If the tool requires an ID from another tool, say which one in the description
|
|
||||||
5. Test with `curl` against `/api/mcp/mcp` to verify the tool appears in `tools/list`
|
|
||||||
+6
-3
@@ -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"
|
||||||
+1
-5
@@ -9,8 +9,4 @@ README.md
|
|||||||
.git
|
.git
|
||||||
dev
|
dev
|
||||||
.build
|
.build
|
||||||
e2e
|
e2e
|
||||||
apps/nextjs/.next
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
@@ -41,20 +41,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +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.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
|
||||||
@@ -23,7 +23,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 # required to approve pull request
|
permission-pull-requests: write # required to approve pull request
|
||||||
|
|
||||||
- name: Install GitHub CLI
|
- name: Install GitHub CLI
|
||||||
|
|||||||
@@ -11,58 +11,17 @@ on:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_COLOR: 3
|
FORCE_COLOR: 3
|
||||||
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 }}
|
|
||||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
|
|
||||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-format-autofix:
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.head_ref }}
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Get changed files
|
|
||||||
id: changed
|
|
||||||
run: |
|
|
||||||
git diff --name-only --diff-filter=ACMR origin/${{ github.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@v5
|
|
||||||
with:
|
|
||||||
commit_message: "style: auto-fix lint and format"
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
@@ -72,35 +31,31 @@ jobs:
|
|||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm turbo lint --affected && pnpm lint:ws
|
run: pnpm lint && pnpm lint:ws
|
||||||
|
|
||||||
format:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
- name: Format
|
- name: Format
|
||||||
run: pnpm turbo format --affected --continue
|
run: pnpm format
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: pnpm turbo typecheck --affected
|
run: pnpm typecheck
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -123,12 +78,11 @@ jobs:
|
|||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v4
|
|
||||||
- name: Build docker image
|
- name: Build docker image
|
||||||
id: build-docker-image
|
id: build-docker-image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
@@ -136,11 +90,8 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
load: true
|
|
||||||
tags: homarr-e2e
|
tags: homarr-e2e
|
||||||
network: host
|
network: host
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
- name: Install playwright browsers
|
- name: Install playwright browsers
|
||||||
@@ -154,12 +105,10 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
- name: Copy env
|
- name: Copy env
|
||||||
shell: bash
|
shell: bash
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm turbo build --affected
|
run: pnpm build
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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@v6
|
|
||||||
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,8 +53,8 @@ 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@v6
|
- uses: actions/checkout@v6
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == '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.16.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
|
||||||
@@ -193,7 +191,7 @@ jobs:
|
|||||||
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'
|
||||||
@@ -209,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'
|
||||||
@@ -236,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
|
||||||
@@ -276,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}}**."
|
||||||
@@ -21,10 +21,10 @@ 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
|
||||||
@@ -38,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
|
||||||
@@ -54,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:
|
||||||
@@ -66,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
|
||||||
@@ -75,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 }}"
|
||||||
@@ -84,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"
|
||||||
@@ -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,7 +48,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 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
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ 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
|
||||||
|
|||||||
+3
-2
@@ -66,8 +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
|
AGENTS.md
|
||||||
.opencode
|
.opencode
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[tools]
|
|
||||||
node = "24.16.0"
|
|
||||||
pnpm = "10.33.4"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
|
||||||
"printWidth": 120,
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"sortPackageJson": true,
|
|
||||||
"ignorePatterns": ["**/*.cjs", "**/lang/**", "static-data/**", "CHANGELOG.md"]
|
|
||||||
}
|
|
||||||
@@ -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/**"],
|
|
||||||
"settings": {
|
|
||||||
"react": { "version": "19" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-1
@@ -31,5 +31,6 @@ scopeRangeRegex: "(?:const|let|var)\\s+t\\s*=\\s*(?:await\\s+)?(?:getScopedI18n|
|
|||||||
# refactorTemplates:
|
# refactorTemplates:
|
||||||
# - i18n.get("$1")
|
# - i18n.get("$1")
|
||||||
|
|
||||||
|
|
||||||
# If set to true, only enables this custom framework (will disable all built-in frameworks)
|
# If set to true, only enables this custom framework (will disable all built-in frameworks)
|
||||||
monopoly: true
|
monopoly: true
|
||||||
Vendored
+15
-5
@@ -15,7 +15,9 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm docker:dev",
|
"command": "pnpm docker:dev",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"skipFiles": ["<node_internals>/**"]
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "lint fix",
|
"name": "lint fix",
|
||||||
@@ -23,7 +25,9 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm lint:fix",
|
"command": "pnpm lint:fix",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"skipFiles": ["<node_internals>/**"]
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "format fix",
|
"name": "format fix",
|
||||||
@@ -31,7 +35,9 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm format:fix",
|
"command": "pnpm format:fix",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"skipFiles": ["<node_internals>/**"]
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "db push",
|
"name": "db push",
|
||||||
@@ -63,7 +69,9 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm test",
|
"command": "pnpm test",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"skipFiles": ["<node_internals>/**"]
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "test ui",
|
"name": "test ui",
|
||||||
@@ -71,7 +79,9 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm test:ui",
|
"command": "pnpm test:ui",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"skipFiles": ["<node_internals>/**"]
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Vendored
+14
-3
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
|
"eslint.workingDirectories": [
|
||||||
|
{
|
||||||
|
"mode": "auto"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"eslint.experimental.useFlatConfig": true,
|
||||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||||
|
"prettier.configPath": "./tooling/prettier/index.mjs",
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"ajnart",
|
"ajnart",
|
||||||
"cqmin",
|
"cqmin",
|
||||||
@@ -27,7 +34,11 @@
|
|||||||
],
|
],
|
||||||
"i18n-ally.dirStructure": "auto",
|
"i18n-ally.dirStructure": "auto",
|
||||||
"i18n-ally.displayLanguage": "en",
|
"i18n-ally.displayLanguage": "en",
|
||||||
"i18n-ally.enabledFrameworks": ["custom"],
|
"i18n-ally.enabledFrameworks": [
|
||||||
"i18n-ally.localesPaths": ["packages/translation/src/lang"],
|
"custom"
|
||||||
"i18n-ally.keystyle": "nested"
|
],
|
||||||
|
"i18n-ally.localesPaths": [
|
||||||
|
"packages/translation/src/lang",
|
||||||
|
],
|
||||||
|
"i18n-ally.keystyle": "nested",
|
||||||
}
|
}
|
||||||
+3817
-3961
File diff suppressed because it is too large.
Load diff
@@ -3,11 +3,7 @@
|
|||||||
|
|
||||||
# Exempt Renovate‑managed files (no owners)
|
# Exempt Renovate‑managed files (no owners)
|
||||||
package.json
|
package.json
|
||||||
**/package.json
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
**/package-lock.json
|
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
**/pnpm-lock.yaml
|
|
||||||
pnpm-workspace.yaml
|
|
||||||
Dockerfile
|
Dockerfile
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24.16.0-alpine AS base
|
FROM node:24.15.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import baseConfig from "@homarr/eslint-config/base";
|
||||||
|
import nextjsConfig from "@homarr/eslint-config/nextjs";
|
||||||
|
import reactConfig from "@homarr/eslint-config/react";
|
||||||
|
|
||||||
|
/** @type {import('typescript-eslint').Config} */
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [".next/**"],
|
||||||
|
},
|
||||||
|
...baseConfig,
|
||||||
|
...reactConfig,
|
||||||
|
...nextjsConfig,
|
||||||
|
];
|
||||||
@@ -5,30 +5,19 @@ import "@homarr/common/env";
|
|||||||
import "@homarr/core/infrastructure/logs/env";
|
import "@homarr/core/infrastructure/logs/env";
|
||||||
import "@homarr/docker/env";
|
import "@homarr/docker/env";
|
||||||
|
|
||||||
import { readFileSync } from "fs";
|
|
||||||
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import MillionLint from "@million/lint";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const homarrVersion = (() => {
|
|
||||||
for (const candidate of ["../../package.json", "./package.json"]) {
|
|
||||||
try {
|
|
||||||
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
||||||
if (pkg.name === "homarr") return pkg.version as string;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return "unknown";
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Package path does not work... so we need to use relative path
|
// Package path does not work... so we need to use relative path
|
||||||
const withNextIntl = createNextIntlPlugin({
|
const withNextIntl = createNextIntlPlugin({
|
||||||
|
experimental: {
|
||||||
|
createMessagesDeclaration: "../../packages/translation/src/lang/en.json",
|
||||||
|
},
|
||||||
requestConfig: "../../packages/translation/src/request.ts",
|
requestConfig: "../../packages/translation/src/request.ts",
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
env: {
|
|
||||||
HOMARR_VERSION: homarrVersion,
|
|
||||||
},
|
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// react compiler breaks mantine-react-table, so disabled for now
|
// react compiler breaks mantine-react-table, so disabled for now
|
||||||
@@ -39,12 +28,10 @@ const nextConfig: NextConfig = {
|
|||||||
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
||||||
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
||||||
*/
|
*/
|
||||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
preloadEntriesOnStart: false,
|
|
||||||
webpackMemoryOptimizations: true,
|
|
||||||
},
|
},
|
||||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||||
images: {
|
images: {
|
||||||
@@ -55,18 +42,9 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// skipcq: JS-0116
|
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
|
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
source: "/sw.js",
|
|
||||||
headers: [
|
|
||||||
{ key: "Content-Type", value: "application/javascript; charset=utf-8" },
|
|
||||||
{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
|
|
||||||
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
source: "/(.*)", // Apply CSP to all routes
|
source: "/(.*)", // Apply CSP to all routes
|
||||||
headers: [
|
headers: [
|
||||||
@@ -96,4 +74,8 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
+10
-17
@@ -7,18 +7,18 @@
|
|||||||
"build": "pnpm with-env next build",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .next .turbo node_modules",
|
"clean": "git clean -xdf .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev",
|
"dev": "pnpm with-env next dev",
|
||||||
"format": "oxfmt .",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "oxlint",
|
"lint": "eslint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "dotenv -e ../../.env --"
|
"with-env": "dotenv -e ../../.env --"
|
||||||
},
|
},
|
||||||
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "catalog:",
|
"@dnd-kit/core": "catalog:",
|
||||||
"@dnd-kit/modifiers": "catalog:",
|
"@dnd-kit/modifiers": "catalog:",
|
||||||
"@dnd-kit/sortable": "catalog:",
|
"@dnd-kit/sortable": "catalog:",
|
||||||
"@dnd-kit/utilities": "catalog:",
|
"@dnd-kit/utilities": "catalog:",
|
||||||
"@gfazioli/mantine-onboarding-tour": "catalog:",
|
|
||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
@@ -56,10 +56,8 @@
|
|||||||
"@mantine/hooks": "catalog:",
|
"@mantine/hooks": "catalog:",
|
||||||
"@mantine/modals": "catalog:",
|
"@mantine/modals": "catalog:",
|
||||||
"@mantine/tiptap": "catalog:",
|
"@mantine/tiptap": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "catalog:",
|
"@million/lint": "catalog:",
|
||||||
"@scalar/api-reference-react": "catalog:",
|
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
"@tanstack/query-persist-client-core": "catalog:",
|
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
"@tanstack/react-query-next-experimental": "catalog:",
|
"@tanstack/react-query-next-experimental": "catalog:",
|
||||||
@@ -75,18 +73,11 @@
|
|||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
|
||||||
"flag-icons": "catalog:",
|
"flag-icons": "catalog:",
|
||||||
"glob": "catalog:",
|
"glob": "catalog:",
|
||||||
"adm-zip": "catalog:",
|
|
||||||
"better-sqlite3": "catalog:",
|
|
||||||
"isomorphic-dompurify": "catalog:",
|
"isomorphic-dompurify": "catalog:",
|
||||||
"jszip": "catalog:",
|
|
||||||
"jotai": "catalog:",
|
"jotai": "catalog:",
|
||||||
"lucide-react": "catalog:",
|
|
||||||
"mantine-datatable": "catalog:",
|
|
||||||
"mantine-react-table": "catalog:",
|
"mantine-react-table": "catalog:",
|
||||||
"mcp-handler": "catalog:",
|
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"postcss-preset-mantine": "catalog:",
|
"postcss-preset-mantine": "catalog:",
|
||||||
"prismjs": "catalog:",
|
"prismjs": "catalog:",
|
||||||
@@ -95,24 +86,26 @@
|
|||||||
"react-error-boundary": "catalog:",
|
"react-error-boundary": "catalog:",
|
||||||
"react-simple-code-editor": "catalog:",
|
"react-simple-code-editor": "catalog:",
|
||||||
"sass": "catalog:",
|
"sass": "catalog:",
|
||||||
"sql.js": "catalog:",
|
|
||||||
"superjson": "catalog:",
|
"superjson": "catalog:",
|
||||||
"trpc-to-mcp": "catalog:",
|
"swagger-ui-react": "catalog:",
|
||||||
"use-deep-compare-effect": "catalog:",
|
"use-deep-compare-effect": "catalog:",
|
||||||
"yaml": "catalog:",
|
"yaml": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/adm-zip": "catalog:",
|
|
||||||
"@types/better-sqlite3": "catalog:",
|
|
||||||
"@types/chroma-js": "catalog:",
|
"@types/chroma-js": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/prismjs": "catalog:",
|
"@types/prismjs": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
|
"@types/swagger-ui-react": "catalog:",
|
||||||
"concurrently": "catalog:",
|
"concurrently": "catalog:",
|
||||||
|
"eslint": "catalog:",
|
||||||
"node-loader": "catalog:",
|
"node-loader": "catalog:",
|
||||||
|
"prettier": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -1,7 +0,0 @@
|
|||||||
self.addEventListener("install", () => {
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
|
||||||
event.waitUntil(self.clients.claim());
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
|
|
||||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
|
||||||
|
|
||||||
const corsHeaders = {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "*",
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const baseUrl = extractBaseUrlFromHeaders(await headers());
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
issuer: baseUrl,
|
|
||||||
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
|
|
||||||
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
|
|
||||||
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
|
|
||||||
response_types_supported: ["code"],
|
|
||||||
grant_types_supported: ["authorization_code"],
|
|
||||||
code_challenge_methods_supported: ["S256"],
|
|
||||||
token_endpoint_auth_methods_supported: ["none"],
|
|
||||||
scopes_supported: ["mcp:tools"],
|
|
||||||
},
|
|
||||||
{ headers: corsHeaders },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function OPTIONS() {
|
|
||||||
return new Response(null, { status: 204, headers: corsHeaders });
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
|
|
||||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
|
||||||
|
|
||||||
const corsHeaders = {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "*",
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const baseUrl = extractBaseUrlFromHeaders(await headers());
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
resource: baseUrl,
|
|
||||||
authorization_servers: [baseUrl],
|
|
||||||
},
|
|
||||||
{ headers: corsHeaders },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function OPTIONS() {
|
|
||||||
return new Response(null, { status: 204, headers: corsHeaders });
|
|
||||||
}
|
|
||||||
@@ -2,27 +2,29 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||||
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||||
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||||
import { theme } from "@homarr/ui";
|
|
||||||
|
|
||||||
export const CustomMantineProvider = ({
|
export const CustomMantineProvider = ({
|
||||||
children,
|
children,
|
||||||
defaultColorScheme,
|
defaultColorScheme,
|
||||||
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
|
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||||
const manager = useColorSchemeManager();
|
const manager = useColorSchemeManager();
|
||||||
return (
|
return (
|
||||||
<DirectionProvider>
|
<DirectionProvider>
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
defaultColorScheme={defaultColorScheme}
|
defaultColorScheme={defaultColorScheme}
|
||||||
colorSchemeManager={manager}
|
colorSchemeManager={manager}
|
||||||
theme={theme}
|
theme={createTheme({
|
||||||
cssVariablesResolver={v8CssVariablesResolver}
|
primaryColor: "red",
|
||||||
|
autoContrast: true,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
@@ -33,7 +35,7 @@ export const CustomMantineProvider = ({
|
|||||||
export function useColorSchemeManager(): MantineColorSchemeManager {
|
export function useColorSchemeManager(): MantineColorSchemeManager {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const updateCookieValue = (value: MantineColorScheme) => {
|
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
|
||||||
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
|
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ export function useColorSchemeManager(): MantineColorSchemeManager {
|
|||||||
},
|
},
|
||||||
|
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
|
if (value === "auto") return;
|
||||||
try {
|
try {
|
||||||
if (session) {
|
if (session) {
|
||||||
mutateColorScheme({ colorScheme: value });
|
mutateColorScheme({ colorScheme: value });
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
|
|
||||||
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
|
|
||||||
import { parse, stringify } from "superjson";
|
|
||||||
|
|
||||||
import { fetchApi } from "@homarr/api/client";
|
|
||||||
import {
|
|
||||||
getActiveQueryCacheBoardId,
|
|
||||||
isPersistableWidgetQueryKey,
|
|
||||||
queryCacheStoragePrefix,
|
|
||||||
} from "@homarr/api/query-cache";
|
|
||||||
|
|
||||||
const queryCacheStorage = {
|
|
||||||
async getItem(_key) {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
async setItem(key, value) {
|
|
||||||
const boardId = getActiveQueryCacheBoardId();
|
|
||||||
if (!boardId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
|
|
||||||
} catch {
|
|
||||||
// Query persistence is best-effort; failed writes should not affect widget rendering.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async removeItem(key) {
|
|
||||||
const boardId = getActiveQueryCacheBoardId();
|
|
||||||
if (!boardId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetchApi.queryCache.removeItem.mutate({ boardId, key });
|
|
||||||
} catch {
|
|
||||||
// Query persistence is best-effort; failed removals should not affect widget rendering.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} satisfies AsyncStorage<string>;
|
|
||||||
|
|
||||||
export const createWidgetQueryPersister = () =>
|
|
||||||
experimental_createQueryPersister<string>({
|
|
||||||
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
|
|
||||||
maxAge: Number.POSITIVE_INFINITY,
|
|
||||||
prefix: queryCacheStoragePrefix,
|
|
||||||
refetchOnRestore: true,
|
|
||||||
serialize: (query) => stringify(query),
|
|
||||||
deserialize: (value) => parse<PersistedQuery>(value),
|
|
||||||
filters: {
|
|
||||||
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -17,16 +17,10 @@ import {
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import type { SuperJSONResult } from "superjson";
|
import type { SuperJSONResult } from "superjson";
|
||||||
|
|
||||||
import { TRPCClientError } from "@trpc/client";
|
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
|
|
||||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { showWarningNotification } from "@homarr/notifications";
|
|
||||||
|
|
||||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
|
||||||
|
|
||||||
const getWebSocketProtocol = () => {
|
const getWebSocketProtocol = () => {
|
||||||
// window is not defined on server side
|
// window is not defined on server side
|
||||||
@@ -55,28 +49,12 @@ const wsClient = createWSClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||||
const [queryPersister] = useState(() => createWidgetQueryPersister());
|
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
persister: queryPersister.persisterFn,
|
staleTime: 5 * 1000,
|
||||||
staleTime: queryCacheDefaultStaleTimeMs,
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
onError(error) {
|
|
||||||
if (
|
|
||||||
error instanceof TRPCClientError &&
|
|
||||||
error.data?.code === "FORBIDDEN" &&
|
|
||||||
error.message === "Mutations are disabled in demo mode"
|
|
||||||
) {
|
|
||||||
showWarningNotification({
|
|
||||||
title: "Demo mode",
|
|
||||||
message: "This action is disabled in demo mode.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
|||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Card w={64 * 6} maw="90vw">
|
<Card withBorder w={64 * 6} maw="90vw">
|
||||||
<RegistrationForm invite={invite} />
|
<RegistrationForm invite={invite} />
|
||||||
</Card>
|
</Card>
|
||||||
<Text size="xs" c="gray.5" ta="center">
|
<Text size="xs" c="gray.5" ta="center">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { z } from "zod/v4";
|
|||||||
|
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { useForm } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -162,7 +162,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
|||||||
|
|
||||||
interface SubmitButtonProps {
|
interface SubmitButtonProps {
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
form: UseFormReturnType<FormType>;
|
form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
|
||||||
provider: "credentials" | "ldap";
|
provider: "credentials" | "ldap";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps)
|
|||||||
{tForgotPassword("label")}
|
{tForgotPassword("label")}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
|
|
||||||
<Collapse expanded={visible}>
|
<Collapse in={visible}>
|
||||||
<Card>
|
<Card>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="sm">{tForgotPassword("description")}</Text>
|
<Text size="sm">{tForgotPassword("description")}</Text>
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
|
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconLogin } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { env } from "@homarr/auth/env";
|
import { env } from "@homarr/auth/env";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||||
|
|
||||||
import { env as appEnv } from "~/env";
|
|
||||||
|
|
||||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||||
import { LoginForm } from "./_login-form";
|
import { LoginForm } from "./_login-form";
|
||||||
|
|
||||||
@@ -40,14 +37,7 @@ export default async function Login(props: LoginProps) {
|
|||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
{appEnv.DEMO_MODE && (
|
<Card withBorder w={64 * 6} maw="90vw">
|
||||||
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Card w={64 * 6} maw="90vw">
|
|
||||||
<LoginForm
|
<LoginForm
|
||||||
providers={env.AUTH_PROVIDERS}
|
providers={env.AUTH_PROVIDERS}
|
||||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||||
|
|||||||
@@ -1,27 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Box, Stack } from "@mantine/core";
|
import { useCallback, useRef } from "react";
|
||||||
|
import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||||
|
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||||
|
import { useIsBoardReady } from "./_ready-context";
|
||||||
|
|
||||||
|
let boardName: string | null = null;
|
||||||
|
|
||||||
|
export const updateBoardName = (name: string | null) => {
|
||||||
|
boardName = name;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"];
|
||||||
|
|
||||||
|
export const useUpdateBoard = () => {
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
|
const updateBoard = useCallback(
|
||||||
|
(updaterWithoutUndefined: UpdateCallback) => {
|
||||||
|
if (!boardName) {
|
||||||
|
throw new Error("Board name is not set");
|
||||||
|
}
|
||||||
|
utils.board.getBoardByName.setData({ name: boardName }, (previous) =>
|
||||||
|
previous ? updaterWithoutUndefined(previous) : previous,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[utils],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateBoard,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const ClientBoard = () => {
|
export const ClientBoard = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const currentLayoutId = useCurrentLayout();
|
const currentLayoutId = useCurrentLayout();
|
||||||
|
const isReady = useIsBoardReady();
|
||||||
|
|
||||||
const fullWidthSortedSections = board.sections
|
const fullWidthSortedSections = board.sections
|
||||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<BoardBackgroundVideo />
|
<BoardBackgroundVideo />
|
||||||
<Stack h="100%">
|
<LoadingOverlay
|
||||||
|
visible={!isReady}
|
||||||
|
transitionProps={{ duration: 500 }}
|
||||||
|
loaderProps={{ size: "lg" }}
|
||||||
|
h={fullHeightWithoutHeaderAndFooter}
|
||||||
|
/>
|
||||||
|
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
||||||
{fullWidthSortedSections.map((section) =>
|
{fullWidthSortedSections.map((section) =>
|
||||||
section.kind === "empty" ? (
|
section.kind === "empty" ? (
|
||||||
|
// Unique keys per layout to always reinitialize the gridstack
|
||||||
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||||
) : (
|
) : (
|
||||||
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
// Placed here because gridstack styles are used for board content
|
// Placed here because gridstack styles are used for board content
|
||||||
import "~/styles/gridstack.scss";
|
import "~/styles/gridstack.scss";
|
||||||
|
|
||||||
import type { DehydratedState } from "@tanstack/react-query";
|
|
||||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||||
import { parse } from "superjson";
|
|
||||||
|
|
||||||
import { getQueryClient } from "@homarr/api/server";
|
import { getQueryClient } from "@homarr/api/server";
|
||||||
import { IntegrationProvider } from "@homarr/auth/client";
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
@@ -17,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
|
|||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { getAllQueryCacheAsync } from "@homarr/redis";
|
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||||
|
|
||||||
@@ -42,15 +38,16 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerActions: <BoardContentHeaderActions />,
|
headerActions: <BoardContentHeaderActions />,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
withTour: true,
|
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||||
const resolvedParams = await params;
|
const session = await auth();
|
||||||
|
const integrations = await getIntegrationsWithPermissionsAsync(session);
|
||||||
|
|
||||||
|
const board = await getInitialBoard(await params);
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
|
// Prefetch item data
|
||||||
|
|
||||||
const itemsMap = board.items.reduce((acc, item) => {
|
const itemsMap = board.items.reduce((acc, item) => {
|
||||||
const existing = acc.get(item.kind);
|
const existing = acc.get(item.kind);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -61,29 +58,21 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
return acc;
|
return acc;
|
||||||
}, new Map<WidgetKind, Item[]>());
|
}, new Map<WidgetKind, Item[]>());
|
||||||
|
|
||||||
const [integrations] = await Promise.all([
|
for (const [kind, items] of itemsMap) {
|
||||||
getIntegrationsWithPermissionsAsync(session),
|
await prefetchForKindAsync(kind, queryClient, items).catch((error) => {
|
||||||
...Array.from(itemsMap).map(([kind, items]) =>
|
logger.error(
|
||||||
prefetchForKindAsync(kind, queryClient, items).catch((error) => {
|
new ErrorWithMetadata(
|
||||||
logger.error(
|
"Failed to prefetch widget",
|
||||||
new ErrorWithMetadata(
|
{ widgetKind: kind, itemCount: items.length },
|
||||||
"Failed to prefetch widget",
|
{ cause: error },
|
||||||
{ widgetKind: kind, itemCount: items.length },
|
),
|
||||||
{ cause: error },
|
);
|
||||||
),
|
});
|
||||||
);
|
}
|
||||||
}),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const userId = session?.user.id ?? "anonymous";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<IntegrationProvider integrations={integrations}>
|
<IntegrationProvider integrations={integrations}>
|
||||||
<Suspense>
|
|
||||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
|
||||||
</Suspense>
|
|
||||||
<DynamicClientBoard />
|
<DynamicClientBoard />
|
||||||
</IntegrationProvider>
|
</IntegrationProvider>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
@@ -117,37 +106,3 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PersistedEntry {
|
|
||||||
state: { data: unknown; dataUpdatedAt: number; status: string };
|
|
||||||
queryKey: unknown[];
|
|
||||||
queryHash: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
|
|
||||||
try {
|
|
||||||
const entries = await getAllQueryCacheAsync(userId, boardId);
|
|
||||||
const queries: DehydratedState["queries"] = [];
|
|
||||||
|
|
||||||
for (const serialized of Object.values(entries)) {
|
|
||||||
try {
|
|
||||||
const persisted = parse<PersistedEntry>(serialized);
|
|
||||||
if (persisted.state.status !== "success") continue;
|
|
||||||
|
|
||||||
queries.push({
|
|
||||||
state: persisted.state as DehydratedState["queries"][number]["state"],
|
|
||||||
queryKey: persisted.queryKey,
|
|
||||||
queryHash: persisted.queryHash,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// skip malformed entries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queries.length === 0) return null;
|
|
||||||
|
|
||||||
return <HydrationBoundary state={{ mutations: [], queries }} />;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
import { Group, Menu, ScrollArea } from "@mantine/core";
|
||||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconBox,
|
IconBox,
|
||||||
@@ -12,7 +11,6 @@ import {
|
|||||||
IconLayoutBoard,
|
IconLayoutBoard,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPencilOff,
|
IconPencilOff,
|
||||||
IconPlug,
|
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconReplace,
|
IconReplace,
|
||||||
IconResize,
|
IconResize,
|
||||||
@@ -38,16 +36,14 @@ import { useBoardPermissions } from "~/components/board/permissions/client";
|
|||||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
|
|
||||||
export const BoardContentHeaderActions = () => {
|
export const BoardContentHeaderActions = () => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
|
||||||
|
|
||||||
if (!hasChangeAccess || isLoading) {
|
if (!hasChangeAccess) {
|
||||||
return <SelectBoardsMenu />;
|
return <SelectBoardsMenu />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,15 +51,11 @@ export const BoardContentHeaderActions = () => {
|
|||||||
<>
|
<>
|
||||||
{isEditMode && <AddMenu />}
|
{isEditMode && <AddMenu />}
|
||||||
|
|
||||||
<EditModeMenu demoMode={demoMode ?? false} />
|
<EditModeMenu />
|
||||||
|
|
||||||
{!demoMode && (
|
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||||
<OnboardingTour.Target id="board-settings">
|
<IconSettings stroke={1.5} />
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
</HeaderButton>
|
||||||
<IconSettings stroke={1.5} />
|
|
||||||
</HeaderButton>
|
|
||||||
</OnboardingTour.Target>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SelectBoardsMenu />
|
<SelectBoardsMenu />
|
||||||
</>
|
</>
|
||||||
@@ -75,7 +67,6 @@ const AddMenu = () => {
|
|||||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
|
||||||
const { addCategoryToEnd } = useCategoryActions();
|
const { addCategoryToEnd } = useCategoryActions();
|
||||||
const { addDynamicSection } = useDynamicSectionActions();
|
const { addDynamicSection } = useDynamicSectionActions();
|
||||||
const { createItem } = useItemActions();
|
const { createItem } = useItemActions();
|
||||||
@@ -117,12 +108,8 @@ const AddMenu = () => {
|
|||||||
});
|
});
|
||||||
}, [openAppSelectModal, createItem]);
|
}, [openAppSelectModal, createItem]);
|
||||||
|
|
||||||
const handleAddIntegration = useCallback(() => {
|
|
||||||
openIntegrationSelectModal({});
|
|
||||||
}, [openIntegrationSelectModal]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end">
|
<Menu position="bottom-end" withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<HeaderButton w="auto" px={4}>
|
<HeaderButton w="auto" px={4}>
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
@@ -140,10 +127,6 @@ const AddMenu = () => {
|
|||||||
{t("app.action.add")}
|
{t("app.action.add")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
|
||||||
{t("integration.action.create")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||||
@@ -158,7 +141,7 @@ const AddMenu = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
const EditModeMenu = () => {
|
||||||
const [isEditMode, { open, close }] = useEditMode();
|
const [isEditMode, { open, close }] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
@@ -181,28 +164,18 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const discardDemoChanges = useCallback(() => {
|
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
|
||||||
close();
|
|
||||||
}, [utils, board.name, close]);
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (isEditMode) {
|
if (isEditMode) return saveBoard(board);
|
||||||
if (demoMode) return discardDemoChanges();
|
|
||||||
return saveBoard(board);
|
|
||||||
}
|
|
||||||
open();
|
open();
|
||||||
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
|
}, [board, isEditMode, saveBoard, open]);
|
||||||
|
|
||||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||||
usePreventLeaveWithDirty(isEditMode);
|
usePreventLeaveWithDirty(isEditMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingTour.Target id="board-edit-mode">
|
<HeaderButton onClick={toggle} loading={isPending}>
|
||||||
<HeaderButton onClick={toggle} loading={isPending}>
|
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
</HeaderButton>
|
||||||
</HeaderButton>
|
|
||||||
</OnboardingTour.Target>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,31 +183,27 @@ const SelectBoardsMenu = () => {
|
|||||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingTour.Target id="board-switcher">
|
<Menu position="bottom-end" withArrow>
|
||||||
<Box>
|
<Menu.Target>
|
||||||
<Menu position="bottom-end">
|
<HeaderButton w="auto" px={4}>
|
||||||
<Menu.Target>
|
<IconReplace stroke={1.5} />
|
||||||
<HeaderButton w="auto" px={4}>
|
</HeaderButton>
|
||||||
<IconReplace stroke={1.5} />
|
</Menu.Target>
|
||||||
</HeaderButton>
|
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||||
</Menu.Target>
|
<ScrollArea.Autosize mah={300}>
|
||||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
{boards.map((board) => (
|
||||||
<ScrollArea.Autosize mah={300}>
|
<Menu.Item
|
||||||
{boards.map((board) => (
|
key={board.id}
|
||||||
<Menu.Item
|
component={Link}
|
||||||
key={board.id}
|
href={`/boards/${board.name}`}
|
||||||
component={Link}
|
leftSection={<IconLayoutBoard size={20} />}
|
||||||
href={`/boards/${board.name}`}
|
>
|
||||||
leftSection={<IconLayoutBoard size={20} />}
|
{board.name}
|
||||||
>
|
</Menu.Item>
|
||||||
{board.name}
|
))}
|
||||||
</Menu.Item>
|
</ScrollArea.Autosize>
|
||||||
))}
|
</Menu.Dropdown>
|
||||||
</ScrollArea.Autosize>
|
</Menu>
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Box>
|
|
||||||
</OnboardingTour.Target>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,7 +244,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
if (env.NODE_ENV === "development") return;
|
if (env.NODE_ENV === "development") return; // Allow to reload in development
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.returnValue = true;
|
event.returnValue = true;
|
||||||
|
|||||||
@@ -1,19 +1,42 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { createContext, useCallback, useContext } from "react";
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
const BoardReadyContext = createContext<{
|
const BoardReadyContext = createContext<{
|
||||||
|
isReady: boolean;
|
||||||
markAsReady: (id: string) => void;
|
markAsReady: (id: string) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
|
export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
const pathname = usePathname();
|
||||||
const markAsReady = useCallback((_id: string) => {}, []);
|
const utils = clientApi.useUtils();
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const [readySections, setReadySections] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Reset sections required for ready state
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setReadySections([]);
|
||||||
|
};
|
||||||
|
}, [pathname, utils]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id)));
|
||||||
|
}, [board.sections.length, setReadySections]);
|
||||||
|
|
||||||
|
const markAsReady = useCallback((id: string) => {
|
||||||
|
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardReadyContext.Provider
|
<BoardReadyContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
isReady: board.sections.length === readySections.length,
|
||||||
markAsReady,
|
markAsReady,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -31,3 +54,13 @@ export const useMarkSectionAsReady = () => {
|
|||||||
|
|
||||||
return context.markAsReady;
|
return context.markAsReady;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useIsBoardReady = () => {
|
||||||
|
const context = useContext(BoardReadyContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("BoardReadyProvider is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.isReady;
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import type { MantineColorsTuple } from "@mantine/core";
|
import type { MantineColorsTuple } from "@mantine/core";
|
||||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
@@ -24,7 +24,6 @@ export const BoardMantineProvider = ({
|
|||||||
},
|
},
|
||||||
primaryColor: "primaryColor",
|
primaryColor: "primaryColor",
|
||||||
autoContrast: true,
|
autoContrast: true,
|
||||||
defaultRadius: "sm",
|
|
||||||
fontSizes: {
|
fontSizes: {
|
||||||
"2xl": rem(24),
|
"2xl": rem(24),
|
||||||
"3xl": rem(28),
|
"3xl": rem(28),
|
||||||
@@ -33,12 +32,7 @@ export const BoardMantineProvider = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider
|
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
|
||||||
defaultColorScheme={defaultColorScheme}
|
|
||||||
theme={theme}
|
|
||||||
colorSchemeManager={colorSchemeManager}
|
|
||||||
cssVariablesResolver={v8CssVariablesResolver}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
|||||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={12}>
|
<Grid.Col span={12}>
|
||||||
<Collapse expanded={showPreview}>
|
<Collapse in={showPreview}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
|||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
|
||||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import type { Board } from "./_types";
|
import type { Board } from "./_types";
|
||||||
import type { Params } from "./(content)/_creator";
|
import type { Params } from "./(content)/_creator";
|
||||||
@@ -21,21 +20,14 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
|||||||
|
|
||||||
const logger = createLogger({ module: "createBoardLayout" });
|
const logger = createLogger({ module: "createBoardLayout" });
|
||||||
|
|
||||||
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
|
|
||||||
if (!hasSession) return <>{children}</>;
|
|
||||||
return <BoardTourProvider>{children}</BoardTourProvider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
headerActions: JSX.Element;
|
headerActions: JSX.Element;
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
withTour?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardLayout = <TParams extends Params>({
|
export const createBoardLayout = <TParams extends Params>({
|
||||||
headerActions,
|
headerActions,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
withTour = false,
|
|
||||||
}: CreateBoardLayoutProps<TParams>) => {
|
}: CreateBoardLayoutProps<TParams>) => {
|
||||||
const Layout = async ({
|
const Layout = async ({
|
||||||
params,
|
params,
|
||||||
@@ -69,16 +61,14 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
<EditModeProvider>
|
<EditModeProvider>
|
||||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||||
<CustomCss />
|
<CustomCss />
|
||||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
<ClientShell hasNavigation={false}>
|
||||||
<ClientShell hasNavigation={false}>
|
<MainHeader
|
||||||
<MainHeader
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
actions={headerActions}
|
||||||
actions={headerActions}
|
hasNavigation={false}
|
||||||
hasNavigation={false}
|
/>
|
||||||
/>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
</ClientShell>
|
||||||
</ClientShell>
|
|
||||||
</BoardTourWrapper>
|
|
||||||
</BoardMantineProvider>
|
</BoardMantineProvider>
|
||||||
</EditModeProvider>
|
</EditModeProvider>
|
||||||
</BoardReadyProvider>
|
</BoardReadyProvider>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
|
||||||
import { Card } from "@mantine/core";
|
|
||||||
|
|
||||||
interface InitStepCardProps {
|
|
||||||
withBorder?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InitStepCard = ({ children, withBorder = true }: PropsWithChildren<InitStepCardProps>) => (
|
|
||||||
<Card w="100%" withBorder={withBorder}>
|
|
||||||
{children}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export const onboardingContentWidth = 64 * 16;
|
|
||||||
export const onboardingMaxWidth = "90vw";
|
|
||||||
@@ -1,27 +1,39 @@
|
|||||||
import type { MantineColor } from "@mantine/core";
|
import type { MantineColor } from "@mantine/core";
|
||||||
import { Button, Stack, Text } from "@mantine/core";
|
import { Button, Card, Stack, Text } from "@mantine/core";
|
||||||
import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
|
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { getMantineColor } from "@homarr/common";
|
import { getMantineColor } from "@homarr/common";
|
||||||
|
import { db } from "@homarr/db";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
|
|
||||||
export const InitFinish = async () => {
|
export const InitFinish = async () => {
|
||||||
|
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
|
||||||
const tFinish = await getScopedI18n("init.step.finish");
|
const tFinish = await getScopedI18n("init.step.finish");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<Card w={64 * 6} maw="90vw" withBorder>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>{tFinish("description")}</Text>
|
<Text>{tFinish("description")}</Text>
|
||||||
|
|
||||||
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
|
{firstBoard ? (
|
||||||
{tFinish("action.goToBoard", { name: "dashboard" })}
|
<InternalLinkButton
|
||||||
</InternalLinkButton>
|
href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`}
|
||||||
|
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
|
||||||
|
>
|
||||||
|
{tFinish("action.goToBoard", { name: firstBoard.name })}
|
||||||
|
</InternalLinkButton>
|
||||||
|
) : (
|
||||||
|
<InternalLinkButton
|
||||||
|
href="/auth/login?callbackUrl=/manage/boards"
|
||||||
|
iconProps={{ icon: IconCategoryPlus, color: "blue" }}
|
||||||
|
>
|
||||||
|
{tFinish("action.createBoard")}
|
||||||
|
</InternalLinkButton>
|
||||||
|
)}
|
||||||
|
|
||||||
{isProviderEnabled("credentials") && (
|
{isProviderEnabled("credentials") && (
|
||||||
<InternalLinkButton
|
<InternalLinkButton
|
||||||
@@ -39,7 +51,7 @@ export const InitFinish = async () => {
|
|||||||
{tFinish("action.docs")}
|
{tFinish("action.docs")}
|
||||||
</ExternalLinkButton>
|
</ExternalLinkButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Stack, TextInput } from "@mantine/core";
|
import { Button, Card, Stack, TextInput } from "@mantine/core";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -10,8 +10,6 @@ import { useZodForm } from "@homarr/form";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { groupCreateSchema } from "@homarr/validation/group";
|
import { groupCreateSchema } from "@homarr/validation/group";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
|
|
||||||
export const InitGroup = () => {
|
export const InitGroup = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
|
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
|
||||||
@@ -36,7 +34,7 @@ export const InitGroup = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<Card w={64 * 6} maw="90vw" withBorder>
|
||||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -50,6 +48,6 @@ export const InitGroup = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { ActionIcon, Button, Group, Text } from "@mantine/core";
|
import { ActionIcon, Button, Card, Group, Text } from "@mantine/core";
|
||||||
import type { FileWithPath } from "@mantine/dropzone";
|
import type { FileWithPath } from "@mantine/dropzone";
|
||||||
import { IconPencil } from "@tabler/icons-react";
|
import { IconPencil } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { humanFileSize } from "@homarr/common";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
|
|
||||||
interface FileInfoCardProps {
|
interface FileInfoCardProps {
|
||||||
file: FileWithPath;
|
file: FileWithPath;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
@@ -15,7 +13,7 @@ interface FileInfoCardProps {
|
|||||||
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||||
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
||||||
return (
|
return (
|
||||||
<InitStepCard withBorder={false}>
|
<Card w={64 * 12 + 8} maw="90vw">
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Group>
|
<Group>
|
||||||
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||||
@@ -38,6 +36,6 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
|||||||
<IconPencil size={16} stroke={1.5} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
import { Stack } from "@mantine/core";
|
import { Card, Stack } from "@mantine/core";
|
||||||
import type { FileWithPath } from "@mantine/dropzone";
|
import type { FileWithPath } from "@mantine/dropzone";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { InitialOldmarrImport } from "@homarr/old-import/components";
|
import { InitialOldmarrImport } from "@homarr/old-import/components";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
import { FileInfoCard } from "./file-info-card";
|
import { FileInfoCard } from "./file-info-card";
|
||||||
import { ImportDropZone } from "./import-dropzone";
|
import { ImportDropZone } from "./import-dropzone";
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ export const InitImport = () => {
|
|||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||||
<ImportDropZone
|
<ImportDropZone
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
updateFile={(file) => {
|
updateFile={(file) => {
|
||||||
@@ -41,12 +40,12 @@ export const InitImport = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack w="100%" mb="sm">
|
<Stack mb="sm">
|
||||||
<FileInfoCard file={file} onRemove={() => setFile(null)} />
|
<FileInfoCard file={file} onRemove={() => setFile(null)} />
|
||||||
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
|
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
Collapse,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Paper,
|
|
||||||
ScrollArea,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
|
||||||
|
|
||||||
export interface DiscoveredIntegration {
|
|
||||||
containerId: string;
|
|
||||||
containerName: string;
|
|
||||||
kind: IntegrationKind;
|
|
||||||
suggestedUrl: string;
|
|
||||||
publishedPort: number | null;
|
|
||||||
iconUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscoveredApp {
|
|
||||||
containerId: string;
|
|
||||||
containerName: string;
|
|
||||||
suggestedUrl: string;
|
|
||||||
publishedPort: number | null;
|
|
||||||
iconUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DockerDiscoveryIndicatorProps {
|
|
||||||
integrations: DiscoveredIntegration[];
|
|
||||||
apps: DiscoveredApp[];
|
|
||||||
selectedAppIds: Set<string>;
|
|
||||||
onToggleApp: (containerId: string) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const APP_CARD_HEIGHT = 80;
|
|
||||||
|
|
||||||
export const DockerDiscoveryIndicator = ({
|
|
||||||
integrations,
|
|
||||||
apps,
|
|
||||||
selectedAppIds,
|
|
||||||
onToggleApp,
|
|
||||||
isLoading,
|
|
||||||
}: DockerDiscoveryIndicatorProps) => {
|
|
||||||
const hasApps = apps.length > 0;
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
const expandedOnce = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if ((hasApps || integrations.length > 0) && !expandedOnce.current) {
|
|
||||||
expandedOnce.current = true;
|
|
||||||
setExpanded(true);
|
|
||||||
}
|
|
||||||
}, [hasApps, integrations.length]);
|
|
||||||
|
|
||||||
const t = useScopedI18n("init.step.integrations.docker");
|
|
||||||
const totalCount = integrations.length + apps.length;
|
|
||||||
|
|
||||||
if (!isLoading && totalCount === 0) return null;
|
|
||||||
|
|
||||||
const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
withBorder
|
|
||||||
p="sm"
|
|
||||||
radius="md"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--mantine-color-blue-4)",
|
|
||||||
background: "var(--mantine-color-blue-light)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
|
||||||
<IconBrandDocker size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={0} style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("scanning")}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("scanningDescription")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Loader size="sm" color="blue" type="dots" />
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
withBorder
|
|
||||||
p="sm"
|
|
||||||
radius="md"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--mantine-color-blue-4)",
|
|
||||||
background: "var(--mantine-color-blue-light)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UnstyledButton onClick={() => setExpanded((prev) => !prev)} w="100%">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
|
||||||
<IconBrandDocker size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("detected", { count: String(totalCount) })}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("detectedBreakdown", {
|
|
||||||
integrations: String(integrations.length),
|
|
||||||
apps: String(apps.length),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
<IconChevronDown
|
|
||||||
size={16}
|
|
||||||
color="var(--mantine-color-dimmed)"
|
|
||||||
style={{
|
|
||||||
transform: expanded ? "rotate(180deg)" : undefined,
|
|
||||||
transition: "transform 200ms ease",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
|
|
||||||
<Collapse expanded={expanded}>
|
|
||||||
<Stack gap="xs" mt="sm">
|
|
||||||
{integrations.length > 0 && (
|
|
||||||
<Stack gap={4}>
|
|
||||||
{integrations.map((integration) => (
|
|
||||||
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
|
|
||||||
<IntegrationAvatar kind={integration.kind} size="xs" />
|
|
||||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{integration.containerName}
|
|
||||||
</Text>
|
|
||||||
<Badge size="xs" variant="light" color="blue">
|
|
||||||
{getIntegrationName(integration.kind)}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasApps && (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="xs" fw={500}>
|
|
||||||
{t("appSectionLabel", { count: String(selectedAppCount) })}
|
|
||||||
</Text>
|
|
||||||
<ScrollArea.Autosize mah="30vh">
|
|
||||||
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
|
||||||
{apps.map((app) => {
|
|
||||||
const isSelected = selectedAppIds.has(app.containerId);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={app.containerId}
|
|
||||||
h={APP_CARD_HEIGHT}
|
|
||||||
p="xs"
|
|
||||||
withBorder
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
|
||||||
borderWidth: isSelected ? 2 : undefined,
|
|
||||||
opacity: isSelected ? 1 : 0.6,
|
|
||||||
}}
|
|
||||||
onClick={() => onToggleApp(app.containerId)}
|
|
||||||
>
|
|
||||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
|
||||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
|
||||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{app.containerName}
|
|
||||||
</Text>
|
|
||||||
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
|
||||||
</Group>
|
|
||||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
|
||||||
{app.containerName.at(0)?.toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Collapse>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Alert,
|
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Center,
|
|
||||||
Collapse,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Paper,
|
|
||||||
Progress,
|
|
||||||
SegmentedControl,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import {
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconArrowRight,
|
|
||||||
IconBrandDocker,
|
|
||||||
IconCheck,
|
|
||||||
IconChevronDown,
|
|
||||||
IconSettings,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions";
|
|
||||||
import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions";
|
|
||||||
import { showErrorNotification } from "@homarr/notifications";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
|
||||||
|
|
||||||
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
|
||||||
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
|
|
||||||
type Phase = "select" | "configure" | "done";
|
|
||||||
|
|
||||||
interface IntegrationDraft {
|
|
||||||
kind: IntegrationKind;
|
|
||||||
initialUrl: string;
|
|
||||||
initialName?: string;
|
|
||||||
dockerPort?: number | null;
|
|
||||||
source: "manual" | "docker";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InitIntegrations = () => {
|
|
||||||
const [phase, setPhase] = useState<Phase>("select");
|
|
||||||
const [drafts, setDrafts] = useState<Map<IntegrationKind, IntegrationDraft>>(new Map());
|
|
||||||
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
const [baseHost, setBaseHost] = useState("");
|
|
||||||
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("subdomain");
|
|
||||||
const t = useScopedI18n("init.step.integrations");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
|
|
||||||
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
|
|
||||||
|
|
||||||
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : [];
|
|
||||||
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
|
|
||||||
const totalDetected = dockerIntegrations.length + dockerApps.length;
|
|
||||||
|
|
||||||
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
|
|
||||||
|
|
||||||
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
|
|
||||||
const [appsExpanded, setAppsExpanded] = useState(false);
|
|
||||||
|
|
||||||
const dockerApplied = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (dockerData?.status !== "success" || dockerApplied.current) return;
|
|
||||||
dockerApplied.current = true;
|
|
||||||
|
|
||||||
const newDrafts = new Map(drafts);
|
|
||||||
const newKinds = new Set(selectedKinds);
|
|
||||||
|
|
||||||
for (const item of dockerData.integrations) {
|
|
||||||
if (!newDrafts.has(item.kind)) {
|
|
||||||
newDrafts.set(item.kind, {
|
|
||||||
kind: item.kind,
|
|
||||||
initialUrl: item.suggestedUrl,
|
|
||||||
initialName: item.containerName,
|
|
||||||
dockerPort: item.publishedPort,
|
|
||||||
source: "docker",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
newKinds.add(item.kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
setDrafts(newDrafts);
|
|
||||||
setSelectedKinds(Array.from(newKinds));
|
|
||||||
setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId)));
|
|
||||||
if (dockerData.apps.length > 0) {
|
|
||||||
setAppsExpanded(true);
|
|
||||||
}
|
|
||||||
}, [dockerData]);
|
|
||||||
|
|
||||||
const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => {
|
|
||||||
setSelectedKinds(kinds);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleApp = useCallback((containerId: string) => {
|
|
||||||
setSelectedAppIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(containerId)) {
|
|
||||||
next.delete(containerId);
|
|
||||||
} else {
|
|
||||||
next.add(containerId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleContinueToConfig = () => {
|
|
||||||
const newDrafts = new Map(drafts);
|
|
||||||
for (const kind of selectedKinds) {
|
|
||||||
const existing = newDrafts.get(kind);
|
|
||||||
if (baseHost) {
|
|
||||||
newDrafts.set(kind, {
|
|
||||||
kind,
|
|
||||||
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
|
|
||||||
initialName: existing?.initialName,
|
|
||||||
dockerPort: existing?.dockerPort,
|
|
||||||
source: existing?.source ?? "manual",
|
|
||||||
});
|
|
||||||
} else if (!existing) {
|
|
||||||
newDrafts.set(kind, { kind, initialUrl: "", source: "manual" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDrafts(newDrafts);
|
|
||||||
setCurrentIndex(0);
|
|
||||||
setPhase("configure");
|
|
||||||
};
|
|
||||||
|
|
||||||
const finishSetupAsync = async () => {
|
|
||||||
setPhase("done");
|
|
||||||
try {
|
|
||||||
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
|
|
||||||
if (appsToCreate.length > 0) {
|
|
||||||
await createApps(
|
|
||||||
appsToCreate.map((app) => {
|
|
||||||
const href = baseHost
|
|
||||||
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
|
|
||||||
: app.suggestedUrl;
|
|
||||||
return {
|
|
||||||
name: app.containerName,
|
|
||||||
href: href || null,
|
|
||||||
iconUrl: app.iconUrl,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await setupIntegrations();
|
|
||||||
router.refresh();
|
|
||||||
} catch {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("configure.error.title"),
|
|
||||||
message: t("configure.error.message"),
|
|
||||||
});
|
|
||||||
setPhase("select");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const advanceOrFinish = () => {
|
|
||||||
if (currentIndex + 1 >= selectedKinds.length) {
|
|
||||||
void finishSetupAsync();
|
|
||||||
} else {
|
|
||||||
setCurrentIndex((i) => i + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const backToSelect = () => {
|
|
||||||
setPhase("select");
|
|
||||||
setCurrentIndex(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (phase === "done") {
|
|
||||||
return (
|
|
||||||
<InitStepCard>
|
|
||||||
<Center py="xl">
|
|
||||||
<Stack align="center" gap="md">
|
|
||||||
<Loader size="lg" />
|
|
||||||
<Text>{t("configure.finishing")}</Text>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
</InitStepCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase === "configure") {
|
|
||||||
return (
|
|
||||||
<ConfigurePhase
|
|
||||||
selectedKinds={selectedKinds}
|
|
||||||
currentIndex={currentIndex}
|
|
||||||
drafts={drafts}
|
|
||||||
baseHost={baseHost}
|
|
||||||
setBaseHost={setBaseHost}
|
|
||||||
urlMode={urlMode}
|
|
||||||
setUrlMode={setUrlMode}
|
|
||||||
onSuccess={advanceOrFinish}
|
|
||||||
onCancel={backToSelect}
|
|
||||||
onSkip={advanceOrFinish}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InitStepCard>
|
|
||||||
<Stack>
|
|
||||||
<Text>{t("description")}</Text>
|
|
||||||
|
|
||||||
<Alert variant="outline" color="orange" icon={<IconAlertTriangle />} title={t("urlTemplate.label")}>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("urlTemplate.warning")}
|
|
||||||
</Text>
|
|
||||||
<Group gap="sm" grow>
|
|
||||||
<TextInput
|
|
||||||
placeholder={t("urlTemplate.placeholder")}
|
|
||||||
value={baseHost}
|
|
||||||
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
|
||||||
data-autofocus
|
|
||||||
/>
|
|
||||||
<SegmentedControl
|
|
||||||
value={urlMode}
|
|
||||||
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
|
||||||
data={[
|
|
||||||
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
|
||||||
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
|
||||||
]}
|
|
||||||
style={{ flex: "0 0 auto" }}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
{baseHost && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{isDockerLoading && (
|
|
||||||
<Paper withBorder p="sm" radius="md">
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
|
||||||
<IconBrandDocker size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("docker.scanning")}
|
|
||||||
</Text>
|
|
||||||
<Loader size="sm" color="blue" type="dots" />
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isDockerLoading && totalDetected > 0 && (
|
|
||||||
<Paper withBorder p="sm" radius="md" style={{ borderColor: "var(--mantine-color-teal-4)" }}>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon variant="light" color="teal" size="lg" radius="md">
|
|
||||||
<IconBrandDocker size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm">
|
|
||||||
{t("docker.summary", {
|
|
||||||
apps: String(totalDetected),
|
|
||||||
integrations: String(dockerIntegrations.length),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IntegrationMultiSelectGrid
|
|
||||||
selectedKinds={selectedKinds}
|
|
||||||
onSelectionChange={handleSelectionChange}
|
|
||||||
detectedKinds={detectedKinds}
|
|
||||||
onboarding
|
|
||||||
/>
|
|
||||||
|
|
||||||
{dockerApps.length > 0 && (
|
|
||||||
<Paper withBorder p="sm" radius="md">
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group
|
|
||||||
justify="space-between"
|
|
||||||
wrap="nowrap"
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => setAppsExpanded((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("docker.appSectionLabel", { count: String(selectedAppCount) })}
|
|
||||||
</Text>
|
|
||||||
<IconChevronDown
|
|
||||||
size={16}
|
|
||||||
color="var(--mantine-color-dimmed)"
|
|
||||||
style={{
|
|
||||||
transform: appsExpanded ? "rotate(180deg)" : undefined,
|
|
||||||
transition: "transform 200ms ease",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Collapse expanded={appsExpanded}>
|
|
||||||
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
|
||||||
{dockerApps.map((app) => {
|
|
||||||
const isSelected = selectedAppIds.has(app.containerId);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={app.containerId}
|
|
||||||
h={80}
|
|
||||||
p="xs"
|
|
||||||
withBorder
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
|
||||||
borderWidth: isSelected ? 2 : undefined,
|
|
||||||
opacity: isSelected ? 1 : 0.6,
|
|
||||||
}}
|
|
||||||
onClick={() => handleToggleApp(app.containerId)}
|
|
||||||
>
|
|
||||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
|
||||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
|
||||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{app.containerName}
|
|
||||||
</Text>
|
|
||||||
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
|
||||||
</Group>
|
|
||||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
|
||||||
{app.containerName.at(0)?.toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Collapse>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
|
|
||||||
{t("action.skip")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleContinueToConfig}
|
|
||||||
disabled={selectedKinds.length === 0 || undefined}
|
|
||||||
rightSection={<IconArrowRight size={16} stroke={1.5} />}
|
|
||||||
suppressHydrationWarning
|
|
||||||
>
|
|
||||||
{t("action.continue")} {selectedKinds.length > 0 && `(${selectedKinds.length})`}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</InitStepCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ConfigurePhaseProps {
|
|
||||||
selectedKinds: IntegrationKind[];
|
|
||||||
currentIndex: number;
|
|
||||||
drafts: Map<IntegrationKind, IntegrationDraft>;
|
|
||||||
baseHost: string;
|
|
||||||
setBaseHost: (value: string) => void;
|
|
||||||
urlMode: UrlTemplateMode;
|
|
||||||
setUrlMode: (value: UrlTemplateMode) => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSkip: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConfigurePhase = ({
|
|
||||||
selectedKinds,
|
|
||||||
currentIndex,
|
|
||||||
drafts,
|
|
||||||
baseHost,
|
|
||||||
setBaseHost,
|
|
||||||
urlMode,
|
|
||||||
setUrlMode,
|
|
||||||
onSuccess,
|
|
||||||
onCancel,
|
|
||||||
onSkip,
|
|
||||||
}: ConfigurePhaseProps) => {
|
|
||||||
const t = useScopedI18n("init.step.integrations");
|
|
||||||
const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
|
|
||||||
const currentKind = selectedKinds[currentIndex]!;
|
|
||||||
const draft = drafts.get(currentKind);
|
|
||||||
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
|
|
||||||
const computedUrl = baseHost
|
|
||||||
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
|
|
||||||
: draft?.initialUrl;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InitStepCard>
|
|
||||||
<Stack>
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<Group gap="xs">
|
|
||||||
<IntegrationAvatar kind={currentKind} size="sm" />
|
|
||||||
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
|
|
||||||
</Group>
|
|
||||||
<Tooltip label={t("configure.helpers.tooltip")}>
|
|
||||||
<ActionIcon variant={helpersOpen ? "light" : "subtle"} color="gray" onClick={toggleHelpers}>
|
|
||||||
<IconSettings size={18} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Progress value={progress} size="sm" />
|
|
||||||
<Text size="xs" c="dimmed" ta="center">
|
|
||||||
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Collapse expanded={helpersOpen}>
|
|
||||||
<Stack
|
|
||||||
gap="xs"
|
|
||||||
p="xs"
|
|
||||||
style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}
|
|
||||||
>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("configure.helpers.description")}
|
|
||||||
</Text>
|
|
||||||
<Group gap="sm" grow>
|
|
||||||
<TextInput
|
|
||||||
size="xs"
|
|
||||||
placeholder={t("urlTemplate.placeholder")}
|
|
||||||
value={baseHost}
|
|
||||||
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<SegmentedControl
|
|
||||||
size="xs"
|
|
||||||
value={urlMode}
|
|
||||||
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
|
||||||
data={[
|
|
||||||
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
|
||||||
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
|
||||||
]}
|
|
||||||
style={{ flex: "0 0 auto" }}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
{baseHost && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("urlTemplate.preview", {
|
|
||||||
example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
<NewIntegrationForm
|
|
||||||
key={currentKind}
|
|
||||||
kind={currentKind}
|
|
||||||
initialUrl={computedUrl}
|
|
||||||
initialName={draft?.initialName}
|
|
||||||
onSuccess={onSuccess}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onSkip={onSkip}
|
|
||||||
isOnboarding
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</InitStepCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Group, Stack, Switch, Text } from "@mantine/core";
|
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -13,8 +13,6 @@ import type { TranslationObject } from "@homarr/translation";
|
|||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { settingsInitSchema } from "@homarr/validation/settings";
|
import { settingsInitSchema } from "@homarr/validation/settings";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
|
|
||||||
export const InitSettings = () => {
|
export const InitSettings = () => {
|
||||||
const tSection = useScopedI18n("management.page.settings.section");
|
const tSection = useScopedI18n("management.page.settings.section");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -31,8 +29,8 @@ export const InitSettings = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||||
<Stack w="100%">
|
<Stack>
|
||||||
<InitStepCard>
|
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fw={500}>{tSection("analytics.title")}</Text>
|
<Text fw={500}>{tSection("analytics.title")}</Text>
|
||||||
|
|
||||||
@@ -40,8 +38,8 @@ export const InitSettings = () => {
|
|||||||
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
|
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
<InitStepCard>
|
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
||||||
|
|
||||||
@@ -64,7 +62,7 @@ export const InitSettings = () => {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
|
|
||||||
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
||||||
{t("common.action.continue")}
|
{t("common.action.continue")}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import { Stack, Text } from "@mantine/core";
|
import { Card, Stack, Text } from "@mantine/core";
|
||||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { getMantineColor } from "@homarr/common";
|
import { getMantineColor } from "@homarr/common";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
|
||||||
import { InitSqliteRestore } from "./sqlite-restore";
|
|
||||||
import { InitStartButton } from "./next-button";
|
import { InitStartButton } from "./next-button";
|
||||||
|
|
||||||
export const InitStart = async () => {
|
export const InitStart = async () => {
|
||||||
const tStart = await getScopedI18n("init.step.start");
|
const tStart = await getScopedI18n("init.step.start");
|
||||||
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<Card w={64 * 6} maw="90vw" withBorder>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>{tStart("description")}</Text>
|
<Text>{tStart("description")}</Text>
|
||||||
|
|
||||||
@@ -30,8 +26,7 @@ export const InitStart = async () => {
|
|||||||
>
|
>
|
||||||
{tStart("action.importOldmarr")}
|
{tStart("action.importOldmarr")}
|
||||||
</InitStartButton>
|
</InitStartButton>
|
||||||
{isSqlite && <InitSqliteRestore />}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button, Collapse, Stack, Text } from "@mantine/core";
|
|
||||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { getMantineColor } from "@homarr/common";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
|
||||||
|
|
||||||
export const InitSqliteRestore = () => {
|
|
||||||
const t = useScopedI18n("init.step.start.action");
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => setExpanded(false)}
|
|
||||||
>
|
|
||||||
{t("backToOptions")}
|
|
||||||
</Button>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("restoreSqliteDescription")}
|
|
||||||
</Text>
|
|
||||||
<DatabaseRestoreFlow variant="standalone" />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={() => setExpanded(true)}
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
|
|
||||||
>
|
|
||||||
{t("restoreSqlite")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Stack, TextInput } from "@mantine/core";
|
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { CustomPasswordInput } from "@homarr/ui";
|
||||||
import { userInitSchema } from "@homarr/validation/user";
|
import { userInitSchema } from "@homarr/validation/user";
|
||||||
|
|
||||||
export const InitUserForm = () => {
|
export const InitUserForm = () => {
|
||||||
@@ -59,10 +59,12 @@ export const InitUserForm = () => {
|
|||||||
>
|
>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||||
<UserCreatePasswordFields
|
<CustomPasswordInput
|
||||||
passwordInputProps={form.getInputProps("password")}
|
withPasswordRequirements
|
||||||
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
|
label={t("field.password.label")}
|
||||||
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
|
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
||||||
<Button type="submit" fullWidth loading={isPending}>
|
<Button type="submit" fullWidth loading={isPending}>
|
||||||
{t("action.create")}
|
{t("action.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { InitStepCard } from "../../_components/init-step-card";
|
import { Card } from "@mantine/core";
|
||||||
|
|
||||||
import { InitUserForm } from "./init-user-form";
|
import { InitUserForm } from "./init-user-form";
|
||||||
|
|
||||||
export const InitUser = () => {
|
export const InitUser = () => {
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<Card w={64 * 6} maw="90vw" withBorder>
|
||||||
<InitUserForm />
|
<InitUserForm />
|
||||||
</InitStepCard>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -9,12 +9,10 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
|
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
|
||||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||||
import { onboardingContentWidth, onboardingMaxWidth } from "./_constants";
|
|
||||||
import { BackToStart } from "./_steps/back";
|
import { BackToStart } from "./_steps/back";
|
||||||
import { InitFinish } from "./_steps/finish/init-finish";
|
import { InitFinish } from "./_steps/finish/init-finish";
|
||||||
import { InitGroup } from "./_steps/group/init-group";
|
import { InitGroup } from "./_steps/group/init-group";
|
||||||
import { InitImport } from "./_steps/import/init-import";
|
import { InitImport } from "./_steps/import/init-import";
|
||||||
import { InitIntegrations } from "./_steps/integrations/init-integrations";
|
|
||||||
import { InitSettings } from "./_steps/settings/init-settings";
|
import { InitSettings } from "./_steps/settings/init-settings";
|
||||||
import { InitStart } from "./_steps/start/init-start";
|
import { InitStart } from "./_steps/start/init-start";
|
||||||
import { InitUser } from "./_steps/user/init-user";
|
import { InitUser } from "./_steps/user/init-user";
|
||||||
@@ -25,7 +23,6 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
|
|||||||
user: InitUser,
|
user: InitUser,
|
||||||
group: InitGroup,
|
group: InitGroup,
|
||||||
settings: InitSettings,
|
settings: InitSettings,
|
||||||
integrations: InitIntegrations,
|
|
||||||
finish: InitFinish,
|
finish: InitFinish,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +35,7 @@ export default async function InitPage() {
|
|||||||
return (
|
return (
|
||||||
<Box mih="100dvh">
|
<Box mih="100dvh">
|
||||||
<Center>
|
<Center>
|
||||||
<Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}>
|
<Stack align="center" mt="xl">
|
||||||
<HomarrLogoWithTitle size="lg" />
|
<HomarrLogoWithTitle size="lg" />
|
||||||
<Stack gap={6} align="center">
|
<Stack gap={6} align="center">
|
||||||
<Title order={3} fw={400} ta="center">
|
<Title order={3} fw={400} ta="center">
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
import "@gfazioli/mantine-onboarding-tour/styles.css";
|
|
||||||
import "@homarr/notifications/styles.css";
|
import "@homarr/notifications/styles.css";
|
||||||
import "@homarr/spotlight/styles.css";
|
import "@homarr/spotlight/styles.css";
|
||||||
import "@homarr/ui/styles.css";
|
import "@homarr/ui/styles.css";
|
||||||
import "mantine-datatable/styles.css";
|
|
||||||
import "~/styles/color-scheme.scss";
|
|
||||||
import "~/styles/scroll-area.scss";
|
import "~/styles/scroll-area.scss";
|
||||||
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -27,9 +24,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
|||||||
|
|
||||||
import { Analytics } from "~/components/layout/analytics";
|
import { Analytics } from "~/components/layout/analytics";
|
||||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||||
|
|
||||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
|
||||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import { DayJsLoader } from "./_client-providers/dayjs-loader";
|
import { DayJsLoader } from "./_client-providers/dayjs-loader";
|
||||||
import { JotaiProvider } from "./_client-providers/jotai";
|
import { JotaiProvider } from "./_client-providers/jotai";
|
||||||
@@ -47,13 +42,13 @@ const fontSans = Inter({
|
|||||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||||
title: "Homarr",
|
title: "Homarr",
|
||||||
description:
|
description:
|
||||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Homarr Dashboard",
|
title: "Homarr Dashboard",
|
||||||
description:
|
description:
|
||||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||||
url: "https://homarr.dev",
|
url: "https://homarr.dev",
|
||||||
siteName: "Homarr",
|
siteName: "Homarr Documentation",
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/logo/logo.png",
|
icon: "/logo/logo.png",
|
||||||
@@ -134,7 +129,7 @@ export default async function Layout(props: {
|
|||||||
dir={direction}
|
dir={direction}
|
||||||
data-mantine-color-scheme={colorScheme}
|
data-mantine-color-scheme={colorScheme}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
|
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
|
||||||
}}
|
}}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
@@ -145,8 +140,7 @@ export default async function Layout(props: {
|
|||||||
</head>
|
</head>
|
||||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||||
<StackedProvider>
|
<StackedProvider>
|
||||||
<Notifications pauseResetOnHover="notification" />
|
<Notifications />
|
||||||
<ServiceWorkerRegistration />
|
|
||||||
{props.children}
|
{props.children}
|
||||||
</StackedProvider>
|
</StackedProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const HeroBanner = async () => {
|
|||||||
<Image
|
<Image
|
||||||
key={`grid-column-${columnIndex}-scroll-1-${index}`}
|
key={`grid-column-${columnIndex}-scroll-1-${index}`}
|
||||||
src={icon}
|
src={icon}
|
||||||
|
radius="md"
|
||||||
fit={"contain"}
|
fit={"contain"}
|
||||||
w={50}
|
w={50}
|
||||||
h={50}
|
h={50}
|
||||||
@@ -56,6 +57,7 @@ export const HeroBanner = async () => {
|
|||||||
<Image
|
<Image
|
||||||
key={`grid-column-${columnIndex}-scroll-2-${index}`}
|
key={`grid-column-${columnIndex}-scroll-2-${index}`}
|
||||||
src={icon}
|
src={icon}
|
||||||
|
radius="md"
|
||||||
fit={"contain"}
|
fit={"contain"}
|
||||||
w={50}
|
w={50}
|
||||||
h={50}
|
h={50}
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ export default async function AboutPage() {
|
|||||||
<Group py="lg">
|
<Group py="lg">
|
||||||
<Image src={homarrLogoPath} width={100} height={100} alt="" />
|
<Image src={homarrLogoPath} width={100} height={100} alt="" />
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Title order={1}>Homarr</Title>
|
<Title order={1} tt="uppercase">
|
||||||
|
Homarr
|
||||||
|
</Title>
|
||||||
<Title order={2}>{t("version", { version })}</Title>
|
<Title order={2}>{t("version", { version })}</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -193,9 +195,9 @@ export default async function AboutPage() {
|
|||||||
href={`https://www.npmjs.com/package/${name}`}
|
href={`https://www.npmjs.com/package/${name}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Card radius="md" p="xs" className={classes.dependencyCard}>
|
<Card withBorder radius="md" p="xs" className={classes.dependencyCard}>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<ThemeIcon variant="light" size="lg">
|
<ThemeIcon variant="light" size="lg" radius="md">
|
||||||
<IconPackage size="1.5rem" stroke={1.5} />
|
<IconPackage size="1.5rem" stroke={1.5} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -11,9 +11,9 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
|||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||||
|
|
||||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { AppDeleteButton } from "./_app-delete-button";
|
import { AppDeleteButton } from "./_app-delete-button";
|
||||||
|
|
||||||
@@ -39,35 +39,34 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||||
const t = await getScopedI18n("app");
|
const t = await getScopedI18n("app");
|
||||||
|
|
||||||
const canCreate = session.user.permissions.includes("app-create");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManageContainer>
|
||||||
title={t("page.list.title")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
<Stack>
|
||||||
canCreate ? (
|
<Title>{t("page.list.title")}</Title>
|
||||||
<TourTarget id="manage-apps-create">
|
<Group justify="space-between" align="center">
|
||||||
|
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
|
||||||
|
{session.user.permissions.includes("app-create") && (
|
||||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||||
{t("page.create.title")}
|
{t("page.create.title")}
|
||||||
</MobileAffixButton>
|
</MobileAffixButton>
|
||||||
</TourTarget>
|
)}
|
||||||
) : undefined
|
</Group>
|
||||||
}
|
{apps.length === 0 && <AppNoResults />}
|
||||||
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
{apps.length > 0 && (
|
||||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
|
||||||
floatingPrimaryAction={canCreate}
|
|
||||||
>
|
|
||||||
{apps.length === 0 && <AppNoResults />}
|
|
||||||
{apps.length > 0 && (
|
|
||||||
<TourTarget id="manage-apps-list">
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{apps.map((app) => (
|
{apps.map((app) => (
|
||||||
<AppCard key={app.id} app={app} />
|
<AppCard key={app.id} app={app} />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</TourTarget>
|
)}
|
||||||
)}
|
|
||||||
</ManagePageLayout>
|
{/* Added margin to not hide pagination behind affix-button */}
|
||||||
|
<Group justify="end" mb={48}>
|
||||||
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card withBorder>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Menu } from "@mantine/core";
|
import { Affix, Button, Menu } from "@mantine/core";
|
||||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
|
||||||
|
|
||||||
export const CreateBoardButton = () => {
|
export const CreateBoardButton = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||||
|
|
||||||
|
const buttonGroupContent = (
|
||||||
|
<>
|
||||||
|
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||||
|
{t("management.page.board.action.new.label")}
|
||||||
|
</Button>
|
||||||
|
<Menu position="bottom-end">
|
||||||
|
<Menu.Target>
|
||||||
|
<Button px="xs" ms={1}>
|
||||||
|
<IconChevronDown size="1rem" />
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||||
|
{t("board.action.oldImport.label")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageMobilePrimaryAction>
|
<>
|
||||||
<Button.Group>
|
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
|
||||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||||
{t("management.page.board.action.new.label")}
|
<Button.Group>{buttonGroupContent}</Button.Group>
|
||||||
</Button>
|
</Affix>
|
||||||
<Menu position="bottom-end">
|
</>
|
||||||
<Menu.Target>
|
|
||||||
<Button px="xs" ms={1}>
|
|
||||||
<IconChevronDown size="1rem" />
|
|
||||||
</Button>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
|
||||||
{t("board.action.oldImport.label")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Button.Group>
|
|
||||||
</ManageMobilePrimaryAction>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -9,17 +9,12 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
MenuTarget,
|
MenuTarget,
|
||||||
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
||||||
IconDeviceMobile,
|
|
||||||
IconDotsVertical,
|
|
||||||
IconHomeFilled,
|
|
||||||
IconLayoutDashboard,
|
|
||||||
IconLock,
|
|
||||||
IconWorld,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -28,9 +23,8 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
import { Link, UserAvatar } from "@homarr/ui";
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NoResults } from "~/components/no-results";
|
|
||||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||||
import { CreateBoardButton } from "./_components/create-board-button";
|
import { CreateBoardButton } from "./_components/create-board-button";
|
||||||
|
|
||||||
@@ -41,30 +35,23 @@ export default async function ManageBoardsPage() {
|
|||||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManageContainer>
|
||||||
title={t("title")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
<Stack>
|
||||||
canCreateBoards ? (
|
<Group justify="space-between">
|
||||||
<TourTarget id="manage-boards-create">
|
<Title mb="md">{t("title")}</Title>
|
||||||
<CreateBoardButton />
|
{canCreateBoards && <CreateBoardButton />}
|
||||||
</TourTarget>
|
</Group>
|
||||||
) : undefined
|
|
||||||
}
|
<Grid mb={{ base: "xl", md: 0 }}>
|
||||||
floatingPrimaryAction={canCreateBoards}
|
{boards.map((board) => (
|
||||||
>
|
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||||
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
<BoardCard board={board} />
|
||||||
{boards.length > 0 && (
|
</GridCol>
|
||||||
<TourTarget id="manage-boards-list">
|
))}
|
||||||
<Grid>
|
</Grid>
|
||||||
{boards.map((board) => (
|
</Stack>
|
||||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
</ManageContainer>
|
||||||
<BoardCard board={board} />
|
|
||||||
</GridCol>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</TourTarget>
|
|
||||||
)}
|
|
||||||
</ManagePageLayout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,14 +66,16 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card radius="lg" withBorder>
|
||||||
<CardSection p="sm" withBorder>
|
<CardSection p="sm" withBorder>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Tooltip label={t(`visibility.${visibility}`)}>
|
<Tooltip label={t(`visibility.${visibility}`)}>
|
||||||
<VisibilityIcon size={20} stroke={1.5} />
|
<VisibilityIcon size={20} stroke={1.5} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Text fw={700}>{board.name}</Text>
|
<Text fw="bolder" tt="uppercase">
|
||||||
|
{board.name}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
@@ -134,7 +123,6 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("action.settings.label")}
|
|
||||||
>
|
>
|
||||||
<IconDotsVertical size={16} stroke={1.5} />
|
<IconDotsVertical size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
+3
-8
@@ -33,7 +33,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
const KindIcon = integrationSecretIcons[secret.kind];
|
const KindIcon = integrationSecretIcons[secret.kind];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card withBorder>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Group>
|
<Group>
|
||||||
@@ -58,12 +58,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{isPublic ? (
|
{isPublic ? (
|
||||||
<ActionIcon
|
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
|
||||||
color="gray"
|
|
||||||
variant="subtle"
|
|
||||||
onClick={togglePublicSecretDisplay}
|
|
||||||
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")}
|
|
||||||
>
|
|
||||||
<DisplayIcon size={16} stroke={1.5} />
|
<DisplayIcon size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -84,7 +79,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Collapse expanded={editMode}>{children}</Collapse>
|
<Collapse in={editMode}>{children}</Collapse>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
-2
@@ -8,7 +8,6 @@ import {
|
|||||||
IconPasswordUser,
|
IconPasswordUser,
|
||||||
IconPlug,
|
IconPlug,
|
||||||
IconServer,
|
IconServer,
|
||||||
IconTag,
|
|
||||||
IconUser,
|
IconUser,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -29,5 +28,4 @@ export const integrationSecretIcons = {
|
|||||||
githubAppId: IconCode,
|
githubAppId: IconCode,
|
||||||
githubInstallationId: IconPlug,
|
githubInstallationId: IconPlug,
|
||||||
privateKey: IconKey,
|
privateKey: IconKey,
|
||||||
slug: IconTag,
|
|
||||||
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
||||||
+1
-1
@@ -12,7 +12,7 @@ interface FormType {
|
|||||||
interface SecretKindsSegmentedControlProps<TFormType extends FormType> {
|
interface SecretKindsSegmentedControlProps<TFormType extends FormType> {
|
||||||
defaultKinds?: IntegrationSecretKind[];
|
defaultKinds?: IntegrationSecretKind[];
|
||||||
secretKinds: IntegrationSecretKind[][];
|
secretKinds: IntegrationSecretKind[][];
|
||||||
form: UseFormReturnType<TFormType, TFormType>;
|
form: UseFormReturnType<TFormType, (values: TFormType) => TFormType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SecretKindsSegmentedControl = <TFormType extends FormType>({
|
export const SecretKindsSegmentedControl = <TFormType extends FormType>({
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
|||||||
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
|
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card style={{ borderColor: getMantineColor("red", 8) }}>
|
<Card withBorder style={{ borderColor: getMantineColor("red", 8) }}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text size="lg" fw={500} c="red.8">
|
<Text size="lg" fw={500} c="red.8">
|
||||||
|
|||||||
+1
-1
@@ -216,7 +216,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
|
|||||||
const tCertificateField = useScopedI18n("certificate.field");
|
const tCertificateField = useScopedI18n("certificate.field");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card withBorder>
|
||||||
<Text fw={500}>{tDetails("title")}</Text>
|
<Text fw={500}>{tDetails("title")}</Text>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ChangeEvent } from "react";
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
|
||||||
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { IntegrationAvatar, Link } from "@homarr/ui";
|
||||||
|
|
||||||
|
interface IntegrationCreateDropdownContentProps {
|
||||||
|
enableMockIntegration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const filteredKinds = useMemo(() => {
|
||||||
|
return integrationKinds
|
||||||
|
.filter((kind) => enableMockIntegration || kind !== "mock")
|
||||||
|
.filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()))
|
||||||
|
.sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB)));
|
||||||
|
}, [search, enableMockIntegration]);
|
||||||
|
|
||||||
|
const handleSearch = React.useCallback(
|
||||||
|
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),
|
||||||
|
[setSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction={{ base: "column-reverse", md: "column" }} gap="sm">
|
||||||
|
<TextInput
|
||||||
|
leftSection={<IconSearch stroke={1.5} size={20} />}
|
||||||
|
placeholder={t("integration.page.list.search")}
|
||||||
|
value={search}
|
||||||
|
data-autofocus
|
||||||
|
onChange={handleSearch}
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filteredKinds.length > 0 ? (
|
||||||
|
<ScrollArea.Autosize mah={384}>
|
||||||
|
{filteredKinds.map((kind) => (
|
||||||
|
<Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}>
|
||||||
|
<Group>
|
||||||
|
<IntegrationAvatar kind={kind} size="sm" />
|
||||||
|
<Text size="sm">{getIntegrationName(kind)}</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
) : (
|
||||||
|
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
|
||||||
|
|
||||||
import { NewIntegrationForm } from "./_integration-new-form";
|
|
||||||
|
|
||||||
interface IntegrationNewFormWrapperProps {
|
|
||||||
kind: IntegrationKind;
|
|
||||||
initialUrl?: string;
|
|
||||||
initialName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IntegrationNewFormWrapper = ({ kind, initialUrl, initialName }: IntegrationNewFormWrapperProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NewIntegrationForm
|
|
||||||
kind={kind}
|
|
||||||
initialUrl={initialUrl}
|
|
||||||
initialName={initialName}
|
|
||||||
onSuccess={() => router.push("/manage/integrations")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Anchor,
|
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Collapse,
|
Collapse,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
|
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -27,7 +27,6 @@ import type { IntegrationKind } from "@homarr/definitions";
|
|||||||
import {
|
import {
|
||||||
getAllSecretKindOptions,
|
getAllSecretKindOptions,
|
||||||
getIconUrl,
|
getIconUrl,
|
||||||
getIntegrationApiKeyUrl,
|
|
||||||
getIntegrationDefaultUrl,
|
getIntegrationDefaultUrl,
|
||||||
getIntegrationName,
|
getIntegrationName,
|
||||||
integrationDefs,
|
integrationDefs,
|
||||||
@@ -46,13 +45,9 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i
|
|||||||
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
|
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
|
||||||
|
|
||||||
interface NewIntegrationFormProps {
|
interface NewIntegrationFormProps {
|
||||||
kind: IntegrationKind;
|
searchParams: Partial<z.infer<typeof integrationCreateSchema>> & {
|
||||||
initialUrl?: string;
|
kind: IntegrationKind;
|
||||||
initialName?: string;
|
};
|
||||||
onSuccess: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
onSkip?: () => void;
|
|
||||||
isOnboarding?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||||
@@ -63,48 +58,39 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const NewIntegrationForm = ({
|
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
||||||
kind,
|
|
||||||
initialUrl,
|
|
||||||
initialName,
|
|
||||||
onSuccess,
|
|
||||||
onCancel,
|
|
||||||
onSkip,
|
|
||||||
isOnboarding = false,
|
|
||||||
}: NewIntegrationFormProps) => {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const secretKinds = getAllSecretKindOptions(kind);
|
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
||||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||||
|
const router = useRouter();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false);
|
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||||
|
|
||||||
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
|
||||||
if (hasUrlSecret) {
|
if (hasUrlSecret) {
|
||||||
|
// Placeholder Url, replaced with origin of the secret Url on submit
|
||||||
url = "http://localhost";
|
url = "http://localhost";
|
||||||
}
|
}
|
||||||
const form = useZodForm(formSchema, {
|
const form = useZodForm(formSchema, {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: initialName ?? getIntegrationName(kind),
|
name: searchParams.name ?? getIntegrationName(searchParams.kind),
|
||||||
url,
|
url,
|
||||||
secrets: secretKinds[0].map((kind) => ({
|
secrets: secretKinds[0].map((kind) => ({
|
||||||
kind,
|
kind,
|
||||||
value: "",
|
value: "",
|
||||||
})),
|
})),
|
||||||
attemptSearchEngineCreation: !isOnboarding,
|
attemptSearchEngineCreation: true,
|
||||||
hasApp: !isOnboarding,
|
hasApp: false,
|
||||||
appHref: isOnboarding ? null : url,
|
appHref: url,
|
||||||
appId: null,
|
appId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({
|
const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await revalidatePathActionAsync("/manage/integrations");
|
await revalidatePathActionAsync("/manage/integrations");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } =
|
|
||||||
clientApi.onboard.createIntegration.useMutation();
|
|
||||||
const isPending = isCreatePending || isOnboardingCreatePending;
|
|
||||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||||
|
|
||||||
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
|
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
|
||||||
@@ -112,39 +98,6 @@ export const NewIntegrationForm = ({
|
|||||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||||
: values.url;
|
: values.url;
|
||||||
|
|
||||||
const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => {
|
|
||||||
if (data && "error" in data && data.error) {
|
|
||||||
setError(data.error);
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("integration.page.create.notification.error.title"),
|
|
||||||
message: t("integration.page.create.notification.error.message"),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("integration.page.create.notification.success.title"),
|
|
||||||
message: t("integration.page.create.notification.success.message"),
|
|
||||||
});
|
|
||||||
|
|
||||||
onSuccess();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMutationError = () => {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("integration.page.create.notification.error.title"),
|
|
||||||
message: t("integration.page.create.notification.error.message"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOnboarding) {
|
|
||||||
await createOnboardingIntegrationAsync(
|
|
||||||
{ kind, name: values.name, url, secrets: values.secrets },
|
|
||||||
{ onSuccess: onMutationSuccess, onError: onMutationError },
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
|
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
|
||||||
|
|
||||||
const app = hasApp
|
const app = hasApp
|
||||||
@@ -153,21 +106,49 @@ export const NewIntegrationForm = ({
|
|||||||
: {
|
: {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
href: hasCustomHref ? appHref : url,
|
href: hasCustomHref ? appHref : url,
|
||||||
iconUrl: getIconUrl(kind),
|
iconUrl: getIconUrl(searchParams.kind),
|
||||||
description: null,
|
description: null,
|
||||||
pingUrl: url,
|
pingUrl: url,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await createIntegrationAsync(
|
await createIntegrationAsync(
|
||||||
{ kind, ...values, url, app },
|
{
|
||||||
{ onSuccess: onMutationSuccess, onError: onMutationError },
|
kind: searchParams.kind,
|
||||||
|
...values,
|
||||||
|
url,
|
||||||
|
app,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
// We do it this way as we are unable to send a typesafe error through onError
|
||||||
|
if (data?.error) {
|
||||||
|
setError(data.error);
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("integration.page.create.notification.error.title"),
|
||||||
|
message: t("integration.page.create.notification.error.message"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("integration.page.create.notification.success.title"),
|
||||||
|
message: t("integration.page.create.notification.success.message"),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/manage/integrations");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("integration.page.create.notification.error.title"),
|
||||||
|
message: t("integration.page.create.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const integrationCategories = integrationDefs[kind].category.flat();
|
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
|
||||||
const supportsSearchEngine =
|
|
||||||
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||||
@@ -194,39 +175,27 @@ export const NewIntegrationForm = ({
|
|||||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<ApiKeySettingsLink kind={kind} url={form.values.url} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||||
|
|
||||||
{!isOnboarding && supportsSearchEngine && (
|
{supportsSearchEngine && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||||
kind: getIntegrationName(kind),
|
kind: getIntegrationName(searchParams.kind),
|
||||||
})}
|
})}
|
||||||
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
|
<AppForm form={form} canCreateApps={canCreateApps} />
|
||||||
|
|
||||||
<Group justify="end" align="center">
|
<Group justify="end" align="center">
|
||||||
{onCancel ? (
|
<Button variant="default" component={Link} href="/manage/integrations">
|
||||||
<Button variant="default" onClick={onCancel}>
|
{t("common.action.backToOverview")}
|
||||||
{t("common.action.backToOverview")}
|
</Button>
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="default" component={Link} href="/manage/integrations">
|
|
||||||
{t("common.action.backToOverview")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onSkip && (
|
|
||||||
<Button variant="default" onClick={onSkip}>
|
|
||||||
{t("common.action.skip")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="submit" loading={isPending}>
|
<Button type="submit" loading={isPending}>
|
||||||
{t("integration.testConnection.action.create")}
|
{t("integration.testConnection.action.create")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -257,7 +226,7 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
|||||||
description={t("integration.field.createApp.description")}
|
description={t("integration.field.createApp.description")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Collapse expanded={form.values.hasApp}>
|
<Collapse in={form.values.hasApp}>
|
||||||
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{canCreateApps && (
|
{canCreateApps && (
|
||||||
@@ -297,40 +266,6 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeUrl = (raw: string): string | null => {
|
|
||||||
try {
|
|
||||||
return new URL(raw).href;
|
|
||||||
} catch {
|
|
||||||
// Bare IP / hostname — prepend http:// and retry
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return new URL(`http://${raw}`).href;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const apiKeyUrl = getIntegrationApiKeyUrl(url, kind);
|
|
||||||
if (!apiKeyUrl) return null;
|
|
||||||
|
|
||||||
const resolved = normalizeUrl(url);
|
|
||||||
if (!resolved) return null;
|
|
||||||
|
|
||||||
const fullApiKeyUrl = getIntegrationApiKeyUrl(resolved, kind);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Anchor href={fullApiKeyUrl ?? apiKeyUrl} target="_blank" rel="noopener noreferrer" size="sm">
|
|
||||||
<Group gap={4}>
|
|
||||||
<IconKey size={14} stroke={1.5} />
|
|
||||||
<Text size="sm">{t("integration.field.apiKeySettings.label")}</Text>
|
|
||||||
<IconExternalLink size={14} stroke={1.5} />
|
|
||||||
</Group>
|
|
||||||
</Anchor>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type IntegrationAppSelectProps = Modify<
|
type IntegrationAppSelectProps = Modify<
|
||||||
GetInputPropsReturnType,
|
GetInputPropsReturnType,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
|
||||||
|
|
||||||
import { IntegrationSelectGrid } from "~/components/integration/integration-select-grid";
|
|
||||||
|
|
||||||
interface IntegrationNewSelectGridProps {
|
|
||||||
enableMockIntegration: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IntegrationNewSelectGrid = ({ enableMockIntegration }: IntegrationNewSelectGridProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleSelect = (kind: IntegrationKind) => {
|
|
||||||
router.push(`/manage/integrations/new?kind=${kind}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={enableMockIntegration} />;
|
|
||||||
};
|
|
||||||
@@ -3,21 +3,21 @@ import { Container, Group, Stack, Title } from "@mantine/core";
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
import type { integrationCreateSchema } from "@homarr/validation/integration";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { env } from "~/env";
|
import { NewIntegrationForm } from "./_integration-new-form";
|
||||||
import { IntegrationNewFormWrapper } from "./_integration-new-form-wrapper";
|
|
||||||
import { IntegrationNewSelectGrid } from "./_integration-new-select-grid";
|
|
||||||
|
|
||||||
interface NewIntegrationPageProps {
|
interface NewIntegrationPageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<
|
||||||
kind?: string;
|
Partial<z.infer<typeof integrationCreateSchema>> & {
|
||||||
url?: string;
|
kind: IntegrationKind;
|
||||||
name?: string;
|
}
|
||||||
}>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
||||||
@@ -28,20 +28,8 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
|
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const t = await getScopedI18n("integration");
|
notFound();
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DynamicBreadcrumb />
|
|
||||||
<Container>
|
|
||||||
<Stack>
|
|
||||||
<Title>{t("action.create")}</Title>
|
|
||||||
<IntegrationNewSelectGrid enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tCreate = await getScopedI18n("integration.page.create");
|
const tCreate = await getScopedI18n("integration.page.create");
|
||||||
@@ -57,7 +45,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
|
|||||||
<IntegrationAvatar kind={currentKind} size="md" />
|
<IntegrationAvatar kind={currentKind} size="md" />
|
||||||
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<IntegrationNewFormWrapper kind={currentKind} initialUrl={searchParams.url} initialName={searchParams.name} />
|
<NewIntegrationForm searchParams={searchParams} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
transition: background-color 150ms ease;
|
transition: transform 150ms ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&[data-first="true"] {
|
&[data-first="true"] {
|
||||||
@@ -20,9 +20,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&[data-active] {
|
&[data-active] {
|
||||||
|
transform: scale(1.01);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: var(--mantine-color-body);
|
background-color: var(--mantine-color-body);
|
||||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
|
box-shadow: var(--mantine-shadow-md);
|
||||||
border-radius: var(--mantine-radius-lg);
|
border-radius: var(--mantine-radius-lg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
AccordionControl,
|
AccordionControl,
|
||||||
@@ -6,9 +7,15 @@ import {
|
|||||||
AccordionPanel,
|
AccordionPanel,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
ActionIconGroup,
|
ActionIconGroup,
|
||||||
|
Affix,
|
||||||
Anchor,
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
|
Menu,
|
||||||
|
MenuDropdown,
|
||||||
|
MenuTarget,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
TableTbody,
|
TableTbody,
|
||||||
@@ -17,8 +24,9 @@ import {
|
|||||||
TableThead,
|
TableThead,
|
||||||
TableTr,
|
TableTr,
|
||||||
Text,
|
Text,
|
||||||
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -29,12 +37,13 @@ import { getIntegrationName } from "@homarr/definitions";
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
|
import { env } from "~/env";
|
||||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||||
|
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||||
import classes from "./page.module.css";
|
import classes from "./page.module.css";
|
||||||
|
|
||||||
interface IntegrationsPageProps {
|
interface IntegrationsPageProps {
|
||||||
@@ -57,26 +66,60 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManageContainer>
|
||||||
title={t("page.list.title")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
<Stack>
|
||||||
canCreateIntegrations ? (
|
<Group justify="space-between" align="center">
|
||||||
<TourTarget id="manage-integrations-create">
|
<Title>{t("page.list.title")}</Title>
|
||||||
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
|
||||||
{t("action.create")}
|
{canCreateIntegrations && (
|
||||||
</MobileAffixButton>
|
<>
|
||||||
</TourTarget>
|
<Box>
|
||||||
) : undefined
|
<IntegrationSelectMenu>
|
||||||
}
|
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||||
floatingPrimaryAction={canCreateIntegrations}
|
<MenuTarget>
|
||||||
>
|
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
<TourTarget id="manage-integrations-list">
|
</MenuTarget>
|
||||||
|
</Affix>
|
||||||
|
</IntegrationSelectMenu>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box visibleFrom="md">
|
||||||
|
<IntegrationSelectMenu>
|
||||||
|
<MenuTarget>
|
||||||
|
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
|
</MenuTarget>
|
||||||
|
</IntegrationSelectMenu>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||||
</TourTarget>
|
</Stack>
|
||||||
</ManagePageLayout>
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
width={256}
|
||||||
|
trapFocus
|
||||||
|
position="bottom-end"
|
||||||
|
withinPortal
|
||||||
|
shadow="md"
|
||||||
|
keepMounted={false}
|
||||||
|
withInitialFocusPlaceholder={false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<MenuDropdown>
|
||||||
|
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
|
||||||
|
</MenuDropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface IntegrationListProps {
|
interface IntegrationListProps {
|
||||||
integrations: RouterOutputs["integration"]["all"];
|
integrations: RouterOutputs["integration"]["all"];
|
||||||
activeTab?: IntegrationKind;
|
activeTab?: IntegrationKind;
|
||||||
@@ -109,12 +152,12 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||||
{entries.map(([kind, kindIntegrations], index) => (
|
{entries.map(([kind, integrations], index) => (
|
||||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||||
<Group>
|
<Group>
|
||||||
<Text>{getIntegrationName(kind)}</Text>
|
<Text>{getIntegrationName(kind)}</Text>
|
||||||
<CountBadge count={kindIntegrations.length} />
|
<CountBadge count={integrations.length} />
|
||||||
</Group>
|
</Group>
|
||||||
</AccordionControl>
|
</AccordionControl>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
@@ -127,7 +170,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
</TableTr>
|
</TableTr>
|
||||||
</TableThead>
|
</TableThead>
|
||||||
<TableTbody>
|
<TableTbody>
|
||||||
{kindIntegrations.map((integration) => (
|
{integrations.map((integration) => (
|
||||||
<TableTr key={integration.id}>
|
<TableTr key={integration.id}>
|
||||||
<TableTd>{integration.name}</TableTd>
|
<TableTd>{integration.name}</TableTd>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
@@ -148,7 +191,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
>
|
>
|
||||||
<IconPencil size={16} stroke={1.5} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -159,9 +202,9 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<Stack gap="xs" hiddenFrom="md">
|
<Stack gap="xs" hiddenFrom="md">
|
||||||
{kindIntegrations.map((integration, integrationIndex) => (
|
{integrations.map((integration, index) => (
|
||||||
<Fragment key={integration.id}>
|
<Fragment key={integration.id}>
|
||||||
{integrationIndex !== 0 && <Divider />}
|
{index !== 0 && <Divider />}
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Text>{integration.name}</Text>
|
<Text>{integration.name}</Text>
|
||||||
@@ -176,7 +219,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
>
|
>
|
||||||
<IconPencil size={16} stroke={1.5} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
IconBrandTablerFilled,
|
IconBrandTablerFilled,
|
||||||
IconCertificate,
|
IconCertificate,
|
||||||
IconClipboardListFilled,
|
IconClipboardListFilled,
|
||||||
IconDatabaseExport,
|
|
||||||
IconDirectionsFilled,
|
IconDirectionsFilled,
|
||||||
IconGitFork,
|
IconGitFork,
|
||||||
IconHelpSquareRoundedFilled,
|
IconHelpSquareRoundedFilled,
|
||||||
@@ -29,7 +28,6 @@ import {
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
@@ -38,7 +36,6 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
|||||||
import type { NavigationLink } from "~/components/layout/navigation";
|
import type { NavigationLink } from "~/components/layout/navigation";
|
||||||
import { MainNavigation } from "~/components/layout/navigation";
|
import { MainNavigation } from "~/components/layout/navigation";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
|
||||||
|
|
||||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||||
const t = await getScopedI18n("management.navbar");
|
const t = await getScopedI18n("management.navbar");
|
||||||
@@ -48,13 +45,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
icon: IconHomeFilled,
|
icon: IconHomeFilled,
|
||||||
href: "/manage",
|
href: "/manage",
|
||||||
"data-onboarding-tour-id": "manage-welcome",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconLayoutDashboardFilled,
|
icon: IconLayoutDashboardFilled,
|
||||||
href: "/manage/boards",
|
href: "/manage/boards",
|
||||||
label: t("items.boards"),
|
label: t("items.boards"),
|
||||||
"data-onboarding-tour-id": "manage-boards",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconBox,
|
icon: IconBox,
|
||||||
@@ -64,14 +59,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
iconProps: {
|
iconProps: {
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
"data-onboarding-tour-id": "manage-apps",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconAffiliateFilled,
|
icon: IconAffiliateFilled,
|
||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
label: t("items.integrations"),
|
label: t("items.integrations"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
"data-onboarding-tour-id": "manage-integrations",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconSearch,
|
icon: IconSearch,
|
||||||
@@ -81,20 +74,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
iconProps: {
|
iconProps: {
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
"data-onboarding-tour-id": "manage-search-engines",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPhotoFilled,
|
icon: IconPhotoFilled,
|
||||||
href: "/manage/medias",
|
href: "/manage/medias",
|
||||||
label: t("items.medias"),
|
label: t("items.medias"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
"data-onboarding-tour-id": "manage-medias",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconUserFilled,
|
icon: IconUserFilled,
|
||||||
label: t("items.users.label"),
|
label: t("items.users.label"),
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
"data-onboarding-tour-id": "manage-users",
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.users.items.manage"),
|
label: t("items.users.items.manage"),
|
||||||
@@ -156,12 +146,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/tools/tasks",
|
href: "/manage/tools/tasks",
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t("items.tools.items.backup"),
|
|
||||||
icon: IconDatabaseExport,
|
|
||||||
href: "/manage/tools/backup",
|
|
||||||
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -169,7 +153,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/settings",
|
href: "/manage/settings",
|
||||||
icon: IconSettingsFilled,
|
icon: IconSettingsFilled,
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
"data-onboarding-tour-id": "manage-settings",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.help.label"),
|
label: t("items.help.label"),
|
||||||
@@ -208,17 +191,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
return (
|
||||||
|
|
||||||
const shell = (
|
|
||||||
<ClientShell hasNavigation>
|
<ClientShell hasNavigation>
|
||||||
<MainHeader></MainHeader>
|
<MainHeader></MainHeader>
|
||||||
<MainNavigation links={navigationLinks}></MainNavigation>
|
<MainNavigation links={navigationLinks}></MainNavigation>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
</ClientShell>
|
</ClientShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!session) return shell;
|
|
||||||
|
|
||||||
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
|
||||||
}
|
}
|
||||||
@@ -4,15 +4,17 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
TableTbody,
|
TableTbody,
|
||||||
TableTd,
|
TableTd,
|
||||||
TableTh,
|
TableTh,
|
||||||
TableThead,
|
TableThead,
|
||||||
TableTr,
|
TableTr,
|
||||||
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconExternalLink, IconPhoto } from "@tabler/icons-react";
|
import { IconExternalLink } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -24,9 +26,8 @@ import { createLocalImageUrl } from "@homarr/icons/local";
|
|||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NoResults } from "~/components/no-results";
|
|
||||||
import { CopyMedia } from "./_actions/copy-media";
|
import { CopyMedia } from "./_actions/copy-media";
|
||||||
import { DeleteMedia } from "./_actions/delete-media";
|
import { DeleteMedia } from "./_actions/delete-media";
|
||||||
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
||||||
@@ -47,7 +48,7 @@ interface MediaListPageProps {
|
|||||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function MediaListPage(props: MediaListPageProps) {
|
export default async function GroupsListPage(props: MediaListPageProps) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -57,31 +58,22 @@ export default async function MediaListPage(props: MediaListPageProps) {
|
|||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||||
const canUpload = session.user.permissions.includes("media-upload");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManageContainer>
|
||||||
title={t("media.plural")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
<Stack>
|
||||||
canUpload ? (
|
<Title>{t("media.plural")}</Title>
|
||||||
<ManageMobilePrimaryAction>
|
<Group justify="space-between">
|
||||||
<UploadMediaButton />
|
<Group>
|
||||||
</ManageMobilePrimaryAction>
|
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||||
) : undefined
|
{session.user.permissions.includes("media-view-all") && (
|
||||||
}
|
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
||||||
toolbar={
|
)}
|
||||||
<Group>
|
</Group>
|
||||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
|
||||||
{session.user.permissions.includes("media-view-all") && (
|
{session.user.permissions.includes("media-upload") && <UploadMediaButton />}
|
||||||
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
}
|
|
||||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
|
||||||
floatingPrimaryAction={canUpload}
|
|
||||||
>
|
|
||||||
{medias.length === 0 && <NoResults icon={IconPhoto} title={t("media.noResults.title")} />}
|
|
||||||
{medias.length > 0 && (
|
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<TableThead>
|
<TableThead>
|
||||||
<TableTr>
|
<TableTr>
|
||||||
@@ -98,8 +90,13 @@ export default async function MediaListPage(props: MediaListPageProps) {
|
|||||||
))}
|
))}
|
||||||
</TableTbody>
|
</TableTbody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
|
||||||
</ManagePageLayout>
|
{/* Added margin to not hide pagination behind affix-button */}
|
||||||
|
<Group justify="end" mb={48}>
|
||||||
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export default async function NotFound() {
|
|||||||
return (
|
return (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
<Stack align="center">
|
<Stack align="center">
|
||||||
<Title order={1}>{t("title")}</Title>
|
<Title order={1} tt="uppercase">
|
||||||
|
{t("title")}
|
||||||
|
</Title>
|
||||||
<Text>{t("text")}</Text>
|
<Text>{t("text")}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -29,20 +29,20 @@ export default async function ManagementPage() {
|
|||||||
<Space h="md" />
|
<Space h="md" />
|
||||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||||
{statistics.map((statistic) => (
|
{statistics.map((statistic) => (
|
||||||
<Card component={Link} href={statistic.path} key={statistic.path}>
|
<Card component={Link} href={statistic.path} key={statistic.path} radius="lg">
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<Text size="xl" fw={700}>
|
<Text size="2.4rem" fw="bolder">
|
||||||
{statistic.count}
|
{statistic.count}
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="red" size="xs">
|
||||||
{t(`statisticLabel.${statistic.subtitleKey}`)}
|
{t(`statisticLabel.${statistic.subtitleKey}`)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
<Text fw="bold">{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
<IconArrowRight />
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -10,8 +10,9 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
|||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
||||||
|
|
||||||
@@ -37,31 +38,34 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
|||||||
|
|
||||||
const tEngine = await getScopedI18n("search.engine");
|
const tEngine = await getScopedI18n("search.engine");
|
||||||
|
|
||||||
const canCreate = session.user.permissions.includes("search-engine-create");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManageContainer>
|
||||||
title={tEngine("page.list.title")}
|
<DynamicBreadcrumb />
|
||||||
primaryAction={
|
<Stack>
|
||||||
canCreate ? (
|
<Title>{tEngine("page.list.title")}</Title>
|
||||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
<Group justify="space-between" align="center">
|
||||||
{tEngine("page.create.title")}
|
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
|
||||||
</MobileAffixButton>
|
{session.user.permissions.includes("search-engine-create") && (
|
||||||
) : undefined
|
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||||
}
|
{tEngine("page.create.title")}
|
||||||
toolbar={<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
</MobileAffixButton>
|
||||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
)}
|
||||||
floatingPrimaryAction={canCreate}
|
</Group>
|
||||||
>
|
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||||
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
{searchEngines.length > 0 && (
|
||||||
{searchEngines.length > 0 && (
|
<Stack gap="sm">
|
||||||
<Stack gap="sm">
|
{searchEngines.map((searchEngine) => (
|
||||||
{searchEngines.map((searchEngine) => (
|
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
||||||
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
))}
|
||||||
))}
|
</Stack>
|
||||||
</Stack>
|
)}
|
||||||
)}
|
|
||||||
</ManagePageLayout>
|
{/* Added margin to not hide pagination behind affix-button */}
|
||||||
|
<Group justify="end" mb={48}>
|
||||||
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +78,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card withBorder>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
<>
|
<>
|
||||||
<Title order={2}>{t("title")}</Title>
|
<Title order={2}>{t("title")}</Title>
|
||||||
|
|
||||||
<Card pos="relative">
|
<Card pos="relative" withBorder>
|
||||||
<LoadingOverlay visible={isPending} />
|
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||||
<Stack>
|
<Stack>
|
||||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Group, Text } from "@mantine/core";
|
import { Group, Text } from "@mantine/core";
|
||||||
import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
|
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
import { colorSchemes } from "@homarr/definitions";
|
import { colorSchemes } from "@homarr/definitions";
|
||||||
@@ -34,7 +34,6 @@ export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: Serve
|
|||||||
};
|
};
|
||||||
|
|
||||||
const appearanceIcons = {
|
const appearanceIcons = {
|
||||||
auto: IconDeviceDesktop,
|
|
||||||
light: IconSun,
|
light: IconSun,
|
||||||
dark: IconMoon,
|
dark: IconMoon,
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -41,11 +41,11 @@ export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexing
|
|||||||
<>
|
<>
|
||||||
<Title order={2}>{t("title")}</Title>
|
<Title order={2}>{t("title")}</Title>
|
||||||
|
|
||||||
<Card pos="relative">
|
<Card pos="relative" withBorder>
|
||||||
<Text c={"dimmed"} mb={"lg"}>
|
<Text c={"dimmed"} mb={"lg"}>
|
||||||
{t("warning")}
|
{t("warning")}
|
||||||
</Text>
|
</Text>
|
||||||
<LoadingOverlay visible={isPending} />
|
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||||
<Stack>
|
<Stack>
|
||||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
|||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
form: Modify<
|
form: Modify<
|
||||||
UseFormReturnType<TFormValue, TFormValue>,
|
UseFormReturnType<TFormValue, () => TFormValue>,
|
||||||
{
|
{
|
||||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
|
||||||
import { IconBrain, IconCode, IconKey } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { McpInstructions } from "./mcp-instructions";
|
|
||||||
|
|
||||||
export interface McpToolGroup {
|
|
||||||
namespace: string;
|
|
||||||
tools: { name: string; description: string; type: "query" | "mutation" }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiPageTabsProps {
|
|
||||||
documentationLabel: string;
|
|
||||||
apiKeyLabel: string;
|
|
||||||
mcpLabel: string;
|
|
||||||
documentationPanel: ReactNode;
|
|
||||||
authenticationPanel: ReactNode;
|
|
||||||
baseUrl: string;
|
|
||||||
hasApiKeys: boolean;
|
|
||||||
toolGroups: McpToolGroup[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ApiPageTabs({
|
|
||||||
documentationLabel,
|
|
||||||
apiKeyLabel,
|
|
||||||
mcpLabel,
|
|
||||||
documentationPanel,
|
|
||||||
authenticationPanel,
|
|
||||||
baseUrl,
|
|
||||||
hasApiKeys,
|
|
||||||
toolGroups,
|
|
||||||
}: ApiPageTabsProps) {
|
|
||||||
const [activeTab, setActiveTab] = useState<string | null>("documentation");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTab value="documentation" leftSection={<IconCode size={16} />}>
|
|
||||||
{documentationLabel}
|
|
||||||
</TabsTab>
|
|
||||||
<TabsTab value="authentication" leftSection={<IconKey size={16} />}>
|
|
||||||
{apiKeyLabel}
|
|
||||||
</TabsTab>
|
|
||||||
<TabsTab value="mcp" leftSection={<IconBrain size={16} />}>
|
|
||||||
{mcpLabel}
|
|
||||||
</TabsTab>
|
|
||||||
</TabsList>
|
|
||||||
<TabsPanel value="authentication">{authenticationPanel}</TabsPanel>
|
|
||||||
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
|
|
||||||
<TabsPanel value="mcp">
|
|
||||||
<McpInstructions baseUrl={baseUrl} hasApiKeys={hasApiKeys} toolGroups={toolGroups} />
|
|
||||||
</TabsPanel>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionControl,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionPanel,
|
|
||||||
ActionIcon,
|
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Code,
|
|
||||||
CopyButton,
|
|
||||||
Group,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconBrain,
|
|
||||||
IconCheck,
|
|
||||||
IconCopy,
|
|
||||||
IconKey,
|
|
||||||
IconLock,
|
|
||||||
IconPlus,
|
|
||||||
IconPlugConnected,
|
|
||||||
IconSparkles,
|
|
||||||
IconTerminal,
|
|
||||||
IconTools,
|
|
||||||
IconWorld,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { CopyApiKeyModal } from "./copy-api-key-modal";
|
|
||||||
import type { McpToolGroup } from "./api-page-tabs";
|
|
||||||
import classes from "./mcp-tools-accordion.module.css";
|
|
||||||
|
|
||||||
const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = {
|
|
||||||
query: { color: "blue", method: "GET" },
|
|
||||||
mutation: { color: "orange", method: "POST" },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface McpInstructionsProps {
|
|
||||||
baseUrl: string;
|
|
||||||
hasApiKeys: boolean;
|
|
||||||
toolGroups: McpToolGroup[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) {
|
|
||||||
const t = useScopedI18n("management.page.tool.api.tab.mcp");
|
|
||||||
const { openModal } = useModalAction(CopyApiKeyModal);
|
|
||||||
const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({
|
|
||||||
async onSuccess(data) {
|
|
||||||
openModal({ apiKey: data.apiKey });
|
|
||||||
await revalidatePathActionAsync("/manage/tools/api");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const mcpUrl = `${baseUrl}/api/mcp/mcp`;
|
|
||||||
|
|
||||||
const streamableHttpConfig = JSON.stringify(
|
|
||||||
{
|
|
||||||
mcpServers: {
|
|
||||||
homarr: {
|
|
||||||
url: mcpUrl,
|
|
||||||
headers: {
|
|
||||||
ApiKey: "<your-api-key>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
|
|
||||||
const stdioConfig = JSON.stringify(
|
|
||||||
{
|
|
||||||
mcpServers: {
|
|
||||||
homarr: {
|
|
||||||
command: "npx",
|
|
||||||
args: ["-y", "mcp-remote", mcpUrl, "--header", "ApiKey:<your-api-key>"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
|
|
||||||
const testCommand = `curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "ApiKey: <your-api-key>" -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' ${mcpUrl}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xl" p="md">
|
|
||||||
<Group gap="sm" align="center">
|
|
||||||
<ThemeIcon variant="light" size="xl" radius="md">
|
|
||||||
<IconBrain size={24} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Title order={3}>{t("title")}</Title>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("subtitle")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Alert variant="light" icon={<IconSparkles size={18} />}>
|
|
||||||
<Text size="sm">{t("description")}</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{!hasApiKeys && (
|
|
||||||
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<Text size="sm">{t("noApiKey")}</Text>
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
variant="filled"
|
|
||||||
leftSection={<IconPlus size={14} />}
|
|
||||||
onClick={() => createApiKey()}
|
|
||||||
loading={isPending}
|
|
||||||
>
|
|
||||||
{t("createApiKeyButton")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Title order={5} mb="xs">
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconPlugConnected size={16} />
|
|
||||||
{t("endpoint.title")}
|
|
||||||
</Group>
|
|
||||||
</Title>
|
|
||||||
<CopyableCode value={mcpUrl} copyLabel={t("copy")} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Title order={5} mb="xs">
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconLock size={16} />
|
|
||||||
{t("authentication.title")}
|
|
||||||
</Group>
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" mb="sm">
|
|
||||||
{t("authentication.intro")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Accordion variant="separated" defaultValue="apikey">
|
|
||||||
<AccordionItem value="apikey">
|
|
||||||
<AccordionControl>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconKey size={16} />
|
|
||||||
{t("authentication.apiKey.title")}
|
|
||||||
</Group>
|
|
||||||
</AccordionControl>
|
|
||||||
<AccordionPanel>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="sm">{t("authentication.apiKey.description")}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("authentication.permissions")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</AccordionPanel>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem value="oauth">
|
|
||||||
<AccordionControl>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconWorld size={16} />
|
|
||||||
{t("authentication.oauth.title")}
|
|
||||||
</Group>
|
|
||||||
</AccordionControl>
|
|
||||||
<AccordionPanel>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="sm">{t("authentication.oauth.description")}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("authentication.oauth.hint")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</AccordionPanel>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Title order={5} mb="sm">
|
|
||||||
{t("clientConfig.title")}
|
|
||||||
</Title>
|
|
||||||
<Accordion variant="separated" defaultValue="streamable-http">
|
|
||||||
<AccordionItem value="streamable-http">
|
|
||||||
<AccordionControl>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Badge size="xs" variant="filled">
|
|
||||||
{t("clientConfig.streamableHttp.recommended")}
|
|
||||||
</Badge>
|
|
||||||
{t("clientConfig.streamableHttp.title")}
|
|
||||||
</Group>
|
|
||||||
</AccordionControl>
|
|
||||||
<AccordionPanel>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("clientConfig.streamableHttp.worksWith")}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("clientConfig.streamableHttp.addToConfig")}
|
|
||||||
</Text>
|
|
||||||
<List size="xs" spacing={4}>
|
|
||||||
<ListItem>
|
|
||||||
{t("clientConfig.streamableHttp.cursor", {
|
|
||||||
name: "Cursor",
|
|
||||||
file: ".cursor/mcp.json",
|
|
||||||
})}
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
{t("clientConfig.streamableHttp.claudeDesktop", {
|
|
||||||
name: "Claude Desktop",
|
|
||||||
file: "claude_desktop_config.json",
|
|
||||||
})}
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
{t("clientConfig.streamableHttp.claudeCode", {
|
|
||||||
name: "Claude Code",
|
|
||||||
file: ".claude/mcp.json",
|
|
||||||
command: "claude mcp add homarr",
|
|
||||||
})}
|
|
||||||
</ListItem>
|
|
||||||
<ListItem>
|
|
||||||
{t("clientConfig.streamableHttp.vsCodeCopilot", {
|
|
||||||
name: "VS Code Copilot",
|
|
||||||
file: ".vscode/mcp.json",
|
|
||||||
})}
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
<CopyableCode value={streamableHttpConfig} copyLabel={t("copy")} />
|
|
||||||
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={16} />} p="xs">
|
|
||||||
<Text size="xs">{t("clientConfig.streamableHttp.replaceHint")}</Text>
|
|
||||||
</Alert>
|
|
||||||
</Stack>
|
|
||||||
</AccordionPanel>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem value="stdio">
|
|
||||||
<AccordionControl>{t("clientConfig.stdio.title")}</AccordionControl>
|
|
||||||
<AccordionPanel>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("clientConfig.stdio.description", { dependency: "npx" })}
|
|
||||||
</Text>
|
|
||||||
<CopyableCode value={stdioConfig} copyLabel={t("copy")} />
|
|
||||||
</Stack>
|
|
||||||
</AccordionPanel>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Title order={5} mb="xs">
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconTerminal size={16} />
|
|
||||||
{t("testConnection.title")}
|
|
||||||
</Group>
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed" mb="xs">
|
|
||||||
{t("testConnection.description")}
|
|
||||||
</Text>
|
|
||||||
<CopyableCode value={testCommand} copyLabel={t("copy")} />
|
|
||||||
<Text size="xs" c="dimmed" mt="xs">
|
|
||||||
{t("testConnection.hint")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Title order={5} mb="xs">
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconTools size={16} />
|
|
||||||
{t("availableTools.title")}
|
|
||||||
</Group>
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed" mb="sm">
|
|
||||||
{t("availableTools.description", {
|
|
||||||
count: String(toolGroups.reduce((sum, g) => sum + g.tools.length, 0)),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
<Accordion variant="separated" multiple>
|
|
||||||
{toolGroups.map((group) => (
|
|
||||||
<AccordionItem key={group.namespace} value={group.namespace}>
|
|
||||||
<AccordionControl>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{group.namespace}
|
|
||||||
</Text>
|
|
||||||
<Badge size="xs" variant="light" circle>
|
|
||||||
{group.tools.length}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
</AccordionControl>
|
|
||||||
<AccordionPanel>
|
|
||||||
<Stack gap={6}>
|
|
||||||
{group.tools.map((tool) => (
|
|
||||||
<div key={tool.name} className={classes.toolRow}>
|
|
||||||
<Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}>
|
|
||||||
{toolTypeDisplay[tool.type].method}
|
|
||||||
</Badge>
|
|
||||||
<div>
|
|
||||||
<Code fz="xs" fw={600}>
|
|
||||||
{tool.name}
|
|
||||||
</Code>
|
|
||||||
<Text size="xs" c="dimmed" lh={1.4}>
|
|
||||||
{tool.description}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</AccordionPanel>
|
|
||||||
</AccordionItem>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CopyableCode({ value, copyLabel }: { value: string; copyLabel: string }) {
|
|
||||||
return (
|
|
||||||
<Code block pos="relative" style={{ whiteSpace: "pre-wrap" }}>
|
|
||||||
{value}
|
|
||||||
<CopyButton value={value}>
|
|
||||||
{({ copied, copy }) => (
|
|
||||||
<ActionIcon variant="subtle" size="sm" onClick={copy} pos="absolute" top={8} right={8} title={copyLabel}>
|
|
||||||
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
</Code>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
.toolRow {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 42px 1fr;
|
|
||||||
align-items: first baseline;
|
|
||||||
gap: var(--mantine-spacing-xs);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useComputedColorScheme } from "@mantine/core";
|
|
||||||
import { ApiReferenceReact } from "@scalar/api-reference-react";
|
|
||||||
|
|
||||||
import "@scalar/api-reference-react/style.css";
|
|
||||||
import "./scalar-theme.css";
|
|
||||||
|
|
||||||
interface ScalarApiReferenceProps {
|
|
||||||
document: object;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScalarApiReference({ document }: ScalarApiReferenceProps) {
|
|
||||||
const colorScheme = useComputedColorScheme("light");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ApiReferenceReact
|
|
||||||
configuration={{
|
|
||||||
content: document,
|
|
||||||
layout: "classic",
|
|
||||||
theme: "alternate",
|
|
||||||
showSidebar: false,
|
|
||||||
hideDarkModeToggle: true,
|
|
||||||
hideSearch: true,
|
|
||||||
hiddenClients: true,
|
|
||||||
showDeveloperTools: "never",
|
|
||||||
defaultOpenAllTags: true,
|
|
||||||
forceDarkModeState: colorScheme,
|
|
||||||
authentication: {
|
|
||||||
preferredSecurityScheme: "apikey",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
.light-mode {
|
|
||||||
--scalar-background-1: var(--mantine-color-body);
|
|
||||||
--scalar-background-2: var(--mantine-color-gray-0);
|
|
||||||
--scalar-background-3: var(--mantine-color-gray-1);
|
|
||||||
--scalar-background-card: var(--mantine-color-white);
|
|
||||||
--scalar-background-accent: rgba(0, 0, 0, 0.04);
|
|
||||||
|
|
||||||
--scalar-color-1: var(--mantine-color-text);
|
|
||||||
--scalar-color-2: var(--mantine-color-dimmed);
|
|
||||||
--scalar-color-3: var(--mantine-color-dimmed);
|
|
||||||
--scalar-color-accent: var(--mantine-color-red-6);
|
|
||||||
|
|
||||||
--scalar-border-color: var(--mantine-color-gray-3);
|
|
||||||
|
|
||||||
--scalar-button-1: var(--mantine-color-dark-7);
|
|
||||||
--scalar-button-1-hover: var(--mantine-color-dark-5);
|
|
||||||
--scalar-button-1-color: var(--mantine-color-white);
|
|
||||||
|
|
||||||
--scalar-scrollbar-color: rgba(0, 0, 0, 0.18);
|
|
||||||
--scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark-mode {
|
|
||||||
--scalar-background-1: var(--mantine-color-body);
|
|
||||||
--scalar-background-2: var(--mantine-color-dark-6);
|
|
||||||
--scalar-background-3: var(--mantine-color-dark-5);
|
|
||||||
--scalar-background-card: var(--mantine-color-dark-6);
|
|
||||||
--scalar-background-accent: rgba(255, 255, 255, 0.06);
|
|
||||||
|
|
||||||
--scalar-color-1: var(--mantine-color-text);
|
|
||||||
--scalar-color-2: var(--mantine-color-dimmed);
|
|
||||||
--scalar-color-3: var(--mantine-color-dimmed);
|
|
||||||
--scalar-color-accent: var(--mantine-color-red-6);
|
|
||||||
|
|
||||||
--scalar-border-color: var(--mantine-color-dark-4);
|
|
||||||
|
|
||||||
--scalar-button-1: rgba(255, 255, 255, 0.9);
|
|
||||||
--scalar-button-1-hover: rgba(255, 255, 255, 0.8);
|
|
||||||
--scalar-button-1-color: var(--mantine-color-dark-7);
|
|
||||||
|
|
||||||
--scalar-scrollbar-color: rgba(255, 255, 255, 0.18);
|
|
||||||
--scalar-scrollbar-color-active: rgba(255, 255, 255, 0.36);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scalar-card,
|
|
||||||
.scalar-api-client__item,
|
|
||||||
.dark-mode .dark-mode.scalar-card {
|
|
||||||
--scalar-background-1: var(--scalar-background-card);
|
|
||||||
--scalar-background-2: var(--scalar-background-card);
|
|
||||||
--scalar-background-3: var(--scalar-background-card);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { OpenAPIObject } from "openapi3-ts/oas31";
|
||||||
|
import SwaggerUI from "swagger-ui-react";
|
||||||
|
|
||||||
|
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
|
||||||
|
import "../swagger-ui-dark.css";
|
||||||
|
import "../swagger-ui-overrides.css";
|
||||||
|
import "../swagger-ui.css";
|
||||||
|
|
||||||
|
interface SwaggerUIClientProps {
|
||||||
|
document: OpenAPIObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const requestInterceptor = (req: Record<string, any>) => {
|
||||||
|
req.credentials = "omit";
|
||||||
|
return req;
|
||||||
|
};
|
||||||
|
|
||||||
|
return <SwaggerUI requestInterceptor={requestInterceptor} spec={document} />;
|
||||||
|
};
|
||||||
@@ -1,57 +1,16 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Stack } from "@mantine/core";
|
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
||||||
|
|
||||||
import { mcpRouter } from "@homarr/api/mcp";
|
|
||||||
import { openApiDocument } from "@homarr/api/open-api";
|
import { openApiDocument } from "@homarr/api/open-api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
|
|
||||||
import { ApiKeysManagement } from "./components/api-keys";
|
import { ApiKeysManagement } from "./components/api-keys";
|
||||||
import { ApiPageTabs } from "./components/api-page-tabs";
|
|
||||||
import { ScalarApiReference } from "./components/scalar-api-reference";
|
|
||||||
|
|
||||||
import type { McpToolGroup } from "./components/api-page-tabs";
|
|
||||||
|
|
||||||
let cachedToolGroups: McpToolGroup[] | null = null;
|
|
||||||
|
|
||||||
function getMcpToolGroups(): McpToolGroup[] {
|
|
||||||
if (cachedToolGroups) return cachedToolGroups;
|
|
||||||
|
|
||||||
const tools = extractMcpTools();
|
|
||||||
|
|
||||||
const procedures = (mcpRouter as any)._def.procedures as Record<string, { _def?: { type?: string } }>;
|
|
||||||
const procedureTypeMap = new Map<string, "query" | "mutation">();
|
|
||||||
for (const [key, proc] of Object.entries(procedures)) {
|
|
||||||
const type = proc?._def?.type;
|
|
||||||
if (type === "query" || type === "mutation") {
|
|
||||||
procedureTypeMap.set(key, type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = new Map<string, McpToolGroup["tools"]>();
|
|
||||||
for (const tool of tools) {
|
|
||||||
const namespace = tool.pathInRouter[0] ?? "other";
|
|
||||||
if (!groups.has(namespace)) {
|
|
||||||
groups.set(namespace, []);
|
|
||||||
}
|
|
||||||
const procedureKey = tool.pathInRouter.join(".");
|
|
||||||
groups.get(namespace)?.push({
|
|
||||||
name: tool.name,
|
|
||||||
description: tool.description,
|
|
||||||
type: procedureTypeMap.get(procedureKey) ?? "query",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({
|
|
||||||
namespace,
|
|
||||||
tools: items,
|
|
||||||
}));
|
|
||||||
return cachedToolGroups;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -71,25 +30,24 @@ export default async function ApiPage() {
|
|||||||
if (!session?.user.permissions.includes("admin")) {
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
const requestHeaders = await headers();
|
const document = openApiDocument(extractBaseUrlFromHeaders(await headers()));
|
||||||
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
|
|
||||||
const document = openApiDocument(baseUrl);
|
|
||||||
const apiKeys = await api.apiKeys.getAll();
|
const apiKeys = await api.apiKeys.getAll();
|
||||||
const t = await getScopedI18n("management.page.tool.api.tab");
|
const t = await getScopedI18n("management.page.tool.api.tab");
|
||||||
const toolGroups = getMcpToolGroups();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<ApiPageTabs
|
<Tabs defaultValue={"documentation"}>
|
||||||
documentationLabel={t("documentation.label")}
|
<TabsList>
|
||||||
apiKeyLabel={t("apiKey.label")}
|
<TabsTab value={"documentation"}>{t("documentation.label")}</TabsTab>
|
||||||
mcpLabel={t("mcp.label")}
|
<TabsTab value={"authentication"}>{t("apiKey.label")}</TabsTab>
|
||||||
documentationPanel={<ScalarApiReference document={document} />}
|
</TabsList>
|
||||||
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
|
<TabsPanel value={"authentication"}>
|
||||||
baseUrl={baseUrl}
|
<ApiKeysManagement apiKeys={apiKeys} />
|
||||||
hasApiKeys={apiKeys.length > 0}
|
</TabsPanel>
|
||||||
toolGroups={toolGroups}
|
<TabsPanel value={"documentation"}>
|
||||||
/>
|
<SwaggerUIClient document={document} />
|
||||||
|
</TabsPanel>
|
||||||
|
</Tabs>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,4 @@
|
|||||||
|
.swagger-ui .info {
|
||||||
|
margin: 0 !important;
|
||||||
|
margin-bottom: 20px !important;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 694 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user