Compare commits
214
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8203ca0c63 | ||
|
|
5e5492818a | ||
|
|
a6ec5f789d | ||
|
|
dade477b32 | ||
|
|
79f88dc3b8 | ||
|
|
4a12f8be61 | ||
|
|
044ad9edf9 | ||
|
|
57d2c6a7e1 | ||
|
|
46f6301fdd | ||
|
|
c3f300d87d | ||
|
|
1de558dd51 | ||
|
|
2711f3db13 | ||
|
|
ff3933b335 | ||
|
|
fa737638a8 | ||
|
|
97b7c417ab | ||
|
|
58d8005d38 | ||
|
|
507ac5181f | ||
|
|
b0cdb81533 | ||
|
|
fc0367a32f | ||
|
|
7cd30b640c | ||
|
|
a9855fe8d3 | ||
|
|
7a1272146c | ||
|
|
db61b6f390 | ||
|
|
887c294191 | ||
|
|
2f2a6291a2 | ||
|
|
1b3353118d | ||
|
|
e03f56ac31 | ||
|
|
d571a7f75e | ||
|
|
e3b765b9bd | ||
|
|
4da313826d | ||
|
|
948ed7a7fa | ||
|
|
f7fc64ebc4 | ||
|
|
b7f51d6f5c | ||
|
|
e185eeb6d7 | ||
|
|
90d703b870 | ||
|
|
75640f21e0 | ||
|
|
4aa43ae27a | ||
|
|
6b67671e29 | ||
|
|
220f361b69 | ||
|
|
a1d6457ced | ||
|
|
f7ade3c676 | ||
|
|
f656d9ca2d | ||
|
|
cefd90385d | ||
|
|
e5021b1d78 | ||
|
|
f0bd5132a3 | ||
|
|
d356fa41a0 | ||
|
|
a9e9ac1cfa | ||
|
|
1b3e720701 | ||
|
|
2461363d4f | ||
|
|
f29e74b6f4 | ||
|
|
89dfe82fe9 | ||
|
|
48a0f75c31 | ||
|
|
37103cf414 | ||
|
|
dec34916b2 | ||
|
|
64a4c196c0 | ||
|
|
41d596a1c9 | ||
|
|
bac0bc7b84 | ||
|
|
1ac6774821 | ||
|
|
7adb21ca59 | ||
|
|
4daf2729e5 | ||
|
|
1942c32128 | ||
|
|
b061a59433 | ||
|
|
ab867a9e50 | ||
|
|
968338aa97 | ||
|
|
b34102a973 | ||
|
|
378d1e1f1f | ||
|
|
3a8abe4415 | ||
|
|
6feb37dfd4 | ||
|
|
8042a18dc1 | ||
|
|
c78bf05fc3 | ||
|
|
4635c51fe1 | ||
|
|
5cc0f97aa2 | ||
|
|
b581f9b11a | ||
|
|
756e8d1724 | ||
|
|
7c64de5cbc | ||
|
|
56af6bcaf0 | ||
|
|
84b269a80d | ||
|
|
4d30a2e8c4 | ||
|
|
2d15b3d407 | ||
|
|
44a2973b1a | ||
|
|
00a055283e | ||
|
|
a474dcd961 | ||
|
|
69fe180968 | ||
|
|
036f6aa185 | ||
|
|
12a1bd7e2e | ||
|
|
c4ec45a9d0 | ||
|
|
b8e0123893 | ||
|
|
df953771a7 | ||
|
|
44bd16d677 | ||
|
|
277a509f49 | ||
|
|
a9ad991892 | ||
|
|
3183914a15 | ||
|
|
62be81ea60 | ||
|
|
ffc292b22a | ||
|
|
c83fdb2a6e | ||
|
|
b5c316b825 | ||
|
|
81fdd90e7a | ||
|
|
831b6221fd | ||
|
|
37edfb2de2 | ||
|
|
43cc68bdd4 | ||
|
|
0937555d49 | ||
|
|
663fe980bf | ||
|
|
9e2c487389 | ||
|
|
698e551790 | ||
|
|
71e4526ad5 | ||
|
|
5ee84f8cf4 | ||
|
|
b07a4a0f3e | ||
|
|
62a476a949 | ||
|
|
6671ac48f9 | ||
|
|
3c7a7615ba | ||
|
|
2f301bb56a | ||
|
|
e06abbc176 | ||
|
|
04daeba089 | ||
|
|
7c83d2cb6e | ||
|
|
2829d145af | ||
|
|
44bf36fff9 | ||
|
|
acd11c3fc1 | ||
|
|
b9c701a71b | ||
|
|
e52ecd64ee | ||
|
|
af77f729ec | ||
|
|
7c39e4de13 | ||
|
|
3e918e5d3d | ||
|
|
9cd45f7832 | ||
|
|
90ca3770a1 | ||
|
|
a183339063 | ||
|
|
0a4e4c3b70 | ||
|
|
b52e664d0f | ||
|
|
9f4634a95d | ||
|
|
a6f6c2e945 | ||
|
|
b6d23e7546 | ||
|
|
b0f2c94674 | ||
|
|
80d18bb10d | ||
|
|
2bb0f61da7 | ||
|
|
24386656fd | ||
|
|
453360fe61 | ||
|
|
beac1ed780 | ||
|
|
aa1b3810f1 | ||
|
|
ce3c1b6cc1 | ||
|
|
3f6b830f9b | ||
|
|
f0cb79799a | ||
|
|
7561462594 | ||
|
|
9cf0070442 | ||
|
|
647afbe2cb | ||
|
|
bfa8566284 | ||
|
|
e31555776e | ||
|
|
3c88ae9023 | ||
|
|
5200c17baf | ||
|
|
97311b338a | ||
|
|
a6b7e9df93 | ||
|
|
a1aa5505a3 | ||
|
|
e9289bd174 | ||
|
|
e8edc203b6 | ||
|
|
ed6680ba6a | ||
|
|
16c6929396 | ||
|
|
dba9efc726 | ||
|
|
ef012981db | ||
|
|
e77b9bb3d2 | ||
|
|
c24f4954ea | ||
|
|
a268fd5a26 | ||
|
|
8094458b10 | ||
|
|
b88a1a771e | ||
|
|
b94d2f15bc | ||
|
|
ce8e330c82 | ||
|
|
0e7b9fa2d4 | ||
|
|
42b108964b | ||
|
|
ff630ea647 | ||
|
|
45e5827872 | ||
|
|
b20e8a1cd1 | ||
|
|
712da837b1 | ||
|
|
424999e13a | ||
|
|
f8ce537dce | ||
|
|
3d202d409f | ||
|
|
1c8fc989da | ||
|
|
a410ee7caf | ||
|
|
03f80e1ee2 | ||
|
|
c04baab4e9 | ||
|
|
feb84c9a62 | ||
|
|
e097aa3ee9 | ||
|
|
a8bf9219a2 | ||
|
|
9199a2b221 | ||
|
|
fda64b36ae | ||
|
|
ad19e4c954 | ||
|
|
d91ca61ff0 | ||
|
|
05c260667a | ||
|
|
fb5b882e28 | ||
|
|
c5e00c2543 | ||
|
|
33964fbfe8 | ||
|
|
abd42de067 | ||
|
|
7852f1d987 | ||
|
|
e4dab4d1b4 | ||
|
|
346f57f3fa | ||
|
|
a4c8d67e92 | ||
|
|
e53cf65054 | ||
|
|
320e88992a | ||
|
|
678e151681 | ||
|
|
5d1517bdf2 | ||
|
|
dc7b1b005e | ||
|
|
9bbc8b4db9 | ||
|
|
0a32fe0967 | ||
|
|
0d0dc6aaa0 | ||
|
|
bd1a15af22 | ||
|
|
841fdbdd78 | ||
|
|
de595b0623 | ||
|
|
9c52a33f3a | ||
|
|
3b14cb0783 | ||
|
|
4a2b221c25 | ||
|
|
3bfa9d669e | ||
|
|
8fdfde4f2f | ||
|
|
7873c175b2 | ||
|
|
e583328e87 | ||
|
|
4d024a2a02 | ||
|
|
051e0c3dc0 | ||
|
|
5114bba2b1 | ||
|
|
ec986147a2 |
No files matched your search
@@ -0,0 +1,191 @@
|
||||
---
|
||||
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`
|
||||
+5
-1
@@ -9,4 +9,8 @@ README.md
|
||||
.git
|
||||
dev
|
||||
.build
|
||||
e2e
|
||||
e2e
|
||||
apps/nextjs/.next
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
+14
-4
@@ -12,10 +12,6 @@ AUTH_SECRET="supersecret"
|
||||
# or starting the project without any (which will show a randomly generated one).
|
||||
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# Cron job API key is generated every time the container starts as it is required for communication between nextjs-api and tasks-api
|
||||
# export CRON_JOB_API_KEY=$(openssl rand -base64 32)
|
||||
# CRON_JOB_API_KEY="your-generated-api-key"
|
||||
|
||||
LOG_LEVEL='info'
|
||||
|
||||
# This is how you can use the sqlite driver:
|
||||
@@ -45,6 +41,20 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||
|
||||
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=true
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.61.0
|
||||
- 1.60.1-beta.1
|
||||
- 1.60.0
|
||||
- 1.59.3
|
||||
- 1.59.2
|
||||
- 1.59.1
|
||||
- 1.59.0
|
||||
- 1.58.1
|
||||
|
||||
@@ -25,16 +25,16 @@ body:
|
||||
label: Public API available?
|
||||
description: Is there a public API available, that we can consume in Homarr?
|
||||
options:
|
||||
- Yes, available on a website
|
||||
- Yes, available in the application itself
|
||||
- No
|
||||
- "Yes, available on a website"
|
||||
- "Yes, available in the application itself"
|
||||
- "No"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Are you willing to contribute this yourself?
|
||||
options:
|
||||
- Yes
|
||||
- No
|
||||
- "Yes"
|
||||
- "No"
|
||||
validations:
|
||||
required: true
|
||||
@@ -24,7 +24,7 @@ body:
|
||||
attributes:
|
||||
label: Are you willing to contribute this yourself?
|
||||
options:
|
||||
- Yes
|
||||
- No
|
||||
- "Yes"
|
||||
- "No"
|
||||
validations:
|
||||
required: true
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to approve pull request
|
||||
|
||||
- name: Install GitHub CLI
|
||||
|
||||
@@ -11,14 +11,17 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
|
||||
# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds
|
||||
env:
|
||||
FORCE_COLOR: 3
|
||||
SKIP_ENV_VALIDATION: true
|
||||
TURBO_API: ${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
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:
|
||||
lint:
|
||||
@@ -26,6 +29,8 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
@@ -35,31 +40,35 @@ jobs:
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint && pnpm lint:ws
|
||||
run: pnpm turbo lint --affected && pnpm lint:ws
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Format
|
||||
run: pnpm format
|
||||
run: pnpm turbo format --affected --continue -- --cache --cache-location node_modules/.cache/.prettiercache
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Typecheck
|
||||
run: turbo typecheck
|
||||
run: pnpm turbo typecheck --affected
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -82,11 +91,12 @@ jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Build docker image
|
||||
id: build-docker-image
|
||||
uses: docker/build-push-action@v7
|
||||
@@ -94,8 +104,11 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: homarr-e2e
|
||||
network: host
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Install playwright browsers
|
||||
@@ -109,10 +122,12 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Copy env
|
||||
shell: bash
|
||||
run: cp .env.example .env
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
run: pnpm turbo build --affected
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.CROWDIN_APP_ID }}
|
||||
client-id: ${{ secrets.CROWDIN_APP_ID }}
|
||||
permission-contents: write # required to commit to crowdin branch
|
||||
permission-pull-requests: write # required to create pull request
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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,6 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- beta
|
||||
- next
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -34,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' }}
|
||||
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' || github.ref_name == 'next' }}
|
||||
outputs:
|
||||
version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }}
|
||||
git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }}
|
||||
@@ -53,8 +54,8 @@ jobs:
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||
permission-contents: write # required to commit package.json & changelog changes, merge them to dev and publish the release
|
||||
client-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
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
@@ -65,8 +66,8 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
with:
|
||||
node-version: 24.14.1
|
||||
cache: "pnpm"
|
||||
node-version: 24.15.0
|
||||
package-manager-cache: false
|
||||
- run: npm i -g pnpm
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
- name: Install dependencies
|
||||
@@ -95,20 +96,21 @@ jobs:
|
||||
id: read-git-ref
|
||||
run: |
|
||||
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- name: Update dev branch
|
||||
- name: Update dev / next branch
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
continue-on-error: true # Prevent pipeline from failing when merge fails
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
UPSTREAM_BRANCH: ${{ github.ref_name == 'main' && 'dev' || 'next' }}
|
||||
run: |
|
||||
git config user.name "Releases Homarr"
|
||||
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 fetch origin dev
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
git fetch origin ${{ env.UPSTREAM_BRANCH }}
|
||||
git checkout ${{ env.UPSTREAM_BRANCH }}
|
||||
git pull origin ${{ env.UPSTREAM_BRANCH }}
|
||||
git rebase ${{ github.ref_name }}
|
||||
git push origin dev
|
||||
git push origin ${{ env.UPSTREAM_BRANCH }}
|
||||
build-amd64:
|
||||
name: Build docker image for amd64
|
||||
needs: release
|
||||
@@ -191,7 +193,7 @@ jobs:
|
||||
name: Extract amd64 asset from docker image
|
||||
needs: [release, build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Extract amd64
|
||||
if: needs.release.outputs.skipped == 'false'
|
||||
@@ -207,7 +209,7 @@ jobs:
|
||||
name: Extract arm64 asset from docker image
|
||||
needs: [release, build-arm64]
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 2
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Extract arm64
|
||||
if: needs.release.outputs.skipped == 'false'
|
||||
@@ -234,7 +236,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||
permission-contents: write # required to publish the release
|
||||
|
||||
- name: Log in to the Container registry
|
||||
@@ -274,6 +276,6 @@ jobs:
|
||||
- name: Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
with:
|
||||
args: "Successfully deployed images for branch **${{ github.ref_name }}**. Tagged as **${{env.NEXT_VERSION}}**."
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Discord notification
|
||||
if: ${{ github.events.inputs.send-notifications }}
|
||||
if: ${{ github.event.inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
with:
|
||||
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
||||
- name: Checkout repository
|
||||
@@ -38,14 +38,15 @@ jobs:
|
||||
token: ${{ github.token }}
|
||||
branch: dev
|
||||
- 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'
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Discord notification
|
||||
if: ${{ github.events.inputs.send-notifications }}
|
||||
if: ${{ github.event.inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
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 }} (new behaviour: ${{ steps.semver.outputs.bump }})"
|
||||
- name: Obtain token
|
||||
@@ -53,7 +54,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write
|
||||
- name: Approve PR
|
||||
env:
|
||||
@@ -65,7 +66,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||
permission-contents: write # write to main branch (due to merge)
|
||||
permission-pull-requests: write # merge pull request
|
||||
- id: automerge
|
||||
@@ -74,7 +75,7 @@ jobs:
|
||||
uses: "pascalgn/automerge-action@v0.16.4"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
|
||||
MERGE_METHOD: merge # we prefer merge commits for merging to master
|
||||
MERGE_METHOD: merge # we prefer merge commits for merging to main
|
||||
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
||||
MERGE_DELETE_BRANCH: false # never set to true!
|
||||
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
|
||||
@@ -83,23 +84,23 @@ jobs:
|
||||
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
||||
|
||||
- name: Merged Discord notification
|
||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
|
||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
with:
|
||||
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
||||
- name: Major Bump Discord notification
|
||||
if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
|
||||
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
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 }}"
|
||||
- name: Discord Fail Notification
|
||||
if: failure() && github.events.inputs.send-notifications
|
||||
if: failure() && github.event.inputs.send-notifications
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
with:
|
||||
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
|
||||
with:
|
||||
private-key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }}
|
||||
app-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }}
|
||||
client-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }}
|
||||
owner: homarr-labs
|
||||
repositories: |
|
||||
documentation
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||
permission-contents: write # required to commit to branch
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
- name: Checkout code
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
||||
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||
permission-contents: write # required to commit to branch
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
||||
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||
permission-contents: write # required to commit to branch
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
- name: Checkout code
|
||||
|
||||
+1
-1
@@ -71,4 +71,4 @@ en.d.json.ts
|
||||
|
||||
# personal agent config
|
||||
AGENTS.md
|
||||
.opencode
|
||||
.opencode
|
||||
+3900
-3814
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,11 @@
|
||||
|
||||
# Exempt Renovate‑managed files (no owners)
|
||||
package.json
|
||||
**/package.json
|
||||
package-lock.json
|
||||
**/package-lock.json
|
||||
pnpm-lock.yaml
|
||||
**/pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
+1
-3
@@ -1,4 +1,4 @@
|
||||
FROM node:24.14.1-alpine AS base
|
||||
FROM node:24.15.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
@@ -43,8 +43,6 @@ RUN mkdir -p /var/cache/nginx && \
|
||||
COPY --from=builder /app/apps/nextjs/next.config.ts .
|
||||
COPY --from=builder /app/apps/nextjs/package.json .
|
||||
|
||||
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
|
||||
COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
|
||||
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
|
||||
|
||||
COPY --from=builder /app/packages/db/migrations ./db/migrations
|
||||
|
||||
@@ -10,4 +10,21 @@ export default [
|
||||
...baseConfig,
|
||||
...reactConfig,
|
||||
...nextjsConfig,
|
||||
{
|
||||
files: ["public/sw.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
self: "readonly",
|
||||
clients: "readonly",
|
||||
skipWaiting: "readonly",
|
||||
importScripts: "readonly",
|
||||
caches: "readonly",
|
||||
fetch: "readonly",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -6,7 +6,6 @@ import "@homarr/core/infrastructure/logs/env";
|
||||
import "@homarr/docker/env";
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
import MillionLint from "@million/lint";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
@@ -32,6 +31,8 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
preloadEntriesOnStart: false,
|
||||
webpackMemoryOptimizations: true,
|
||||
},
|
||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||
images: {
|
||||
@@ -42,9 +43,18 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
// skipcq: JS-0116
|
||||
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
|
||||
async headers() {
|
||||
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
|
||||
headers: [
|
||||
@@ -74,8 +84,4 @@ 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);
|
||||
@@ -44,9 +44,11 @@
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
"@homarr/spotlight": "workspace:^0.1.0",
|
||||
"@homarr/tasks": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/websocket": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "catalog:",
|
||||
"@mantine/core": "catalog:",
|
||||
@@ -54,7 +56,6 @@
|
||||
"@mantine/hooks": "catalog:",
|
||||
"@mantine/modals": "catalog:",
|
||||
"@mantine/tiptap": "catalog:",
|
||||
"@million/lint": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "catalog:",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
@@ -8,13 +8,12 @@ import dayjs from "dayjs";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||
|
||||
export const CustomMantineProvider = ({
|
||||
children,
|
||||
defaultColorScheme,
|
||||
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
|
||||
const manager = useColorSchemeManager();
|
||||
return (
|
||||
<DirectionProvider>
|
||||
@@ -35,7 +34,7 @@ export const CustomMantineProvider = ({
|
||||
export function useColorSchemeManager(): MantineColorSchemeManager {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
|
||||
const updateCookieValue = (value: MantineColorScheme) => {
|
||||
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
|
||||
};
|
||||
|
||||
@@ -60,7 +59,6 @@ export function useColorSchemeManager(): MantineColorSchemeManager {
|
||||
},
|
||||
|
||||
set: (value) => {
|
||||
if (value === "auto") return;
|
||||
try {
|
||||
if (session) {
|
||||
mutateColorScheme({ colorScheme: value });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Inter } from "next/font/google";
|
||||
import "@homarr/notifications/styles.css";
|
||||
import "@homarr/spotlight/styles.css";
|
||||
import "@homarr/ui/styles.css";
|
||||
import "~/styles/color-scheme.scss";
|
||||
import "~/styles/scroll-area.scss";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -25,6 +26,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import { DayJsLoader } from "./_client-providers/dayjs-loader";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
@@ -129,7 +131,7 @@ export default async function Layout(props: {
|
||||
dir={direction}
|
||||
data-mantine-color-scheme={colorScheme}
|
||||
style={{
|
||||
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
|
||||
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
|
||||
}}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
@@ -141,6 +143,7 @@ export default async function Layout(props: {
|
||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||
<StackedProvider>
|
||||
<Notifications />
|
||||
<ServiceWorkerRegistration />
|
||||
{props.children}
|
||||
</StackedProvider>
|
||||
</body>
|
||||
|
||||
@@ -25,7 +25,14 @@ import {
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconKeyboard, IconLanguage, IconLibrary, IconPackage, IconUsers } from "@tabler/icons-react";
|
||||
import {
|
||||
IconKeyboard,
|
||||
IconLanguage,
|
||||
IconLibrary,
|
||||
IconMoneybagHeart,
|
||||
IconPackage,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { capitalize, objectEntries } from "@homarr/common";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
@@ -37,6 +44,7 @@ import { createMetaTitle } from "~/metadata";
|
||||
import type { PackageJsonDependencies } from "~/versions/package-reader";
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
|
||||
import type openCollectiveContributorsJson from "../../../../../../../static-data/opencollective-contributors.json";
|
||||
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
|
||||
import classes from "./about.module.css";
|
||||
|
||||
@@ -71,6 +79,10 @@ export default async function AboutPage() {
|
||||
res.json(),
|
||||
)) as typeof crowdinContributorsJson;
|
||||
|
||||
const openCollectiveContributors = (await fetch(`${baseServerUrl}/api/about/contributors/opencollective`).then(
|
||||
(res) => res.json(),
|
||||
)) as typeof openCollectiveContributorsJson;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DynamicBreadcrumb />
|
||||
@@ -136,6 +148,30 @@ export default async function AboutPage() {
|
||||
</Flex>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="sponsors">
|
||||
<AccordionControl icon={<IconMoneybagHeart size="1rem" />}>
|
||||
<Stack gap={0}>
|
||||
<Text>{t("accordion.sponsors.title")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("accordion.sponsors.subtitle", {
|
||||
count: String(openCollectiveContributors.length),
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Flex wrap="wrap" gap="xs">
|
||||
{openCollectiveContributors.map((sponsor) => (
|
||||
<GenericContributorLinkCard
|
||||
key={sponsor.slug}
|
||||
link={`https://opencollective.com/${sponsor.slug}`}
|
||||
image={sponsor.imageUrl}
|
||||
name={sponsor.name}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="libraries">
|
||||
<AccordionControl icon={<IconLibrary size="1rem" />}>
|
||||
<Stack gap={0}>
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||
import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
@@ -34,6 +34,7 @@ export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: Serve
|
||||
};
|
||||
|
||||
const appearanceIcons = {
|
||||
auto: IconDeviceDesktop,
|
||||
light: IconSun,
|
||||
dark: IconMoon,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
||||
|
||||
import { openApiDocument } from "@homarr/api";
|
||||
import { openApiDocument } from "@homarr/api/open-api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
|
||||
@@ -58,6 +58,18 @@ const createColumns = (
|
||||
return <ContainerStateBadge state={cell.row.original.state} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "host",
|
||||
header: t("docker.field.host.label"),
|
||||
size: 140,
|
||||
Cell({ row }) {
|
||||
return (
|
||||
<Text truncate="end" title={row.original.host}>
|
||||
{row.original.host}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "image",
|
||||
header: t("docker.field.containerImage.label"),
|
||||
|
||||
+6
@@ -26,6 +26,7 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
|
||||
form.setInitialValues({
|
||||
defaultSearchEngineId: variables.defaultSearchEngineId,
|
||||
openInNewTab: variables.openInNewTab,
|
||||
ddgBangsEnabled: variables.ddgBangsEnabled,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeSearchPreferences.notification.success.message"),
|
||||
@@ -41,6 +42,7 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
|
||||
initialValues: {
|
||||
defaultSearchEngineId: user.defaultSearchEngineId,
|
||||
openInNewTab: user.openSearchInNewTab,
|
||||
ddgBangsEnabled: user.ddgBangs,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,6 +66,10 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
|
||||
label={t("user.field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
firstDayOfWeek: variables.firstDayOfWeek,
|
||||
firstDayOfWeek: variables.firstDayOfWeek as DayOfWeek,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { NextRequest } from "next/server";
|
||||
import { userAgent } from "next/server";
|
||||
import { createOpenApiFetchHandler } from "trpc-to-openapi";
|
||||
|
||||
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||
import { createTRPCContext } from "@homarr/api";
|
||||
import { openApiRouter } from "@homarr/api/open-api";
|
||||
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
|
||||
import { ipAddressFromHeaders } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -30,7 +31,7 @@ const handlerAsync = async (req: NextRequest) => {
|
||||
return createOpenApiFetchHandler({
|
||||
req,
|
||||
endpoint: "/",
|
||||
router: appRouter,
|
||||
router: openApiRouter,
|
||||
createContext: () => createTRPCContext({ session, headers: req.headers }),
|
||||
onError({ error, path, type }) {
|
||||
logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error }));
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import openCollectiveContributors from "../../../../../../../../static-data/opencollective-contributors.json";
|
||||
|
||||
export const GET = () => {
|
||||
return NextResponse.json(openCollectiveContributors);
|
||||
};
|
||||
|
||||
export const dynamic = "force-static";
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { openApiDocument } from "@homarr/api";
|
||||
import { openApiDocument } from "@homarr/api/open-api";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
|
||||
export function GET(request: Request) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
|
||||
import { IconCheck, IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const schemeIcons: Record<ColorScheme, React.ElementType> = {
|
||||
auto: IconDeviceDesktop,
|
||||
light: IconSun,
|
||||
dark: IconMoon,
|
||||
};
|
||||
|
||||
interface ColorSchemeComboboxProps {
|
||||
value: ColorScheme;
|
||||
onChange: (value: ColorScheme) => void;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export const ColorSchemeCombobox = ({ value, onChange, width }: ColorSchemeComboboxProps) => {
|
||||
const tOptions = useScopedI18n("common.colorScheme.options");
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
|
||||
const handleOnOptionSubmit = React.useCallback(
|
||||
(val: string) => {
|
||||
if (!val) return;
|
||||
onChange(val as ColorScheme);
|
||||
combobox.closeDropdown();
|
||||
},
|
||||
[onChange, combobox],
|
||||
);
|
||||
|
||||
const handleOnClick = React.useCallback(() => {
|
||||
combobox.toggleDropdown();
|
||||
}, [combobox]);
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
rightSection={<Combobox.Chevron />}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={handleOnClick}
|
||||
variant="filled"
|
||||
w={width}
|
||||
>
|
||||
<OptionItem scheme={value} currentScheme={value} tOptions={tOptions} />
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{colorSchemes.map((scheme) => (
|
||||
<Combobox.Option value={scheme} key={scheme}>
|
||||
<OptionItem scheme={scheme} currentScheme={value} tOptions={tOptions} showCheck />
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionItem = ({
|
||||
scheme,
|
||||
currentScheme,
|
||||
tOptions,
|
||||
showCheck,
|
||||
}: {
|
||||
scheme: ColorScheme;
|
||||
currentScheme: ColorScheme;
|
||||
tOptions: (key: ColorScheme) => string;
|
||||
showCheck?: boolean;
|
||||
}) => {
|
||||
const Icon = schemeIcons[scheme];
|
||||
|
||||
return (
|
||||
<Group wrap="nowrap" justify="space-between">
|
||||
<Group wrap="nowrap">
|
||||
<Icon size={16} stroke={1.5} />
|
||||
<Text>{tOptions(scheme)}</Text>
|
||||
</Group>
|
||||
{showCheck && scheme === currentScheme && <IconCheck color="currentColor" size={16} />}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||
import { useMantineColorScheme } from "@mantine/core";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import { ColorSchemeCombobox } from "./color-scheme-combobox";
|
||||
|
||||
interface CurrentColorSchemeComboboxProps {
|
||||
w?: string;
|
||||
}
|
||||
|
||||
export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => {
|
||||
const tOptions = useScopedI18n("common.colorScheme.options");
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
return (
|
||||
<SelectWithCustomItems
|
||||
value={colorScheme}
|
||||
onChange={(value) => setColorScheme((value as ColorScheme | null) ?? "light")}
|
||||
data={colorSchemes.map((scheme) => ({
|
||||
value: scheme,
|
||||
label: tOptions(scheme),
|
||||
}))}
|
||||
SelectOption={ColorSchemeCustomOption}
|
||||
w={w}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const appearanceIcons = {
|
||||
light: IconSun,
|
||||
dark: IconMoon,
|
||||
};
|
||||
|
||||
const ColorSchemeCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||
const Icon = appearanceIcons[value];
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Icon size={16} stroke={1.5} />
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
return <ColorSchemeCombobox value={colorScheme as ColorScheme} onChange={setColorScheme} width={w} />;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const ServiceWorkerRegistration = () => {
|
||||
useEffect(() => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" }).catch((error) => {
|
||||
console.error("Service worker registration failed", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -5,16 +5,7 @@ import { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||
import {
|
||||
IconCheck,
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
@@ -24,6 +15,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
||||
|
||||
@@ -34,13 +26,9 @@ interface UserAvatarMenuProps {
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
|
||||
const ColorSchemeIcon = colorScheme === "dark" ? IconSun : IconMoon;
|
||||
|
||||
const colorSchemeText = colorScheme === "dark" ? t("switchToLightMode") : t("switchToDarkMode");
|
||||
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -67,14 +55,14 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
<Menu width={300} withArrow withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
|
||||
{colorSchemeText}
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("@homarr/tasks");
|
||||
await import("@homarr/websocket");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Prevent flash of white background when color scheme is "auto" and OS prefers dark.
|
||||
// CSS media queries are evaluated synchronously during the browser's initial paint,
|
||||
// before any JavaScript executes, so this eliminates the white→dark flicker.
|
||||
html[data-mantine-color-scheme="auto"] {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html[data-mantine-color-scheme="auto"] {
|
||||
background-color: #242424;
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,11 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/main.ts"
|
||||
},
|
||||
"main": "./src/main.ts",
|
||||
"types": "./src/main.ts",
|
||||
"scripts": {
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:*.node --external:@opentelemetry/api --external:deasync --external:bcrypt --outfile=tasks.cjs",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
@@ -24,7 +23,6 @@
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^",
|
||||
"@homarr/cron-job-api": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs-core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
@@ -37,6 +35,7 @@
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"croner": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"fastify": "catalog:",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { handshakeAsync } from "@homarr/redis";
|
||||
|
||||
import { waitForRedisAsync } from "./main";
|
||||
|
||||
const { mockLogger } = vi.hoisted(() => ({
|
||||
mockLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./overrides", () => ({}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => mockLogger,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
handshakeAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/cron-jobs", () => ({
|
||||
jobGroup: {
|
||||
initializeAsync: vi.fn().mockResolvedValue(undefined),
|
||||
startAllAsync: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./on-start", () => ({
|
||||
onStartAsync: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe("waitForRedisAsync", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("succeeds on first attempt", async () => {
|
||||
vi.mocked(handshakeAsync).mockResolvedValueOnce(undefined);
|
||||
|
||||
await waitForRedisAsync();
|
||||
|
||||
expect(handshakeAsync).toHaveBeenCalledOnce();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("Redis connection established");
|
||||
});
|
||||
|
||||
test("retries on failure then succeeds", async () => {
|
||||
vi.mocked(handshakeAsync)
|
||||
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
||||
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const promise = waitForRedisAsync();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await promise;
|
||||
|
||||
expect(handshakeAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockLogger.warn).toHaveBeenCalledTimes(2);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("Redis connection established");
|
||||
});
|
||||
|
||||
test("logs error and proceeds after max retries exhausted", async () => {
|
||||
vi.mocked(handshakeAsync).mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
|
||||
const promise = waitForRedisAsync();
|
||||
|
||||
for (let index = 0; index < 10; index++) {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
}
|
||||
await promise;
|
||||
|
||||
expect(handshakeAsync).toHaveBeenCalledTimes(10);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith("Redis did not become ready after retries, proceeding anyway");
|
||||
expect(mockLogger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("warns with attempt count on each retry", async () => {
|
||||
vi.mocked(handshakeAsync).mockRejectedValueOnce(new Error("fail")).mockResolvedValueOnce(undefined);
|
||||
|
||||
const promise = waitForRedisAsync();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await promise;
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith("Redis not ready, retrying (1/10)...");
|
||||
});
|
||||
|
||||
test("does not retry after success", async () => {
|
||||
vi.mocked(handshakeAsync).mockResolvedValueOnce(undefined);
|
||||
|
||||
await waitForRedisAsync();
|
||||
|
||||
expect(handshakeAsync).toHaveBeenCalledOnce();
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+19
-37
@@ -1,52 +1,34 @@
|
||||
// This import has to be the first import in the file so that the agent is overridden before any other modules are imported.
|
||||
import "./overrides";
|
||||
|
||||
import type { FastifyTRPCPluginOptions } from "@trpc/server/adapters/fastify";
|
||||
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
|
||||
import fastify from "fastify";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { JobRouter } from "@homarr/cron-job-api";
|
||||
import { jobRouter } from "@homarr/cron-job-api";
|
||||
import { CRON_JOB_API_KEY_HEADER, CRON_JOB_API_PATH, CRON_JOB_API_PORT } from "@homarr/cron-job-api/constants";
|
||||
import { jobGroup } from "@homarr/cron-jobs";
|
||||
import { db } from "@homarr/db";
|
||||
import { handshakeAsync } from "@homarr/redis";
|
||||
|
||||
import { JobManager } from "./job-manager";
|
||||
import { onStartAsync } from "./on-start";
|
||||
|
||||
const logger = createLogger({ module: "tasksMain" });
|
||||
const logger = createLogger({ module: "tasks" });
|
||||
|
||||
const server = fastify({
|
||||
maxParamLength: 5000,
|
||||
});
|
||||
server.register(fastifyTRPCPlugin, {
|
||||
prefix: CRON_JOB_API_PATH,
|
||||
trpcOptions: {
|
||||
router: jobRouter,
|
||||
createContext: ({ req }) => ({
|
||||
manager: new JobManager(db, jobGroup),
|
||||
apiKey: req.headers[CRON_JOB_API_KEY_HEADER] as string | undefined,
|
||||
}),
|
||||
onError({ path, error }) {
|
||||
logger.error(new ErrorWithMetadata("Error in tasks tRPC handler", { path }, { cause: error }));
|
||||
},
|
||||
} satisfies FastifyTRPCPluginOptions<JobRouter>["trpcOptions"],
|
||||
});
|
||||
const MAX_RETRIES = 10;
|
||||
const RETRY_DELAY_MS = 500;
|
||||
|
||||
export const waitForRedisAsync = async () => {
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await handshakeAsync();
|
||||
logger.info("Redis connection established");
|
||||
return;
|
||||
} catch {
|
||||
logger.warn(`Redis not ready, retrying (${attempt}/${MAX_RETRIES})...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
logger.error("Redis did not become ready after retries, proceeding anyway");
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
await waitForRedisAsync();
|
||||
await onStartAsync();
|
||||
await jobGroup.initializeAsync();
|
||||
await jobGroup.startAllAsync();
|
||||
|
||||
try {
|
||||
await server.listen({ port: CRON_JOB_API_PORT });
|
||||
logger.info("Tasks web server started successfully", { port: CRON_JOB_API_PORT });
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata("Failed to start tasks web server", { port: CRON_JOB_API_PORT }, { cause: err }),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
@@ -9,5 +9,5 @@
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "*.cjs", "*.css"]
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/main.ts"
|
||||
},
|
||||
"main": "./src/main.ts",
|
||||
"types": "./src/main.ts",
|
||||
"scripts": {
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:@opentelemetry/api --external:deasync --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "*.cjs", "*.css"]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.14.1-trixie AS base
|
||||
FROM node:24.15.0-trixie AS base
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
"name": "homarr-prebuilt-debian",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.8.0"
|
||||
"better-sqlite3": "^12.10.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"engines": {
|
||||
"node": ">=24.14.1",
|
||||
"pnpm": ">=10.33.0"
|
||||
"node": ">=24.15.0",
|
||||
"pnpm": ">=10.33.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
+6
-6
@@ -9,17 +9,17 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
better-sqlite3:
|
||||
specifier: ^12.8.0
|
||||
version: 12.8.0
|
||||
specifier: ^12.10.0
|
||||
version: 12.10.0
|
||||
|
||||
packages:
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
better-sqlite3@12.8.0:
|
||||
resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==}
|
||||
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
|
||||
better-sqlite3@12.10.0:
|
||||
resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==}
|
||||
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
|
||||
|
||||
bindings@1.5.0:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
@@ -148,7 +148,7 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
better-sqlite3@12.8.0:
|
||||
better-sqlite3@12.10.0:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
prebuild-install: 7.1.3
|
||||
|
||||
@@ -372,6 +372,13 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/umami" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg" alt="Umami" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Umami</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/unifi-controller" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png" alt="Unifi Controller" width="90" height="90" />
|
||||
<br/>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { chromium } from "playwright";
|
||||
import { chromium } from "@playwright/test";
|
||||
import { GenericContainer } from "testcontainers";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chromium } from "playwright";
|
||||
import { chromium } from "@playwright/test";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type { Page } from "playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||
import type { SqliteDatabase } from "../e2e-db";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Page } from "playwright";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||
|
||||
+16
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.59.2",
|
||||
"version": "1.61.1-beta.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -15,7 +15,7 @@
|
||||
"db:migration:sqlite:run": "pnpm -F db migration:sqlite:run",
|
||||
"db:push": "pnpm -F db push:sqlite",
|
||||
"db:studio": "pnpm -F db studio",
|
||||
"dev": "turbo dev --parallel",
|
||||
"dev": "pnpm -F nextjs dev",
|
||||
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
|
||||
"format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache",
|
||||
"format:fix": "turbo format --continue -- --write --cache --cache-location node_modules/.cache/.prettiercache",
|
||||
@@ -36,6 +36,7 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"devDependencies": {
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@playwright/test": "catalog:",
|
||||
"@semantic-release/changelog": "catalog:",
|
||||
"@semantic-release/commit-analyzer": "catalog:",
|
||||
"@semantic-release/git": "catalog:",
|
||||
@@ -59,10 +60,10 @@
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"engines": {
|
||||
"node": ">=24.14.1",
|
||||
"pnpm": ">=10.33.0"
|
||||
"node": ">=24.15.0",
|
||||
"pnpm": ">=10.33.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -79,23 +80,23 @@
|
||||
"overrides": {
|
||||
"@babel/helpers@<7.26.10": ">=7.29.2",
|
||||
"@babel/runtime@<7.26.10": ">=7.29.2",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.15.0",
|
||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.5",
|
||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.5",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
|
||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
|
||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
|
||||
"esbuild@<=0.24.2": ">=0.28.0",
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
|
||||
"hono@<4.6.5": ">=4.12.12",
|
||||
"linkifyjs@<4.3.2": ">=4.3.2",
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.7",
|
||||
"hono@<4.6.5": ">=4.12.19",
|
||||
"linkifyjs@<4.3.2": ">=4.3.3",
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
"proxmox-api>undici": "7.24.6",
|
||||
"react-is": "^19.2.5",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.60.1",
|
||||
"react-is": "^19.2.6",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
|
||||
"sha.js@<=2.4.11": ">=2.4.12",
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||
"tmp@<=0.2.3": ">=0.2.5",
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.8"
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.13"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
||||
@@ -104,6 +105,7 @@
|
||||
"allowUnusedPatches": true,
|
||||
"ignoredBuiltDependencies": [
|
||||
"@scarf/scarf",
|
||||
"@swc/core",
|
||||
"core-js-pure",
|
||||
"protobufjs"
|
||||
]
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"./client": "./src/client.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./websocket": "./src/websocket.ts",
|
||||
"./shared": "./src/shared.ts"
|
||||
"./shared": "./src/shared.ts",
|
||||
"./open-api": "./src/open-api.ts"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
@@ -24,7 +25,6 @@
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-api": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
import { openApiDocument } from "./open-api";
|
||||
import type { AppRouter } from "./root";
|
||||
import { appRouter } from "./root";
|
||||
import { createCallerFactory, createTRPCContext } from "./trpc";
|
||||
@@ -30,5 +29,5 @@ type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
**/
|
||||
type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
|
||||
export { createTRPCContext, appRouter, createCaller, openApiDocument };
|
||||
export { createTRPCContext, appRouter, createCaller };
|
||||
export type { AppRouter, RouterInputs, RouterOutputs };
|
||||
@@ -2,10 +2,21 @@ import { generateOpenApiDocument } from "trpc-to-openapi";
|
||||
|
||||
import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
|
||||
|
||||
import { appRouter } from "./root";
|
||||
import { appRouter } from "./router/app";
|
||||
import { infoRouter } from "./router/info";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { userRouter } from "./router/user";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
export const openApiRouter = createTRPCRouter({
|
||||
appRouter,
|
||||
infoRouter,
|
||||
inviteRouter,
|
||||
userRouter,
|
||||
});
|
||||
|
||||
export const openApiDocument = (base: string) =>
|
||||
generateOpenApiDocument(appRouter, {
|
||||
generateOpenApiDocument(openApiRouter, {
|
||||
title: "Homarr API documentation",
|
||||
version: "1.0.0",
|
||||
baseUrl: base,
|
||||
|
||||
@@ -26,6 +26,7 @@ export const appRouter = createTRPCRouter({
|
||||
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
|
||||
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { searchDuckDuckGoBangsAsync } from "../../services/duckduckgo-bangs";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const bangsRouter = createTRPCRouter({
|
||||
search: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().int().min(1).max(50).default(20),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return await searchDuckDuckGoBangsAsync({ query: input.query, limit: input.limit });
|
||||
}),
|
||||
});
|
||||
@@ -2,59 +2,62 @@ import { observable } from "@trpc/server/observable";
|
||||
import z from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { cronExpressionSchema, jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
|
||||
import { cronJobApi } from "@homarr/cron-job-api/client";
|
||||
import type { TaskStatus } from "@homarr/cron-job-status";
|
||||
import { createCronJobStatusChannel } from "@homarr/cron-job-status";
|
||||
import { jobGroup } from "@homarr/cron-jobs";
|
||||
import { JobManager } from "@homarr/cron-jobs/job-manager";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
const jobGroupKeys = jobGroup.getKeys();
|
||||
const jobNameSchema = z.enum(jobGroup.getKeys());
|
||||
|
||||
const logger = createLogger({ module: "cronJobsRouter" });
|
||||
|
||||
export const cronJobsRouter = createTRPCRouter({
|
||||
triggerJob: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(jobNameSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cronJobApi.trigger.mutate(input);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await new JobManager(ctx.db, jobGroup).triggerAsync(input);
|
||||
}),
|
||||
startJob: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(jobNameSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cronJobApi.start.mutate(input);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await new JobManager(ctx.db, jobGroup).startAsync(input);
|
||||
}),
|
||||
stopJob: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(jobNameSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cronJobApi.stop.mutate(input);
|
||||
.mutation(({ input, ctx }) => {
|
||||
new JobManager(ctx.db, jobGroup).stop(input);
|
||||
}),
|
||||
updateJobInterval: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
name: jobNameSchema,
|
||||
cron: cronExpressionSchema,
|
||||
cron: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
await cronJobApi.updateInterval.mutate(input);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await new JobManager(ctx.db, jobGroup).updateIntervalAsync(input.name, input.cron);
|
||||
}),
|
||||
disableJob: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(jobNameSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cronJobApi.disable.mutate(input);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await new JobManager(ctx.db, jobGroup).disableAsync(input);
|
||||
}),
|
||||
enableJob: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(jobNameSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cronJobApi.enable.mutate(input);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await new JobManager(ctx.db, jobGroup).enableAsync(input);
|
||||
}),
|
||||
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
||||
return await cronJobApi.getAll.query();
|
||||
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
return await new JobManager(ctx.db, jobGroup).getAllAsync();
|
||||
}),
|
||||
subscribeToStatusUpdates: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
|
||||
return observable<TaskStatus>((emit) => {
|
||||
|
||||
@@ -138,6 +138,7 @@ const getContainerOrThrowAsync = async (id: string) => {
|
||||
interface DockerContainer {
|
||||
name: string;
|
||||
id: string;
|
||||
host: string;
|
||||
state: ContainerState;
|
||||
image: string;
|
||||
ports: Port[] | undefined;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
userChangePasswordApiSchema,
|
||||
userChangeSearchPreferencesSchema,
|
||||
userCreateSchema,
|
||||
userDdgBangsSchema,
|
||||
userEditProfileSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userInitSchema,
|
||||
@@ -110,7 +111,7 @@ export const userRouter = createTRPCRouter({
|
||||
const queryResult = (await trx.delete(schema.invites).where(inviteWhere)) as
|
||||
| QueryResult
|
||||
| MySqlRawQueryResult;
|
||||
let count = 0;
|
||||
let count: number;
|
||||
if (Array.isArray(queryResult)) {
|
||||
count = queryResult[0].affectedRows;
|
||||
} else {
|
||||
@@ -276,6 +277,7 @@ export const userRouter = createTRPCRouter({
|
||||
pingIconsEnabled: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
}),
|
||||
)
|
||||
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
||||
@@ -301,6 +303,7 @@ export const userRouter = createTRPCRouter({
|
||||
pingIconsEnabled: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
@@ -541,6 +544,33 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
changeDdgBangs: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
|
||||
.output(z.void())
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "PATCH",
|
||||
path: "/api/users/ddg-bangs",
|
||||
tags: ["users"],
|
||||
protect: true,
|
||||
deprecated: true,
|
||||
},
|
||||
})
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
ddgBangs: input.ddgBangs,
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changeFirstDayOfWeek: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
|
||||
.output(z.void())
|
||||
|
||||
@@ -15,10 +15,13 @@ export const changeSearchPreferencesInputSchema = userChangeSearchPreferencesSch
|
||||
export const changeSearchPreferencesAsync = async (
|
||||
db: Database,
|
||||
session: Session,
|
||||
input: Modify<z.infer<typeof changeSearchPreferencesInputSchema>, { openInNewTab: boolean | undefined }>,
|
||||
input: Modify<
|
||||
z.infer<typeof changeSearchPreferencesInputSchema>,
|
||||
{ openInNewTab: boolean | undefined; ddgBangsEnabled: boolean | undefined }
|
||||
>,
|
||||
) => {
|
||||
const user = session.user;
|
||||
// Only admins can change other users passwords
|
||||
// Only admins can change other users search preferences
|
||||
if (!user.permissions.includes("admin") && user.id !== input.userId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
@@ -45,6 +48,7 @@ export const changeSearchPreferencesAsync = async (
|
||||
.set({
|
||||
defaultSearchEngineId: input.defaultSearchEngineId,
|
||||
openSearchInNewTab: input.openInNewTab,
|
||||
ddgBangs: input.ddgBangsEnabled,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
@@ -12,6 +14,8 @@ import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "calendarRouter" });
|
||||
|
||||
export const calendarRouter = createTRPCRouter({
|
||||
findAllEvents: publicProcedure
|
||||
.input(
|
||||
@@ -24,7 +28,7 @@ export const calendarRouter = createTRPCRouter({
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await Promise.all(
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const { integrationIds: _integrationIds, ...handlerInput } = input;
|
||||
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
|
||||
@@ -40,6 +44,24 @@ export const calendarRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return settled.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
return [result.value];
|
||||
}
|
||||
const integration = ctx.integrations[index];
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Calendar integration request failed; skipping events for this integration",
|
||||
{
|
||||
integrationId: integration?.id,
|
||||
integrationKind: integration?.kind,
|
||||
},
|
||||
{ cause: result.reason },
|
||||
),
|
||||
);
|
||||
return [];
|
||||
});
|
||||
}),
|
||||
subscribeToEvents: publicProcedure
|
||||
.input(
|
||||
|
||||
@@ -30,4 +30,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
|
||||
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
|
||||
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
|
||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import {
|
||||
umamiActiveVisitorsRequestHandler,
|
||||
umamiEventNamesRequestHandler,
|
||||
umamiMultiEventRequestHandler,
|
||||
umamiRequestHandler,
|
||||
umamiTopPagesRequestHandler,
|
||||
umamiTopReferrersRequestHandler,
|
||||
} from "@homarr/request-handler/umami";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "umami-router" });
|
||||
|
||||
export const umamiRouter = createTRPCRouter({
|
||||
getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
|
||||
try {
|
||||
const instance = await createIntegrationAsync(ctx.integration);
|
||||
return await instance.getWebsitesAsync();
|
||||
} catch (error) {
|
||||
logger.warn(new Error("Failed to load websites", { cause: error }));
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
getVisitorStats: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
websiteId: z.string(),
|
||||
timeFrame: z.string(),
|
||||
eventName: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = umamiRequestHandler.handler(integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
eventName: input.eventName,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
visitorStats: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
getEventNames: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata("Failed to load event names", { websiteId: input.websiteId }, { cause: error }),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
getTopPages: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
websiteId: z.string(),
|
||||
timeFrame: z.string(),
|
||||
limit: z.number().int().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
limit: input.limit,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to load top pages",
|
||||
{ websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit },
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
getTopReferrers: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
websiteId: z.string(),
|
||||
timeFrame: z.string(),
|
||||
limit: z.number().int().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
limit: input.limit,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to load top referrers",
|
||||
{ websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit },
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
getMultiEventTimeSeries: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
websiteId: z.string(),
|
||||
timeFrame: z.string(),
|
||||
eventNames: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const sortedNames = [...input.eventNames].sort();
|
||||
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
eventNames: sortedNames,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to load multi-event time series",
|
||||
{ websiteId: input.websiteId, timeFrame: input.timeFrame, eventNames: JSON.stringify(input.eventNames) },
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
getActiveVisitors: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata("Failed to load active visitors", { websiteId: input.websiteId }, { cause: error }),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}),
|
||||
|
||||
subscribeActiveVisitors: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<number>((emit) => {
|
||||
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
});
|
||||
const unsubscribe = innerHandler.subscribe((count) => {
|
||||
emit.next(count);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { duckDuckGoBangsRequestHandler } from "@homarr/request-handler/duckduckgo-bangs";
|
||||
import type { DuckDuckGoBang } from "@homarr/request-handler/duckduckgo-bangs";
|
||||
|
||||
// DuckDuckGo bang keys are intentionally short:
|
||||
// - `t`: token (e.g. "yt"), `s`: display name, `u`: URL template (contains `{{{s}}}`)
|
||||
// - `d`: domain, `c`: category, `sc`: subcategory, `r`: rank (optional)
|
||||
|
||||
const normalizeBangToken = (token: string) => token.toLowerCase().trim();
|
||||
|
||||
/**
|
||||
* Binary search to find the first index where bang.t >= tokenPrefix.
|
||||
* This is O(log n) vs O(n) for findIndex, which matters because DuckDuckGo
|
||||
* has ~13,000+ bangs. Combined with the pre-sorted data, this allows
|
||||
* efficient prefix matching by finding the start position and iterating
|
||||
* only through consecutive matches.
|
||||
*/
|
||||
const lowerBound = (arr: DuckDuckGoBang[], tokenPrefix: string) => {
|
||||
let low = 0;
|
||||
let high = arr.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
const midBang = arr[mid];
|
||||
// Must use the same ordering as the source list sort (localeCompare),
|
||||
// otherwise binary search can miss tokens with symbols like "&" or "_".
|
||||
if (!midBang || midBang.t.localeCompare(tokenPrefix) >= 0) {
|
||||
high = mid;
|
||||
continue;
|
||||
}
|
||||
|
||||
low = mid + 1;
|
||||
}
|
||||
return low;
|
||||
};
|
||||
|
||||
export const searchDuckDuckGoBangsAsync = async (input: {
|
||||
query: string;
|
||||
limit: number;
|
||||
}): Promise<DuckDuckGoBang[]> => {
|
||||
const queryTokenPrefix = normalizeBangToken(input.query);
|
||||
if (!queryTokenPrefix) return [];
|
||||
|
||||
const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getCachedOrUpdatedDataAsync({});
|
||||
const startIndex = lowerBound(allBangs, queryTokenPrefix);
|
||||
const matches: DuckDuckGoBang[] = [];
|
||||
|
||||
for (let index = startIndex; index < allBangs.length; index++) {
|
||||
const bang = allBangs[index];
|
||||
if (!bang) break;
|
||||
if (!bang.t.startsWith(queryTokenPrefix)) break;
|
||||
matches.push(bang);
|
||||
if (matches.length >= input.limit) break;
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
@@ -36,7 +36,7 @@ export const createSessionAsync = async (
|
||||
...user,
|
||||
email: user.email ?? "",
|
||||
permissions: await getCurrentUserPermissionsAsync(db, user.id),
|
||||
colorScheme: "dark",
|
||||
colorScheme: "auto",
|
||||
},
|
||||
} as Session;
|
||||
};
|
||||
|
||||
@@ -41,6 +41,10 @@ export class Stopwatch {
|
||||
return dayjs().millisecond(this.startTime).fromNow(true);
|
||||
}
|
||||
|
||||
getElapsedInFormattedMilliseconds() {
|
||||
return `${this.getElapsedInMilliseconds().toFixed(0)}ms`;
|
||||
}
|
||||
|
||||
getElapsedInMilliseconds() {
|
||||
return performance.now() - this.startTime;
|
||||
}
|
||||
|
||||
@@ -5,3 +5,26 @@ export const capitalize = <T extends string>(str: T) => {
|
||||
export const isNullOrWhitespace = (value: string | null): value is null => {
|
||||
return value == null || value.trim() === "";
|
||||
};
|
||||
|
||||
export const bestMatch = <TItem extends Record<string, unknown>>(
|
||||
search: string,
|
||||
options: TItem[],
|
||||
by: (item: TItem) => string,
|
||||
) => {
|
||||
if (options.length === 0) return null;
|
||||
|
||||
const normalizedSearch = search.toLowerCase().trim();
|
||||
|
||||
return options.reduce<TItem | null>((best, current) => {
|
||||
const currentMatchIndex = by(current).toLowerCase().indexOf(normalizedSearch);
|
||||
|
||||
if (currentMatchIndex === -1) return best;
|
||||
if (best === null) return current;
|
||||
|
||||
const bestMatchIndex = by(best).toLowerCase().indexOf(normalizedSearch);
|
||||
|
||||
if (currentMatchIndex < bestMatchIndex) return current;
|
||||
if (by(current).length < by(best).length && currentMatchIndex === bestMatchIndex) return current;
|
||||
return best;
|
||||
}, null);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { capitalize } from "../string";
|
||||
import { bestMatch, capitalize } from "../string";
|
||||
|
||||
const capitalizeTestCases = [
|
||||
["hello", "Hello"],
|
||||
@@ -12,8 +12,45 @@ const capitalizeTestCases = [
|
||||
|
||||
describe("capitalize should capitalize the first letter of a string", () => {
|
||||
capitalizeTestCases.forEach(([input, expected]) => {
|
||||
it(`should capitalize ${input} to ${expected}`, () => {
|
||||
test(`should capitalize ${input} to ${expected}`, () => {
|
||||
expect(capitalize(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("bestMatch should find the best match in an array of options", () => {
|
||||
test("should find exact match for 'nginx'", () => {
|
||||
const options = [
|
||||
{ name: "my-nginx" },
|
||||
{ name: "nginx-proxy" },
|
||||
{ name: "nginx" },
|
||||
{ name: "redis" },
|
||||
{ name: "postgres" },
|
||||
];
|
||||
|
||||
const result = bestMatch("nginx", options, ({ name }) => name);
|
||||
|
||||
expect(result).toEqual({ name: "nginx" });
|
||||
});
|
||||
test("should find closest match for 'nginx' when exact match is not available", () => {
|
||||
const options = [{ name: "redis" }, { name: "nginx-proxy" }, { name: "my-nginx" }, { name: "postgres" }];
|
||||
|
||||
const result = bestMatch("nginx", options, ({ name }) => name);
|
||||
|
||||
expect(result).toEqual({ name: "nginx-proxy" });
|
||||
});
|
||||
test("should return null if no match is found", () => {
|
||||
const options = [{ name: "redis" }, { name: "postgres" }];
|
||||
|
||||
const result = bestMatch("nginx", options, ({ name }) => name);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
test("should return null if options array is empty", () => {
|
||||
const options: { name: string }[] = [];
|
||||
|
||||
const result = bestMatch("nginx", options, ({ name }) => name);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getPortFromUrl } from "../url";
|
||||
import { getPortFromUrl, isAbsoluteUrl } from "../url.js";
|
||||
|
||||
describe("getPortFromUrl", () => {
|
||||
test.each([
|
||||
@@ -38,3 +38,21 @@ describe("getPortFromUrl", () => {
|
||||
expect(act).toThrowError("Unsupported protocol: ftp:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAbsoluteUrl", () => {
|
||||
test.each([
|
||||
["http://example.com", true],
|
||||
["https://example.com", true],
|
||||
["ftp://example.com", true],
|
||||
["file:///path/to/file", true],
|
||||
["mailto:johndoe@example.com", true],
|
||||
["/relative/path", false],
|
||||
["relative/path", false],
|
||||
])("should return %s for URL: %s", (urlString, expected) => {
|
||||
// Act
|
||||
const result = isAbsoluteUrl(urlString);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -38,3 +38,9 @@ export const getPortFromUrl = (url: URL): number => {
|
||||
|
||||
throw new Error(`Unsupported protocol: ${url.protocol}`);
|
||||
};
|
||||
|
||||
const absoluteUrlRegex = /^[a-z]+:(\/\/)?/;
|
||||
|
||||
export const isAbsoluteUrl = (urlOrPath: string): boolean => {
|
||||
return absoluteUrlRegex.test(urlOrPath.toLowerCase());
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { redisEnv } from "./env";
|
||||
|
||||
const defaultRedisOptions = {
|
||||
connectionName: "homarr",
|
||||
lazyConnect: true,
|
||||
} satisfies RedisOptions;
|
||||
|
||||
export type { Redis as RedisClient } from "ioredis";
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"name": "@homarr/cron-job-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./env": "./src/env.ts",
|
||||
"./constants": "./src/constants.ts",
|
||||
"./client": "./src/client.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@trpc/client": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"@trpc/tanstack-react-query": "catalog:",
|
||||
"node-cron": "catalog:",
|
||||
"react": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-cron": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { createTRPCClient, httpLink } from "@trpc/client";
|
||||
|
||||
import type { JobRouter } from ".";
|
||||
import { CRON_JOB_API_KEY_HEADER, CRON_JOB_API_PATH, CRON_JOB_API_PORT } from "./constants";
|
||||
import { env } from "./env";
|
||||
|
||||
export const cronJobApi = createTRPCClient<JobRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
url: `${getBaseUrl()}${CRON_JOB_API_PATH}`,
|
||||
headers: {
|
||||
[CRON_JOB_API_KEY_HEADER]: env.CRON_JOB_API_KEY,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
function getBaseUrl() {
|
||||
return `http://localhost:${CRON_JOB_API_PORT}`;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export const CRON_JOB_API_PORT = 3002;
|
||||
export const CRON_JOB_API_PATH = "/trpc";
|
||||
export const CRON_JOB_API_KEY_HEADER = "cron-job-api-key";
|
||||
@@ -1,11 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { env as commonEnv } from "@homarr/common/env";
|
||||
import { createEnv } from "@homarr/core/infrastructure/env";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
CRON_JOB_API_KEY: commonEnv.NODE_ENV === "development" ? z.string().default("test") : z.string(),
|
||||
},
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { validate } from "node-cron";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { JobGroupKeys } from "@homarr/cron-jobs";
|
||||
import { jobGroup } from "@homarr/cron-jobs";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
export const jobGroupKeys = jobGroup.getKeys();
|
||||
export const jobNameSchema = z.enum(jobGroup.getKeys());
|
||||
|
||||
export interface IJobManager {
|
||||
startAsync(name: JobGroupKeys): Promise<void>;
|
||||
triggerAsync(name: JobGroupKeys): Promise<void>;
|
||||
stopAsync(name: JobGroupKeys): Promise<void>;
|
||||
updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void>;
|
||||
disableAsync(name: JobGroupKeys): Promise<void>;
|
||||
enableAsync(name: JobGroupKeys): Promise<void>;
|
||||
getAllAsync(): Promise<
|
||||
{
|
||||
name: JobGroupKeys;
|
||||
cron: string;
|
||||
preventManualExecution: boolean;
|
||||
preventCustomInterval: boolean;
|
||||
isEnabled: boolean;
|
||||
}[]
|
||||
>;
|
||||
}
|
||||
|
||||
const t = initTRPC
|
||||
.context<{
|
||||
manager: IJobManager;
|
||||
apiKey?: string;
|
||||
}>()
|
||||
.create();
|
||||
|
||||
const createTrpcRouter = t.router;
|
||||
const apiKeyProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
if (ctx.apiKey !== env.CRON_JOB_API_KEY) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Missing or invalid API key",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
apiKey: undefined, // Clear the API key after checking
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const cronExpressionSchema = z.string().refine((expression) => validate(expression), {
|
||||
error: "Invalid cron expression",
|
||||
});
|
||||
|
||||
export const jobRouter = createTrpcRouter({
|
||||
start: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
|
||||
await ctx.manager.startAsync(input);
|
||||
}),
|
||||
trigger: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
|
||||
await ctx.manager.triggerAsync(input);
|
||||
}),
|
||||
stop: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
|
||||
await ctx.manager.stopAsync(input);
|
||||
}),
|
||||
updateInterval: apiKeyProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: jobNameSchema,
|
||||
cron: cronExpressionSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await ctx.manager.updateIntervalAsync(input.name, input.cron);
|
||||
}),
|
||||
disable: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
|
||||
await ctx.manager.disableAsync(input);
|
||||
}),
|
||||
enable: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
|
||||
await ctx.manager.enableAsync(input);
|
||||
}),
|
||||
getAll: apiKeyProcedure.query(({ ctx }) => {
|
||||
return ctx.manager.getAllAsync();
|
||||
}),
|
||||
});
|
||||
|
||||
export type JobRouter = typeof jobRouter;
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -27,13 +27,12 @@
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"node-cron": "catalog:"
|
||||
"croner": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-cron": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createTask, validate } from "node-cron";
|
||||
import { Cron } from "croner";
|
||||
|
||||
import { Stopwatch } from "@homarr/common";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
@@ -83,10 +83,15 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
? defaultCronExpression
|
||||
: (configuration?.cronExpression ?? defaultCronExpression);
|
||||
|
||||
const scheduledTask = createTask(cronExpression, () => void catchingCallbackAsync(), {
|
||||
name,
|
||||
timezone: creatorOptions.timezone,
|
||||
});
|
||||
const scheduledTask = new Cron(
|
||||
cronExpression,
|
||||
{
|
||||
name,
|
||||
timezone: creatorOptions.timezone,
|
||||
paused: true,
|
||||
},
|
||||
() => void catchingCallbackAsync(),
|
||||
);
|
||||
creatorOptions.logger.logDebug("The scheduled task for cron job was created", {
|
||||
name,
|
||||
cronExpression: defaultCronExpression,
|
||||
@@ -116,6 +121,7 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
},
|
||||
preventManualExecution: options.preventManualExecution ?? false,
|
||||
preventCustomInterval: options.preventCustomInterval ?? false,
|
||||
timezone: creatorOptions.timezone,
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -132,13 +138,6 @@ export const createCronJobCreator = <TAllowedNames extends string = string>(
|
||||
defaultCronExpression: TExpression,
|
||||
options: CreateCronJobOptions = { runOnStart: false },
|
||||
) => {
|
||||
creatorOptions.logger.logDebug("Validating cron expression for cron job", {
|
||||
name,
|
||||
cronExpression: defaultCronExpression,
|
||||
});
|
||||
if (!validate(defaultCronExpression)) {
|
||||
throw new Error(`Invalid cron expression '${defaultCronExpression}' for job '${name}'`);
|
||||
}
|
||||
creatorOptions.logger.logDebug("Cron job expression for cron job is valid", {
|
||||
name,
|
||||
cronExpression: defaultCronExpression,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ScheduledTask } from "node-cron";
|
||||
import type { Cron } from "croner";
|
||||
|
||||
import { objectEntries, objectKeys } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
@@ -34,7 +34,7 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
});
|
||||
}
|
||||
|
||||
const tasks = new Map<string, ScheduledTask>();
|
||||
const tasks = new Map<string, Cron>();
|
||||
|
||||
return {
|
||||
initializeAsync: async () => {
|
||||
@@ -62,7 +62,7 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
name: job.name,
|
||||
});
|
||||
await job.onStartAsync();
|
||||
await tasks.get(name as string)?.start();
|
||||
tasks.get(name as string)?.resume();
|
||||
},
|
||||
startAllAsync: async () => {
|
||||
for (const job of jobRegistry.values()) {
|
||||
@@ -74,7 +74,7 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
name: job.name,
|
||||
});
|
||||
await job.onStartAsync();
|
||||
await tasks.get(job.name)?.start();
|
||||
tasks.get(job.name)?.resume();
|
||||
}
|
||||
},
|
||||
runManuallyAsync: async (name: keyof TJobs) => {
|
||||
@@ -87,23 +87,23 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
options.logger.logInfo("Running schedule cron job manually.", {
|
||||
name: job.name,
|
||||
});
|
||||
await tasks.get(name as string)?.execute();
|
||||
await tasks.get(name as string)?.trigger();
|
||||
},
|
||||
stopAsync: async (name: keyof TJobs) => {
|
||||
stop: (name: keyof TJobs) => {
|
||||
const job = jobRegistry.get(name as string);
|
||||
if (!job) return;
|
||||
|
||||
options.logger.logInfo("Stopping schedule of cron job.", {
|
||||
name: job.name,
|
||||
});
|
||||
await tasks.get(name as string)?.stop();
|
||||
tasks.get(name as string)?.pause();
|
||||
},
|
||||
stopAllAsync: async () => {
|
||||
stopAll: () => {
|
||||
for (const job of jobRegistry.values()) {
|
||||
options.logger.logInfo("Stopping schedule of cron job.", {
|
||||
name: job.name,
|
||||
});
|
||||
await tasks.get(job.name)?.stop();
|
||||
tasks.get(job.name)?.pause();
|
||||
}
|
||||
},
|
||||
getJobRegistry() {
|
||||
@@ -112,8 +112,8 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
getTask(name: keyof TJobs) {
|
||||
return tasks.get(name as string) ?? null;
|
||||
},
|
||||
setTask(name: keyof TJobs, task: ScheduledTask) {
|
||||
tasks.set(name as string, task);
|
||||
setTask(name: keyof TJobs, cron: Cron) {
|
||||
tasks.set(name as string, cron);
|
||||
},
|
||||
getKeys() {
|
||||
return objectKeys(jobs);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
".": "./index.ts",
|
||||
"./job-manager": "./src/job-manager.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
@@ -38,7 +39,8 @@
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"semver-parser": "catalog:"
|
||||
"semver-parser": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { networkControllerJob } from "./jobs/integrations/network-controller";
|
||||
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
|
||||
import { timetableJob } from "./jobs/integrations/timetable";
|
||||
import { tracearrJob } from "./jobs/integrations/tracearr";
|
||||
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
|
||||
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||
@@ -27,33 +28,45 @@ import { updateCheckerJob } from "./jobs/update-checker";
|
||||
import { weatherJob } from "./jobs/weather";
|
||||
import { createCronJobGroup } from "./lib";
|
||||
|
||||
export const jobGroup = createCronJobGroup({
|
||||
analytics: analyticsJob,
|
||||
iconsUpdater: iconsUpdaterJob,
|
||||
ping: pingJob,
|
||||
smartHomeEntityState: smartHomeEntityStateJob,
|
||||
mediaServer: mediaServerJob,
|
||||
mediaOrganizer: mediaOrganizerJob,
|
||||
downloads: downloadsJob,
|
||||
dnsHole: dnsHoleJob,
|
||||
mediaRequestStats: mediaRequestStatsJob,
|
||||
mediaRequestList: mediaRequestListJob,
|
||||
rssFeeds: rssFeedsJob,
|
||||
indexerManager: indexerManagerJob,
|
||||
healthMonitoring: healthMonitoringJob,
|
||||
updateChecker: updateCheckerJob,
|
||||
mediaTranscoding: mediaTranscodingJob,
|
||||
minecraftServerStatus: minecraftServerStatusJob,
|
||||
dockerContainers: dockerContainersJob,
|
||||
networkController: networkControllerJob,
|
||||
firewallCpu: firewallCpuJob,
|
||||
firewallMemory: firewallMemoryJob,
|
||||
firewallVersion: firewallVersionJob,
|
||||
firewallInterfaces: firewallInterfacesJob,
|
||||
refreshNotifications: refreshNotificationsJob,
|
||||
weather: weatherJob,
|
||||
timetable: timetableJob,
|
||||
tracearr: tracearrJob,
|
||||
});
|
||||
const getJobGroup = () => {
|
||||
return createCronJobGroup({
|
||||
analytics: analyticsJob,
|
||||
iconsUpdater: iconsUpdaterJob,
|
||||
ping: pingJob,
|
||||
smartHomeEntityState: smartHomeEntityStateJob,
|
||||
mediaServer: mediaServerJob,
|
||||
mediaOrganizer: mediaOrganizerJob,
|
||||
downloads: downloadsJob,
|
||||
dnsHole: dnsHoleJob,
|
||||
mediaRequestStats: mediaRequestStatsJob,
|
||||
mediaRequestList: mediaRequestListJob,
|
||||
rssFeeds: rssFeedsJob,
|
||||
indexerManager: indexerManagerJob,
|
||||
healthMonitoring: healthMonitoringJob,
|
||||
updateChecker: updateCheckerJob,
|
||||
mediaTranscoding: mediaTranscodingJob,
|
||||
minecraftServerStatus: minecraftServerStatusJob,
|
||||
dockerContainers: dockerContainersJob,
|
||||
networkController: networkControllerJob,
|
||||
firewallCpu: firewallCpuJob,
|
||||
firewallMemory: firewallMemoryJob,
|
||||
firewallVersion: firewallVersionJob,
|
||||
firewallInterfaces: firewallInterfacesJob,
|
||||
refreshNotifications: refreshNotificationsJob,
|
||||
weather: weatherJob,
|
||||
timetable: timetableJob,
|
||||
tracearr: tracearrJob,
|
||||
umamiActiveVisitors: umamiActiveVisitorsJob,
|
||||
});
|
||||
};
|
||||
|
||||
// Add global type augmentation for homarr
|
||||
declare global {
|
||||
var cronJobs: ReturnType<typeof getJobGroup> | undefined;
|
||||
}
|
||||
|
||||
global.cronJobs ??= getJobGroup();
|
||||
|
||||
export const jobGroup = global.cronJobs;
|
||||
|
||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||
@@ -1,7 +1,6 @@
|
||||
import { schedule, validate as validateCron } from "node-cron";
|
||||
import { Cron } from "croner";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { IJobManager } from "@homarr/cron-job-api";
|
||||
import type { jobGroup as cronJobGroup, JobGroupKeys } from "@homarr/cron-jobs";
|
||||
import type { Database, InferInsertModel } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
@@ -9,7 +8,7 @@ import { cronJobConfigurations } from "@homarr/db/schema";
|
||||
|
||||
const logger = createLogger({ module: "jobManager" });
|
||||
|
||||
export class JobManager implements IJobManager {
|
||||
export class JobManager {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private jobGroup: typeof cronJobGroup,
|
||||
@@ -21,28 +20,31 @@ export class JobManager implements IJobManager {
|
||||
public async triggerAsync(name: JobGroupKeys): Promise<void> {
|
||||
await this.jobGroup.runManuallyAsync(name);
|
||||
}
|
||||
public async stopAsync(name: JobGroupKeys): Promise<void> {
|
||||
await this.jobGroup.stopAsync(name);
|
||||
public stop(name: JobGroupKeys): void {
|
||||
this.jobGroup.stop(name);
|
||||
}
|
||||
public async updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void> {
|
||||
logger.info("Updating cron job interval", { name, expression: cron });
|
||||
const job = this.jobGroup.getJobRegistry().get(name);
|
||||
if (!job) throw new Error(`Job ${name} not found`);
|
||||
if (!validateCron(cron)) {
|
||||
throw new Error(`Invalid cron expression: ${cron}`);
|
||||
}
|
||||
if (job.preventCustomInterval && cron !== job.cronExpression) {
|
||||
throw new Error(`Custom cron expressions are not allowed for job ${name}`);
|
||||
}
|
||||
|
||||
await this.updateConfigurationAsync(name, { cronExpression: cron });
|
||||
await this.jobGroup.getTask(name)?.destroy();
|
||||
const updatedConfig = await this.updateConfigurationAsync(name, { cronExpression: cron });
|
||||
this.jobGroup.getTask(name)?.stop();
|
||||
|
||||
this.jobGroup.setTask(
|
||||
name,
|
||||
schedule(cron, () => void job.executeAsync(), {
|
||||
name,
|
||||
}),
|
||||
new Cron(
|
||||
cron,
|
||||
{
|
||||
name,
|
||||
timezone: job.timezone,
|
||||
paused: !updatedConfig.isEnabled,
|
||||
},
|
||||
() => void job.executeAsync(),
|
||||
),
|
||||
);
|
||||
logger.info("Cron job interval updated", { name, expression: cron });
|
||||
}
|
||||
@@ -52,7 +54,7 @@ export class JobManager implements IJobManager {
|
||||
if (!job) throw new Error(`Job ${name} not found`);
|
||||
|
||||
await this.updateConfigurationAsync(name, { isEnabled: false });
|
||||
await this.jobGroup.stopAsync(name);
|
||||
this.jobGroup.stop(name);
|
||||
logger.info("Cron job disabled", { name });
|
||||
}
|
||||
public async enableAsync(name: JobGroupKeys): Promise<void> {
|
||||
@@ -86,21 +88,27 @@ export class JobManager implements IJobManager {
|
||||
name,
|
||||
configuration: JSON.stringify(configuration),
|
||||
});
|
||||
return;
|
||||
return {
|
||||
name,
|
||||
cronExpression: configuration.cronExpression ?? existingConfig.cronExpression,
|
||||
isEnabled: configuration.isEnabled ?? existingConfig.isEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const job = this.jobGroup.getJobRegistry().get(name);
|
||||
if (!job) throw new Error(`Job ${name} not found`);
|
||||
|
||||
await this.db.insert(cronJobConfigurations).values({
|
||||
const updatedConfig = {
|
||||
name,
|
||||
cronExpression: configuration.cronExpression ?? job.cronExpression,
|
||||
isEnabled: configuration.isEnabled ?? true,
|
||||
});
|
||||
};
|
||||
await this.db.insert(cronJobConfigurations).values(updatedConfig);
|
||||
logger.debug("Cron job configuration updated", {
|
||||
name,
|
||||
configuration: JSON.stringify(configuration),
|
||||
});
|
||||
return updatedConfig;
|
||||
}
|
||||
|
||||
public async getAllAsync(): Promise<
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
|
||||
import type { QueryResult } from "pg";
|
||||
|
||||
import { createId, splitToNChunks, Stopwatch } from "@homarr/common";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import { db, handleTransactionsAsync, inArray, sql } from "@homarr/db";
|
||||
import { and, db, eq, handleTransactionsAsync, inArray, notInArray, or } from "@homarr/db";
|
||||
import { iconRepositories, icons } from "@homarr/db/schema";
|
||||
import { fetchIconsAsync } from "@homarr/icons";
|
||||
|
||||
@@ -26,7 +29,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
logger.info("Fetched icons from repositories", {
|
||||
repositoryCount: repositoryIconGroups.length,
|
||||
iconCount: countIcons,
|
||||
duration: stopWatch.getElapsedInHumanWords(),
|
||||
duration: stopWatch.getElapsedInFormattedMilliseconds(),
|
||||
});
|
||||
|
||||
const databaseIconRepositories = await db.query.iconRepositories.findMany({
|
||||
@@ -35,7 +38,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
},
|
||||
});
|
||||
|
||||
const skippedChecksums: `${string}.${string}`[] = [];
|
||||
const skippedChecksums = new Map<string, string[]>();
|
||||
let countDeleted = 0;
|
||||
let countInserted = 0;
|
||||
|
||||
@@ -50,6 +53,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
if (!repositoryIconGroup.success) {
|
||||
continue;
|
||||
}
|
||||
const localSkippedChecksums: string[] = [];
|
||||
|
||||
const repositoryInDb = databaseIconRepositories.find(
|
||||
(dbIconGroup) => dbIconGroup.slug === repositoryIconGroup.slug,
|
||||
@@ -66,7 +70,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
|
||||
for (const icon of repositoryIconGroup.icons) {
|
||||
if (dbIconsInRepository.some((dbIcon) => dbIcon.checksum === icon.checksum)) {
|
||||
skippedChecksums.push(`${iconRepositoryId}.${icon.checksum}`);
|
||||
localSkippedChecksums.push(icon.checksum);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -79,38 +83,28 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
});
|
||||
countInserted++;
|
||||
}
|
||||
}
|
||||
|
||||
const deadIcons = allDbIcons.filter(
|
||||
(icon) => !skippedChecksums.includes(`${icon.iconRepositoryId}.${icon.checksum}`),
|
||||
);
|
||||
skippedChecksums.set(iconRepositoryId, localSkippedChecksums);
|
||||
}
|
||||
|
||||
const deadIconRepositories = databaseIconRepositories.filter(
|
||||
(iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug),
|
||||
);
|
||||
|
||||
const deadIconsFilter = or(
|
||||
...[...skippedChecksums.entries()].map(([iconRepositoryId, checksums]) =>
|
||||
and(eq(icons.iconRepositoryId, iconRepositoryId), notInArray(icons.checksum, checksums)),
|
||||
),
|
||||
);
|
||||
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
if (newIconRepositories.length >= 1) {
|
||||
await transaction.insert(schema.iconRepositories).values(newIconRepositories);
|
||||
}
|
||||
const result = (await transaction.delete(schema.icons).where(deadIconsFilter)) as
|
||||
| MySqlRawQueryResult
|
||||
| QueryResult;
|
||||
|
||||
if (newIcons.length >= 1) {
|
||||
// We only insert 5000 icons at a time to avoid SQLite limitations
|
||||
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
|
||||
await transaction.insert(schema.icons).values(chunck);
|
||||
}
|
||||
}
|
||||
if (deadIcons.length >= 1) {
|
||||
await transaction.delete(schema.icons).where(
|
||||
inArray(
|
||||
// Combine iconRepositoryId and checksum to allow same icons on different repositories
|
||||
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
|
||||
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
countDeleted += Array.isArray(result) ? result[0].affectedRows : (result.rowCount ?? 0);
|
||||
|
||||
if (deadIconRepositories.length >= 1) {
|
||||
await transaction.delete(schema.iconRepositories).where(
|
||||
@@ -121,33 +115,22 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
);
|
||||
}
|
||||
|
||||
countDeleted += deadIcons.length;
|
||||
});
|
||||
},
|
||||
handleSync() {
|
||||
db.transaction((transaction) => {
|
||||
if (newIconRepositories.length >= 1) {
|
||||
transaction.insert(iconRepositories).values(newIconRepositories).run();
|
||||
await transaction.insert(schema.iconRepositories).values(newIconRepositories);
|
||||
}
|
||||
|
||||
if (newIcons.length >= 1) {
|
||||
// We only insert 5000 icons at a time to avoid SQLite limitations
|
||||
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
|
||||
transaction.insert(icons).values(chunck).run();
|
||||
await transaction.insert(schema.icons).values(chunck);
|
||||
}
|
||||
}
|
||||
if (deadIcons.length >= 1) {
|
||||
transaction
|
||||
.delete(icons)
|
||||
.where(
|
||||
inArray(
|
||||
// Combine iconRepositoryId and checksum to allow same icons on different repositories
|
||||
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
|
||||
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync() {
|
||||
db.transaction((transaction) => {
|
||||
const result = transaction.delete(icons).where(deadIconsFilter).run();
|
||||
countDeleted += result.changes;
|
||||
|
||||
if (deadIconRepositories.length >= 1) {
|
||||
transaction
|
||||
@@ -161,13 +144,22 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
.run();
|
||||
}
|
||||
|
||||
countDeleted += deadIcons.length;
|
||||
if (newIconRepositories.length >= 1) {
|
||||
transaction.insert(iconRepositories).values(newIconRepositories).run();
|
||||
}
|
||||
|
||||
if (newIcons.length >= 1) {
|
||||
// We only insert 5000 icons at a time to avoid SQLite limitations
|
||||
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
|
||||
transaction.insert(icons).values(chunck).run();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Updated icons in database", {
|
||||
duration: stopWatch.getElapsedInHumanWords(),
|
||||
duration: stopWatch.getElapsedInFormattedMilliseconds(),
|
||||
added: countInserted,
|
||||
deleted: countDeleted,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
import { umamiActiveVisitorsRequestHandler } from "@homarr/request-handler/umami";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const umamiActiveVisitorsJob = createCronJob("umamiActiveVisitors", EVERY_30_SECONDS).withCallback(
|
||||
createRequestIntegrationJobHandler(umamiActiveVisitorsRequestHandler.handler, {
|
||||
widgetKinds: ["umami"],
|
||||
getInput: {
|
||||
umami: (options) => (options.websiteId ? { websiteId: options.websiteId } : []),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `ddg_bangs` boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -267,6 +267,13 @@
|
||||
"when": 1775399941430,
|
||||
"tag": "0037_remove_salt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "5",
|
||||
"when": 1775400000000,
|
||||
"tag": "0038_add_user_ddg_bangs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "ddg_bangs" boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,13 @@
|
||||
"when": 1775399963041,
|
||||
"tag": "0003_remove_salt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1775400000000,
|
||||
"tag": "0004_add_user_ddg_bangs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,21 +1,35 @@
|
||||
import { createId, objectKeys } from "@homarr/common";
|
||||
import {
|
||||
createDocumentationLink,
|
||||
credentialsAdminGroup,
|
||||
everyoneGroup,
|
||||
getIntegrationDefaultUrl,
|
||||
getIntegrationName,
|
||||
integrationDefs,
|
||||
integrationKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
import type { Database } from "..";
|
||||
import { eq } from "..";
|
||||
import { getMaxGroupPositionAsync } from "../queries";
|
||||
import {
|
||||
getServerSettingByKeyAsync,
|
||||
insertServerSettingByKeyAsync,
|
||||
updateServerSettingByKeyAsync,
|
||||
} from "../queries/server-setting";
|
||||
import { groups, integrations, onboarding, searchEngines } from "../schema";
|
||||
import {
|
||||
boards,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrations,
|
||||
layouts,
|
||||
onboarding,
|
||||
searchEngines,
|
||||
sections,
|
||||
users,
|
||||
} from "../schema";
|
||||
import type { Integration } from "../schema";
|
||||
|
||||
export const seedDataAsync = async (db: Database) => {
|
||||
@@ -24,6 +38,10 @@ export const seedDataAsync = async (db: Database) => {
|
||||
await seedServerSettingsAsync(db);
|
||||
await seedDefaultSearchEnginesAsync(db);
|
||||
await seedDefaultIntegrationsAsync(db);
|
||||
|
||||
if (process.env.DEMO_MODE === "true") {
|
||||
await seedDemoUserAsync(db);
|
||||
}
|
||||
};
|
||||
|
||||
const seedEveryoneGroupAsync = async (db: Database) => {
|
||||
@@ -143,8 +161,9 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
|
||||
const defaultIntegrations = integrationKinds.reduce<Integration[]>((acc, kind) => {
|
||||
const name = getIntegrationName(kind);
|
||||
const defaultUrl = getIntegrationDefaultUrl(kind);
|
||||
const hasNoAuthOption = integrationDefs[kind].secretKinds.some((kinds) => kinds.length === 0);
|
||||
|
||||
if (defaultUrl !== undefined) {
|
||||
if (defaultUrl !== undefined && hasNoAuthOption) {
|
||||
acc.push({
|
||||
id: "new",
|
||||
name: `${name} Default`,
|
||||
@@ -189,3 +208,72 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
|
||||
|
||||
console.log(`Created ${createdCount} default integrations through seeding process`);
|
||||
};
|
||||
|
||||
const seedDemoUserAsync = async (db: Database) => {
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.name, "demo"),
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
console.log("Skipping seeding of demo user as it already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = createId();
|
||||
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: "demo",
|
||||
email: "demo@example.com",
|
||||
password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2",
|
||||
});
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(db);
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
await db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
|
||||
await db.update(onboarding).set({
|
||||
step: "finish",
|
||||
previousStep: "settings",
|
||||
});
|
||||
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "default",
|
||||
isPublic: false,
|
||||
creatorId: userId,
|
||||
});
|
||||
await db.insert(sections).values({
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
await db.insert(layouts).values({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
});
|
||||
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
|
||||
|
||||
console.log(
|
||||
"Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.",
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `ddg_bangs` integer DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user