Compare commits

...
Author SHA1 Message Date
Thomas f18401c72e Merge branch 'dev' into fix/remove-board-loading-overlay 2026-05-22 08:59:52 +02:00
ThomasandManuel dade477b32 feat(seed): add demo mode with pre-seeded user (#5739)
Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
2026-05-21 16:08:46 +02:00
Thomas 79f88dc3b8 ci: add --affected filtering and fix Turbo cache invalidation (#5740)
* ci: add --affected filtering and fix Turbo cache invalidation

- Remove globalDependencies **/.env which invalidated all caches on any .env change
- Move 40+ env vars from globalEnv to build-specific env (lint/format/typecheck don't need DB_URL etc.)
- Add per-task inputs with test file exclusions so test changes don't trigger build/typecheck
- Enable affectedUsingTaskInputs for task-level --affected detection
- Add --affected to all turbo commands in CI (lint, format, typecheck, build)
- Add fetch-depth: 0 for proper git history comparison
- Add Docker Buildx + GHA layer caching for E2E builds

* ci: add Cloudflare remote cache for Turborepo

Configure turborepo-remote-cache-cloudflare as the remote cache backend.
This commit can be reverted independently to disable remote caching.

* fix(ci): set TURBO_SCM_BASE for --affected to work in PRs

Turbo defaults to comparing against `main`, but PRs target `dev`.
Without explicit base/head refs, --affected falls back to running
all tasks. Use the PR base SHA for pull_requests, the before SHA
for pushes, and HEAD~1 as a last resort.

* perf(typecheck): exclude bundled .cjs/.css from tsc

websocket (835K lines) and tasks (755K lines) had bundled output
files being typechecked due to `include: ["."]` in their tsconfig.
Full typecheck drops from ~70s to ~26s.

* perf(turbo): scope topo task inputs to package.json only

The topo virtual task had no explicit inputs, defaulting to all files.
This caused any source change to cascade cache invalidation through
all dependent packages via ^topo. Since topo is only for dependency
ordering, its hash should only change when dependencies change.

* fix(turbo): disable remote cache signature verification

The cache lives on a private CF worker, signature verification
just adds noise without security benefit.

* fix(turbo): remove runtime env vars from build cache key

These env vars (DB_*, AUTH_*, DOCKER_*, etc.) are all runtime
configuration provided by users when running the Docker container.
They don't influence the build output since Next.js standalone mode
reads process.env at runtime and SKIP_ENV_VALIDATION=true skips
validation during build. Listing them in build.env caused unnecessary
cache busting in local dev.

Also restores coverage report comments per review feedback.

* fix(turbo): include test files in typecheck cache inputs

Test files must be typechecked — excluding them from typecheck inputs
caused changed tests to skip type validation.
2026-05-21 14:11:27 +02:00
Thomas Camlong 40e53976e5 fix(lint): suppress no-empty-function for intentional no-op in BoardReadyProvider 2026-05-18 17:00:52 +02:00
Thomas Camlong 211fd648be fix: remove unused ready-tracking state from BoardReadyProvider
markAsReady is still called by gridstack sections but now acts as a
no-op since the loading overlay that consumed isReady was removed.
2026-05-18 14:59:47 +02:00
Thomas Camlong 26702fe078 perf(boards): remove loading overlay and show board immediately
The board was hidden behind a LoadingOverlay until every section
reported ready. This delays first paint unnecessarily — the server
already prefetches widget data so the board can render on arrival.

Also removes dead duplicate exports (updateBoardName, useUpdateBoard)
that already live in @homarr/boards/updater.
2026-05-18 14:47:53 +02:00
9 changed files with 139 additions and 136 deletions

No files matched your search

+2 -1
View File
@@ -10,6 +10,7 @@ README.md
dev
.build
e2e
apps/nextjs/.next
.env
.env.*
!.env.example
!.env.example
+8
View File
@@ -41,6 +41,14 @@ 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.
+24 -5
View File
@@ -15,6 +15,13 @@ concurrency:
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
jobs:
lint:
@@ -22,6 +29,8 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
@@ -31,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: pnpm typecheck
run: pnpm turbo typecheck --affected
test:
runs-on: ubuntu-latest
@@ -78,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
@@ -90,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
@@ -105,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
@@ -1,70 +1,27 @@
"use client";
import { useCallback, useRef } from "react";
import { Box, LoadingOverlay, Stack } from "@mantine/core";
import { Box, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { useIsBoardReady } from "./_ready-context";
let boardName: string | null = null;
export const updateBoardName = (name: string | null) => {
boardName = name;
};
type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"];
export const useUpdateBoard = () => {
const utils = clientApi.useUtils();
const updateBoard = useCallback(
(updaterWithoutUndefined: UpdateCallback) => {
if (!boardName) {
throw new Error("Board name is not set");
}
utils.board.getBoardByName.setData({ name: boardName }, (previous) =>
previous ? updaterWithoutUndefined(previous) : previous,
);
},
[utils],
);
return {
updateBoard,
};
};
export const ClientBoard = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const isReady = useIsBoardReady();
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
const ref = useRef<HTMLDivElement>(null);
return (
<Box h="100%" pos="relative">
<BoardBackgroundVideo />
<LoadingOverlay
visible={!isReady}
transitionProps={{ duration: 500 }}
loaderProps={{ size: "lg" }}
h={fullHeightWithoutHeaderAndFooter}
/>
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
// Unique keys per layout to always reinitialize the gridstack
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
@@ -1,42 +1,19 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { createContext, useCallback, useContext } from "react";
const BoardReadyContext = createContext<{
isReady: boolean;
markAsReady: (id: string) => void;
} | null>(null);
export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
const pathname = usePathname();
const utils = clientApi.useUtils();
const board = useRequiredBoard();
const [readySections, setReadySections] = useState<string[]>([]);
// Reset sections required for ready state
useEffect(() => {
return () => {
setReadySections([]);
};
}, [pathname, utils]);
useEffect(() => {
setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id)));
}, [board.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => {
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
}, []);
// eslint-disable-next-line @typescript-eslint/no-empty-function
const markAsReady = useCallback((_id: string) => {}, []);
return (
<BoardReadyContext.Provider
value={{
isReady: board.sections.length === readySections.length,
markAsReady,
}}
>
@@ -54,13 +31,3 @@ export const useMarkSectionAsReady = () => {
return context.markAsReady;
};
export const useIsBoardReady = () => {
const context = useContext(BoardReadyContext);
if (!context) {
throw new Error("BoardReadyProvider is required");
}
return context.isReady;
};
+1 -1
View File
@@ -9,5 +9,5 @@
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
"exclude": ["node_modules", "*.cjs", "*.css"]
}
+1 -1
View File
@@ -9,5 +9,5 @@
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
"exclude": ["node_modules", "*.cjs", "*.css"]
}
+87 -1
View File
@@ -1,6 +1,7 @@
import { createId, objectKeys } from "@homarr/common";
import {
createDocumentationLink,
credentialsAdminGroup,
everyoneGroup,
getIntegrationDefaultUrl,
getIntegrationName,
@@ -11,12 +12,24 @@ import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server
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) => {
@@ -25,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) => {
@@ -191,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.",
);
};
+11 -46
View File
@@ -1,57 +1,19 @@
{
"$schema": "https://turborepo.org/schema.json",
"globalDependencies": ["**/.env"],
"globalEnv": [
"AUTH_LDAP_BASE",
"AUTH_LDAP_BIND_DN",
"AUTH_LDAP_BIND_PASSWORD",
"AUTH_LDAP_GROUP_CLASS",
"AUTH_LDAP_GROUP_FILTER_EXTRA_ARG",
"AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE",
"AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE",
"AUTH_LDAP_SEARCH_SCOPE",
"AUTH_LDAP_URI",
"AUTH_OIDC_CLIENT_ID",
"AUTH_OIDC_CLIENT_NAME",
"AUTH_OIDC_CLIENT_SECRET",
"AUTH_OIDC_FORCE_USERINFO",
"AUTH_OIDC_ISSUER",
"AUTH_OIDC_SCOPE_OVERWRITE",
"AUTH_OIDC_GROUPS_ATTRIBUTE",
"AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE",
"AUTH_LDAP_USERNAME_ATTRIBUTE",
"AUTH_LDAP_USER_MAIL_ATTRIBUTE",
"AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG",
"AUTH_OIDC_AUTO_LOGIN",
"AUTH_LOGOUT_REDIRECT_URL",
"AUTH_PROVIDERS",
"AUTH_SESSION_EXPIRY_TIME",
"CI",
"DISABLE_REDIS_LOGS",
"DB_URL",
"DB_HOST",
"DB_USER",
"DB_PASSWORD",
"DB_NAME",
"DB_PORT",
"DB_DRIVER",
"DOCKER_HOSTNAMES",
"DOCKER_PORTS",
"DOCKER_SOCKET_PATHS",
"NODE_ENV",
"PORT",
"LOCAL_CERTIFICATE_PATH",
"LOG_LEVEL",
"SECRET_ENCRYPTION_KEY",
"SKIP_ENV_VALIDATION"
],
"remoteCache": { "signature": false },
"globalEnv": ["CI", "NODE_ENV", "SKIP_ENV_VALIDATION"],
"ui": "stream",
"futureFlags": {
"affectedUsingTaskInputs": true
},
"tasks": {
"topo": {
"dependsOn": ["^topo"]
"dependsOn": ["^topo"],
"inputs": ["package.json"]
},
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "!**/*.test.ts", "!**/*.test.tsx", "!**/*.spec.ts", "!**/__tests__/**"],
"outputs": [".next/**", "!.next/cache/**", "next-env.d.ts", ".output/**", ".vercel/output/**"]
},
"dev": {
@@ -59,15 +21,18 @@
"cache": false
},
"format": {
"inputs": ["$TURBO_DEFAULT$"],
"outputs": ["node_modules/.cache/.prettiercache"],
"outputLogs": "new-only"
},
"lint": {
"dependsOn": ["^topo"],
"inputs": ["$TURBO_DEFAULT$"],
"outputs": ["node_modules/.cache/.eslintcache"]
},
"typecheck": {
"dependsOn": ["^topo"],
"inputs": ["$TURBO_DEFAULT$"],
"outputs": ["node_modules/.cache/tsbuildinfo.json"]
},
"clean": {