Compare commits

..
558 changed files with 27088 additions and 92222 deletions

No files matched your search

@@ -1,622 +0,0 @@
# Migrating Tables from mantine-react-table to mantine-datatable
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
## Migration Candidates
Current `mantine-react-table` consumers that could be migrated:
**Widgets (in `packages/widgets/`):**
- `docker/component.tsx` — Docker container table
- `media-server/component.tsx` — Media server sessions
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
- `tools/docker/docker-table.tsx`
- `tools/tasks/_components/tasks-table.tsx`
- `tools/api/components/api-keys.tsx`
- `users/_components/user-list.tsx`
- `users/invites/_components/invite-list.tsx`
- `tools/kubernetes/` — 8 Kubernetes resource tables
**Modals:**
- `packages/modals-collection/src/search-engines/request-media-modal.tsx`
**Shared hook to remove once all migrated:**
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
---
## 1. Core Migration: mantine-react-table → mantine-datatable
### Import changes
```typescript
// BEFORE
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { MRT_ColumnDef } from "mantine-react-table";
// AFTER
import { DataTable, useDataTableColumns } from "mantine-datatable";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
```
### Column definition shape
```typescript
// BEFORE (MRT)
const columns: MRT_ColumnDef<MyRow>[] = [
{
accessorKey: "name",
header: "Name",
size: 200,
enableSorting: true,
Cell: ({ row }) => <Text>{row.original.name}</Text>,
},
];
// AFTER (mantine-datatable)
const columns: DataTableColumn<MyRow>[] = [
{
accessor: "name", // not accessorKey
title: "Name", // not header
width: 200, // not size
sortable: true, // not enableSorting
render: (record) => <Text>{record.name}</Text>, // not Cell
},
];
```
Key differences:
- `accessorKey` → `accessor`
- `header` → `title`
- `size` → `width`
- `Cell: ({ row }) =>` → `render: (record) =>`
- `enableSorting` → `sortable`
- `enableColumnActions` → does not exist (no column action menus)
- `mantineTableBodyCellProps` → `cellsStyle` in `defaultColumnProps`
### Table component
```typescript
// BEFORE
const table = useTranslatedMantineReactTable({ columns, data, ... });
return <MantineReactTable table={table} />;
// AFTER
return (
<DataTable
records={data}
columns={columns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
...
/>
);
```
### Sorting is manual
mantine-datatable does NOT sort for you. You must sort the data yourself:
```typescript
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<MyRow>>({
columnAccessor: "name",
direction: "asc",
});
const sortedData = useMemo(() => {
const mult = sortStatus.direction === "desc" ? -1 : 1;
return [...data].toSorted((a, b) => {
const aVal = a[sortStatus.columnAccessor];
const bVal = b[sortStatus.columnAccessor];
if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult;
return ((aVal as number) - (bVal as number)) * mult;
});
}, [data, sortStatus]);
// Pass sortedData to records, not data
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
```
---
## 2. Making Tables Transparent (Widget Context)
Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque.
Create a CSS file alongside the component:
```css
/* styles.css */
.my-table,
.my-table .mantine-datatable-table,
.my-table .mantine-datatable-table thead,
.my-table .mantine-datatable-table tbody,
.my-table .mantine-datatable-table tfoot,
.my-table th,
.my-table td {
background-color: transparent !important;
}
.my-table .mantine-datatable-table tr {
background-color: transparent;
}
/* Hover: translucent instead of opaque */
.my-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
/* Headers: frosted glass effect */
.my-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
/* Cell overflow */
.my-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hide sort icon until hover */
.my-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.my-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
/* Row expansion area */
.my-table .mantine-datatable-row-expansion-cell {
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
}
```
Import it: `import "./styles.css";` and apply via `className="my-table"`.
---
## 3. Responsive Column Visibility
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
```typescript
interface SizeConfig {
fontSize: "xs" | "sm";
iconSize: number;
cellPadding: number;
showSpeedColumns: boolean;
showTimeColumn: boolean;
showStateColumn: boolean;
}
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
{ maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } },
{ maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } },
];
const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true };
function getSizeConfig(width: number): SizeConfig {
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
if (width < maxWidth) return config;
}
return DEFAULT_SIZE_CONFIG;
}
// Usage: memoize it
const size = useMemo(() => getSizeConfig(width), [width]);
```
Then use a visibility check lookup table for columns:
```typescript
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
downSpeed: (ctx) => ctx.size.showSpeedColumns,
time: (ctx) => ctx.size.showTimeColumn,
state: (ctx) => ctx.size.showStateColumn,
};
function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
if (!ctx.optionsColumnSet.has(accessor)) return false;
const check = columnVisibilityChecks[accessor];
if (check && !check(ctx)) return false;
return true;
}
```
---
## 4. Column Persistence (Drag/Resize → Server)
`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
### Hidden widget options
In `index.ts`, add hidden text options:
```typescript
columnOrder: factory.text({ defaultValue: "" }),
columnWidths: factory.text({ defaultValue: "" }),
```
With visibility override:
```typescript
{
columnOrder: { shouldHide: () => true },
columnWidths: { shouldHide: () => true },
}
```
### Translation keys required
Even hidden options need translation keys or the translation test fails:
```json
"widget.mywidget.option.columnOrder.label": "Column order",
"widget.mywidget.option.columnWidths.label": "Column widths"
```
### Old-import mappings
Add to `packages/old-import/src/widgets/options.ts`:
```typescript
columnOrder: () => undefined,
columnWidths: () => undefined,
```
### Dynamic storeKey
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
```typescript
const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
```
### Hydration from server → localStorage
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
```typescript
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns<MyRow>({ key: storeKey, columns });
const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
useEffect(() => {
if (lastStoreKey.current !== storeKey) {
lastStoreKey.current = storeKey;
hydrated.current = false;
}
if (hydrated.current) return;
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths(
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })),
);
}
requestAnimationFrame(() => { hydrated.current = true; });
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
```
### Persist localStorage → server
Watch for changes and write back:
```typescript
const prevOrder = useRef(columnsOrder);
const prevWidths = useRef(columnsWidth);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (widthsChanged) {
const widthMap: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const w = entry[key as keyof typeof entry];
if (typeof w === "number") widthMap[key] = w;
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
}
persistOption({ columnWidths: JSON.stringify(widthMap) });
}
}, [columnsOrder, columnsWidth, persistOption]);
```
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
---
## 5. Context Menu (Right-Click)
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
```typescript
interface ContextMenuState {
x: number;
y: number;
item: MyRow;
}
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const handleContextMenu = useCallback(
({ record, event }: { record: MyRow; event: React.MouseEvent }) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, item: record });
},
[],
);
// On DataTable:
<DataTable onRowContextMenu={handleContextMenu} />
// Render:
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} />}
```
The menu component uses `Portal` for correct z-index:
```tsx
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) {
return (
<Portal>
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}>
<Menu.Target>
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} />
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
</Menu.Dropdown>
</Menu>
</Portal>
);
}
```
**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`.
---
## 6. Row Expansion
```typescript
<DataTable
rowExpansion={{
trigger: "click",
allowMultiple: true,
collapseProps: {
transitionDuration: 200,
animateOpacity: true,
transitionTimingFunction: "ease-out",
},
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
}}
/>
```
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
---
## 7. Common Pitfalls & Fixes
### Status indicator dots
Don't use `<Badge variant="dot" />` without children — it renders a pill with empty space. Use a plain `Box`:
```tsx
<Box
w={8}
h={8}
style={{
borderRadius: "50%",
backgroundColor: `var(--mantine-color-${color}-filled)`,
flexShrink: 0,
}}
/>
```
### Tooltip overflow on long text columns
When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix:
```tsx
<Tooltip position="bottom-start" ...>
<Group style={{ overflow: "hidden" }}>
...
</Group>
</Tooltip>
```
Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally.
### Dark mode tooltips
Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets.
### Division by zero
Guard all ratio calculations:
```typescript
if (item.size > 0) ratio = item.sent / item.size;
if (totalDown > 0) ratio = totalUp / totalDown;
```
### Composite idAccessor
When rows can come from multiple integrations, IDs may collide. Use a composite key:
```typescript
idAccessor={(record) => `${record.integration.id}:${record.id}`}
```
### JSON option parsing
Always wrap in try/catch:
```typescript
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
if (!value) return fallback;
try { return JSON.parse(value) as T; }
catch { return fallback; }
}
```
### Clipboard API
`navigator.clipboard.writeText()` can throw in non-HTTPS contexts. Always catch:
```typescript
void navigator.clipboard.writeText(text).catch(() => {});
```
### Sync sortStatus with widget options
If the widget has configurable default sort, sync it:
```typescript
useEffect(() => {
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
}, [options.defaultSort, defaultSortDirection]);
```
---
## 8. Accessibility
- All `ActionIcon` components need `aria-label`
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
---
## 9. Error Handling
- Every mutation needs an `onError` callback with `showErrorNotification`
- The `saveItemOptions` mutation (column persistence) also needs `onError`
- Check `isError` on the main query and show a fallback UI
- Use a shared `mutationOptions` object to avoid repeating `onSettled`/`onError`:
```typescript
const mutationOptions = {
onSettled: () => void utils.myQuery.invalidate(),
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
};
```
---
## 10. i18n
- No hardcoded strings. Every user-visible string goes through `useScopedI18n`
- Column titles: `t("items.{accessor}.columnTitle")`
- Detail labels: `t("items.{accessor}.detailsTitle")`
- States: `t("states.{state}")`
- Actions: `t("actions.{action}")`
- Errors: `t("errors.{errorKey}")`
- Hidden options still need `.label` translation keys
---
## 11. Edit Mode
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
```typescript
let tablePointerEvents: React.CSSProperties["pointerEvents"];
if (isEditMode) tablePointerEvents = "none";
let rowContextMenuHandler = handleContextMenu;
if (isEditMode) rowContextMenuHandler = undefined;
<DataTable style={{ pointerEvents: tablePointerEvents }} />
```
---
## 12. DataTable Props Reference (Commonly Used)
```typescript
<DataTable
records={sortedData}
columns={effectiveColumns}
storeColumnsKey={storeKey}
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
fetching={isFetching && data.length === 0}
loaderBackgroundBlur={2}
fz={size.fontSize}
textSelectionDisabled
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("errors.noItems")}
idAccessor={(record) => record.uniqueKey}
height="100%"
className="my-table"
defaultColumnProps={{
noWrap: true,
draggable: true,
resizable: true,
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
rowBackgroundColor={(record) => conditionalBgMap[record.state]}
onRowContextMenu={handleContextMenu}
rowExpansion={{ ... }}
onScroll={() => { if (contextMenu) closeContextMenu(); }}
/>
```
---
## 13. Layout Stability
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
- Give non-name columns explicit `width` values so only the name column flexes
- Use `ellipsis: true` on the name column — it handles `text-overflow: ellipsis` internally
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
---
## 14. Checklist for Each Migration
1. Replace imports (`mantine-react-table` → `mantine-datatable`)
2. Convert column definitions (`accessorKey` → `accessor`, `header` → `title`, `Cell` → `render`, etc.)
3. Implement manual sorting with `useState` + `useMemo`
4. Add CSS file for transparent backgrounds (if widget context)
5. Add responsive column visibility (if widget context)
6. Add hidden options + hydration logic (if column persistence needed)
7. Add old-import mappings for new options
8. Add translation keys (including hidden option labels)
9. Add context menu if row actions exist
10. Add row expansion if detail view exists
11. Add error handling on all mutations
12. Add accessibility attributes
13. Test at multiple widget widths
14. Update docs in `apps/docs/docs/widgets/` if applicable
15. Run `pnpm turbo typecheck` — catches old-import mismatches
16. Run translation spec — catches missing keys
-7
View File
@@ -6,14 +6,7 @@ npm-debug.log
README.md
.next
.turbo
.pnpm-store
**/.pnpm-store
coverage
**/coverage
**/.docusaurus
**/build
.git
.github
dev
.build
e2e
-4
View File
@@ -33,10 +33,6 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.73.0
- 1.72.0
- 1.71.0
- 1.70.0
- 1.69.2
- 1.69.0
- 1.68.0
-2
View File
@@ -1,2 +0,0 @@
.secrets
!.secrets.example
-4
View File
@@ -1,4 +0,0 @@
TURBO_API=
TURBO_TEAM=
TURBO_TOKEN=
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
-36
View File
@@ -1,36 +0,0 @@
# Running CI locally
Install [Act](https://nektosact.com/installation/index.html) and ensure Docker is running, then use:
```bash
pnpm ci:act:fast
pnpm ci:act:docker
pnpm ci:act:all
pnpm ci:act:cleanup
```
`fast` runs the quality gate. `docker` and `all` run the gate followed by the single container build, E2E suite, and memory regression test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
Remote caching is optional locally. Copy `.github/act/.secrets.example` to `.github/act/.secrets`, populate it with rotated credentials, and run with:
```bash
ACT_SECRET_FILE=.github/act/.secrets pnpm ci:act:all
```
Act never publishes or deletes GHCR images. The secret file is ignored by Git.
## PR preview images
Successful non-draft pull requests from this repository publish an amd64 image:
```bash
docker pull ghcr.io/homarr-labs/homarr-test:pr-<number>
```
The tag is replaced when the pull request is updated and deleted when it closes. Production Homarr images remain the supported multi-architecture releases. The `homarr-test` package must be configured as public in GHCR for unauthenticated testers.
## Dokploy preview cache
Keep `needs-demo` as the Dokploy preview label. Under the application's **Preview Build-time Secrets**, configure `TURBO_API`, `TURBO_TEAM`, `TURBO_TOKEN`, and `TURBO_REMOTE_CACHE_SIGNATURE_KEY`. Dokploy passes these to the Dockerfile as BuildKit secrets. Do not configure the token or signature key as Docker build arguments.
Rotate any credentials that have appeared in chat, logs, or committed configuration before enabling the cache.
-8
View File
@@ -11,11 +11,3 @@
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
**When contributing a new integration or widget:**
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
tests (e.g. what happens if it is offline)
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
provide support and maintain the integration (e.g. in case of breaking or failure)
+2 -2
View File
@@ -1,6 +1,6 @@
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["config:recommended", ":dependencyDashboardApproval"],
extends: ["config:recommended"],
packageRules: [
{
matchPackageNames: ["^@homarr/"],
@@ -47,7 +47,7 @@
rangeStrategy: "bump",
automerge: false,
baseBranchPatterns: ["dev"],
dependencyDashboard: true,
dependencyDashboard: false,
// prevent special characters in branch names (otherwise git errors occur on windows)
branchNameStrict: true,
}
-47
View File
@@ -1,47 +0,0 @@
name: Auto-fix lint and format
on:
pull_request:
types: [opened, synchronize, reopened]
branches: ["*"]
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
autofix:
name: Auto-fix
if: github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
env:
BASE_REF: ${{ github.base_ref }}
run: |
git diff --name-only --diff-filter=ACMR "origin/${BASE_REF}...HEAD" -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "style: auto-fix lint and format"
-264
View File
@@ -1,264 +0,0 @@
name: CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: ["*"]
pull_request_target:
types: [closed]
branches: ["*"]
push:
branches: [main, dev, beta, next]
merge_group:
workflow_dispatch:
inputs:
operation:
description: Operation to run
required: true
default: validate
type: choice
options:
- validate
- publish
- cleanup
pr_number:
description: PR number for publish or cleanup
required: false
type: number
ref:
description: Git ref to validate or publish
required: false
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_TELEMETRY_DISABLED: 1
REGISTRY: ghcr.io
PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test
jobs:
fast-gate:
name: Fast gate
if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
TURBO_PLATFORM: linux/amd64
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Validate manual inputs
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'publish'
env:
PR_NUMBER: ${{ inputs.pr_number }}
BUILD_REF: ${{ inputs.ref }}
run: |
test -n "$PR_NUMBER" || { echo "pr_number is required for publish"; exit 1; }
test -n "$BUILD_REF" || { echo "ref is required for publish"; exit 1; }
- name: Setup
uses: ./tooling/github/setup
- name: Copy environment defaults
run: cp .env.example .env
- name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=4
- name: Validate workspace configuration
run: pnpm lint:ws
- name: Run unit tests
run: pnpm test
container:
name: Container and E2E
needs: fast-gate
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: "!env.ACT"
uses: docker/setup-buildx-action@v4
- name: Build container once
if: "!env.ACT"
uses: docker/build-push-action@v7
with:
context: .
network: host
platforms: linux/amd64
load: true
push: false
tags: |
homarr-ci
homarr-e2e
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
cache-from: type=gha,scope=homarr-linux-amd64
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
- name: Build container once with Act
if: env.ACT
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
run: |
secret_args=()
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
if [ -n "${!secret:-}" ]; then
secret_args+=(--secret "id=${secret},env=${secret}")
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: "!env.ACT"
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload E2E artifacts
if: failure() && !env.ACT
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
- name: Determine preview tag
id: preview
if: |
!env.ACT &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
)
env:
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
run: echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
if: steps.preview.outputs.tag != ''
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push preview image
if: steps.preview.outputs.tag != ''
env:
PREVIEW_TAG: ${{ steps.preview.outputs.tag }}
run: |
docker tag homarr-ci "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
docker push "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
- name: Show Act publication result
if: env.ACT
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
cleanup:
name: Clean up preview image
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
packages: write
env:
IMAGE_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
steps:
- name: Validate cleanup input
if: github.event_name == 'workflow_dispatch'
env:
PR_NUMBER: ${{ inputs.pr_number }}
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
- name: Delete GHCR package version
if: "!env.ACT"
uses: actions/github-script@v8
with:
script: |
const packageName = "homarr-test";
const org = context.repo.owner;
const tag = process.env.IMAGE_TAG;
const versions = await github.paginate(
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
{ package_type: "container", package_name: packageName, org, per_page: 100 },
);
const target = versions.find((version) =>
version.metadata?.container?.tags?.includes(tag),
);
if (!target) {
core.info(`No package version found with tag "${tag}"`);
return;
}
await github.rest.packages.deletePackageVersionForOrg({
package_type: "container",
package_name: packageName,
org,
package_version_id: target.id,
});
core.info(`Deleted package version ${target.id} (${tag})`);
- name: Show Act cleanup result
if: env.ACT
run: echo "Act validated cleanup for ${IMAGE_TAG}; external GHCR writes are disabled locally."
+165
View File
@@ -0,0 +1,165 @@
name: "[Quality] Code Analysis"
permissions:
contents: read
on:
pull_request:
branches: ["*"]
push:
branches: ["main"]
merge_group:
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
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-format-autofix:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
run: |
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "style: auto-fix lint and format"
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Lint
run: pnpm turbo lint --affected && pnpm lint:ws
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Format
run: pnpm turbo format --affected --continue
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Typecheck
run: pnpm turbo typecheck --affected
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Test
run: pnpm test
- name: "Report Coverage"
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always()
uses: davelosert/vitest-coverage-report-action@v2
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- 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
with:
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
run: pnpm exec playwright install chromium
- name: Run E2E Tests
shell: bash
run: pnpm test:e2e
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm turbo build --affected
@@ -148,11 +148,6 @@ jobs:
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
env:
SKIP_ENV_VALIDATION: true
build-arm64:
@@ -192,11 +187,6 @@ jobs:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
env:
SKIP_ENV_VALIDATION: true
extract-asset-amd64:
-9
View File
@@ -46,8 +46,6 @@ homarr/
├── tooling/
│ ├── typescript/ # Base tsconfig
│ └── github/ # CI setup action
├── tools/
│ └── homarr-dev/ # Go CLI for local and PR Docker images
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
├── e2e/ # E2E test specs
└── Dockerfile # Multi-stage production build
@@ -70,9 +68,6 @@ When modifying user-facing code, you MUST also update the corresponding document
## Monorepo Commands
- `pnpm dev` — Next.js app only
- `pnpm dev:cli -- dev` — run the developer CLI without installing a global binary
- `pnpm db:seed` — seed default database data explicitly
- `pnpm docker:dev:up` — start the Redis development service in the background
- `pnpm dev:docs` — Docusaurus docs site only
- `pnpm turbo build` — build all packages
- `pnpm turbo build --filter=@homarr/docs` — build docs only
@@ -86,7 +81,3 @@ When modifying user-facing code, you MUST also update the corresponding document
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
- Run `pnpm dev:cli -- build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
-8
View File
@@ -1,11 +1,3 @@
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
### Bug Fixes
-23
View File
@@ -19,14 +19,6 @@ packages/integrations/src/anchor @ohmzi
apps/docs/docs/integrations/archive-team-warrior @darbyjack
packages/integrations/src/archive-team-warrior @darbyjack
# Bazarr - introduced in https://github.com/homarr-labs/homarr/pull/6156 by @Ayush7614
apps/docs/docs/integrations/bazarr @Ayush7614
apps/docs/docs/widgets/bazarr @Ayush7614
packages/api/src/router/widgets/bazarr.ts @Ayush7614
packages/integrations/src/bazarr @Ayush7614
packages/request-handler/src/bazarr.ts @Ayush7614
packages/widgets/src/bazarr @Ayush7614
# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/audiobookshelf @ajnart
packages/integrations/src/audiobookshelf @ajnart
@@ -135,13 +127,6 @@ packages/integrations/src/overseerr @manuel-rw
apps/docs/docs/integrations/paperless-ngx @ajnart
packages/integrations/src/paperless-ngx @ajnart
# PatchMon - introduced in https://github.com/homarr-labs/homarr/pull/6119 by @mrjoshuap
apps/docs/docs/integrations/patchmon @mrjoshuap
packages/integrations/src/patchmon @mrjoshuap
packages/widgets/src/patchmon @mrjoshuap
packages/api/src/router/widgets/patchmon.ts @mrjoshuap
packages/request-handler/src/patchmon.ts @mrjoshuap
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
apps/docs/docs/integrations/peanut @afonsojramos
packages/integrations/src/peanut @afonsojramos
@@ -206,14 +191,6 @@ packages/integrations/src/unraid @manuel-rw
apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart
# Traefik - by @Nerdy-Technician
apps/docs/docs/integrations/traefik @Nerdy-Technician
apps/docs/docs/widgets/traefik @Nerdy-Technician
packages/api/src/router/widgets/traefik.ts @Nerdy-Technician
packages/integrations/src/traefik @Nerdy-Technician
packages/request-handler/src/traefik.ts @Nerdy-Technician
packages/widgets/src/traefik @Nerdy-Technician
# Exempt Renovate-managed files (no owners)
package.json
**/package.json
+11 -30
View File
@@ -1,43 +1,24 @@
# syntax=docker/dockerfile:1.25
FROM node:24.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
RUN apk add --no-cache libc6-compat curl bash && apk update
RUN corepack enable pnpm
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY patches ./patches
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
# content-addressable store is populated). By temporarily switching to the
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
# which IS created by pnpm fetch. The original hoisted linker is restored
# before the install step so the final node_modules layout stays flat.
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
pnpm fetch --ignore-scripts && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
RUN apk add --no-cache libc6-compat curl bash
RUN apk update
COPY . .
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --recursive
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
RUN corepack enable pnpm && pnpm build
FROM base AS runner
WORKDIR /app
+3
View File
@@ -23,6 +23,9 @@
"react-hooks/exhaustive-deps": "warn",
"eslint/no-unused-expressions": "warn"
},
"options": {
"reportUnusedDisableDirectives": "off"
},
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
"settings": {
"react": { "version": "19" }
@@ -1,41 +0,0 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
## Browse development images
```bash
pnpm dev:cli --
```
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
## Build images
```bash
pnpm dev:cli -- build <name>
pnpm dev:cli -- build --pr <number> [name]
```
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
## Rebuild images
```bash
pnpm dev:cli -- rebuild <name>
```
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
## Run images
```bash
homarr run <name>
homarr run --pr <number>
```
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
@@ -17,9 +17,8 @@ This guide will help you set up your development environment to start contributi
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
- **Git**: You can download it from the [official website](https://git-scm.com/).
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI.
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`.
- **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/).
- **A GitHub account**: You can create one on the [official website](https://github.com/).
### Setting up the project
@@ -35,11 +34,11 @@ This guide will help you set up your development environment to start contributi
4. Install the dependencies with `pnpm install`.
5. Create `.env` from `.env.example`. In a POSIX shell, run `cp .env.example .env`; in PowerShell, run `Copy-Item .env.example .env`. Set `DB_URL` to an absolute path for your SQLite file.
5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service.
6. Start Redis in the background with `pnpm docker:dev:up` if your development workflow needs it.
6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'`
7. Create or update the database with `pnpm db:migration:sqlite:run`. This also seeds the default data. To run the seed explicitly later, use `pnpm db:seed`.
7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`.
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
@@ -50,15 +49,8 @@ This guide will help you set up your development environment to start contributi
#### During development
- `pnpm dev`: Start the development server.
- `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images.
- `pnpm dev:cli -- build <name>`: Build the current checkout as `homarr:<name>`.
- `pnpm dev:cli -- build --pr <number>`: Build a pull request locally from a temporary checkout.
- `pnpm dev:cli -- rebuild <name>`: Rebuild a local image from its recorded checkout or pull request.
- `pnpm dev:cli:install`: Optionally install the developer CLI as a `homarr` binary.
- `pnpm cli`: Run cli commands.
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
- `pnpm db:seed`: Explicitly seed the default database data.
- `pnpm docker:dev:up`: Start the Redis development service in the background.
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
- `pnpm db:studio`: Open a visual studio for the database.
- `pnpm package:new`: Create a new package in the monorepo.
@@ -71,7 +63,6 @@ This guide will help you set up your development environment to start contributi
- `pnpm typecheck`: Run the typescript type checker.
#### Building the project
- `pnpm build`: Build the project for production.
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
- `docker build -t homarr .`: Build a docker image for the project.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image.
@@ -55,9 +55,9 @@ A random secret can be generated by using the following command: `openssl rand -
| Environment Variable | Description | Possible values | Default |
| ------------------------ | ----------- | --------------- | ------- |
| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
| ``DOCKER_PORTS`` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
| ``DOCKER_SOCKET_PATHS`` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
| ``DOCKER_HOSTNAMES`` | Comma seperated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
| ``DOCKER_PORTS`` | Comma seperated list of ports to connect to | For example `2375,2376` | - |
| ``DOCKER_SOCKET_PATHS`` | Comma seperated list of paths to sockets | For example `/var/run/docker.sock,/run/user/1000/podman/podman.sock` | - |
## Database
| Environment Variable | Description | Possible values | Default |
+1 -1
View File
@@ -31,4 +31,4 @@ Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scal
### Using external icons
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
It is important that the URL directly points to the file without any advertisements or elements around it.
@@ -127,8 +127,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
<details>
<summary>
Example Setup
@@ -206,7 +204,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
| ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic |
### Permission System
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.25.0](https://img.shields.io/badge/Version-8.25.0-informational?style=flat)
![Version: 8.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.73.0](https://img.shields.io/badge/AppVersion-v1.73.0-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -259,7 +259,7 @@ httproute:
value: /
backendRefs:
- name: homarr
port: 7575
port: 8080
````
</details>
@@ -419,17 +419,17 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` |
| fullnameOverride | string | `""` | Overrides chart's fullname |
| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" |
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
| httproute.enabled | bool | `false` | Enable HTTPRoute |
| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) |
| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. |
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) |
| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression |
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.73.0"` | Overrides the image tag whose default is the chart appVersion |
| image.tag | string | `"v1.60.0"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

@@ -1,54 +0,0 @@
---
title: 'Bazarr'
description: "Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { bazarrIntegration } from '.';
import { bazarrWidget } from '@site/docs/widgets/bazarr';
<IntegrationHeader
integration={bazarrIntegration}
categories={['Subtitles', 'Media']}
/>
Bazarr complements Sonarr and Radarr by managing subtitle downloads. The Homarr integration uses your Bazarr API key to fetch missing subtitle counts and health badges.
### Screenshots
import apiKeySettings from './img/api-key-settings.png';
import widgetOverview from '@site/docs/widgets/bazarr/img/widget-overview.png';
<div className="flex flex-col gap-4">
<div>
<p>API key location in Bazarr (<strong>Settings → General → Security</strong>):</p>
<img src={apiKeySettings} alt="bazarr-api-key-settings" width={128 * 4} />
</div>
<div>
<p>Bazarr widget on a Homarr board:</p>
<img src={widgetOverview} alt="bazarr-widget-on-board" width={128 * 4} />
</div>
</div>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{ widget: bazarrWidget }]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
You can find your Bazarr API key under **Settings → General → Security**.
<IntegrationSecrets secrets={[{
credentials: ['apiKey'],
steps: [
"Open Bazarr and go to Settings → General → Security.",
"Copy the API key and paste it into Homarr when creating the integration.",
],
}]} />
@@ -1,9 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const bazarrIntegration: IntegrationDefinition = {
name: "Bazarr",
description:
"Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles for your media library.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
path: "../../integrations/bazarr",
};
@@ -55,74 +55,6 @@ services:
With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`.
In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock`
### Using Podman
On Linux, Homarr can connect to Podman's [Docker-compatible API](https://docs.podman.io/en/latest/markdown/podman-system-service.1.html).
The Podman API socket must be running and mounted into the Homarr container.
The socket grants full access to Podman, including arbitrary code execution as the user running the API, so only mount it into trusted containers.
For rootless Podman, enable the socket for the user that runs Homarr:
```bash
systemctl --user enable --now podman.socket
```
The rootless socket is located at `$XDG_RUNTIME_DIR/podman/podman.sock` (usually `/run/user/<uid>/podman/podman.sock`).
You can confirm the path with:
```bash
podman info --format '{{.Host.RemoteSocket.Path}}'
```
Mount that socket into the Homarr container and point `DOCKER_SOCKET_PATHS` to its path **inside** the container:
```yaml
services:
homarr:
container_name: homarr
image: ghcr.io/homarr-labs/homarr:latest
restart: unless-stopped
volumes:
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
- ./homarr/appdata:/appdata
environment:
SECRET_ENCRYPTION_KEY: your_64_character_hex_string
DOCKER_SOCKET_PATHS: /var/run/podman.sock
ports:
- "7575:7575"
```
Start Homarr with `podman compose up -d`.
Run the compose project as the same user that owns the rootless Podman socket so `$XDG_RUNTIME_DIR` resolves to the correct path.
To keep the user socket available after logout and across reboots, enable lingering with `loginctl enable-linger <user>`.
For rootful Podman, enable the system socket with `sudo systemctl enable --now podman.socket`.
Its default path is `/run/podman/podman.sock`; mount that path instead and start Homarr with `sudo podman compose up -d`.
:::note SELinux
On systems with SELinux enabled, Podman's documentation recommends disabling label separation for a container that accesses the API socket.
Add the following to the Homarr service:
```yaml
security_opt:
- label=disable
```
:::
You can connect Homarr to multiple Docker-compatible sockets by mounting each socket and listing their in-container paths, separated by commas:
```yaml
services:
homarr:
image: ghcr.io/homarr-labs/homarr:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
environment:
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
```
### Managing your Docker containers within Homarr
Homarr allows you to interact with Docker containers running on your system.
Each container row has an action menu with the following options:
+2 -1
View File
@@ -50,11 +50,12 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
<code>user.read</code>,{" "}
<code>user.update</code>,{" "}
<code>asset.read</code>,{" "}
<code>asset.view</code>,{" "}
<code>asset.download</code>,{" "}
<code>asset.statistics</code>,{" "}
<code>server.statistics</code>,{" "}
<code>server.about</code>.
<br />
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
</span>
),
@@ -10,21 +10,19 @@ import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { navidromeIntegration } from '.';
import { audioStatsWidget } from '@site/docs/widgets/audio-stats';
import { mediaServerWidget } from '@site/docs/widgets/media-server';
<IntegrationHeader
integration={navidromeIntegration}
categories={['Media', 'Media server']}
categories={['Media']}
/>
Navidrome provides music library statistics including artist, album, and song counts, as well as now playing audio streams via the Subsonic API.
Navidrome provides music library statistics including artist, album, and song counts, as well as now playing information via the Subsonic API.
### Widgets & Capabilities
<IntegrationCapabilites
items={[
{ widget: audioStatsWidget },
{ widget: mediaServerWidget },
]}
/>
@@ -1,43 +0,0 @@
---
title: 'PatchMon'
description: "PatchMon is a patch management and monitoring solution for Linux hosts."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { patchmonIntegration } from '.';
import { patchmonWidget } from '@site/docs/widgets/patchmon';
<IntegrationHeader
integration={patchmonIntegration}
categories={['Health Monitoring']}
/>
PatchMon provides host and package update statistics including total hosts, hosts needing updates, and security update counts.
The PatchMon widget requires users to be **signed in to Homarr**; see the [PatchMon widget documentation](/docs/widgets/patchmon#authentication) for details on authentication and public boards.
### Widgets & Capabilities
<IntegrationCapabilites
items={[
{ widget: patchmonWidget },
]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: ['patchmonApiKey', 'patchmonApiSecret'],
steps: [
'Open your PatchMon instance',
'Navigate to Settings and create a new GetHomepage API key',
'Copy the API key and secret from the success dialog',
'Paste them into Homarr — Basic authentication is handled automatically',
]
}]} />
@@ -1,9 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const patchmonIntegration: IntegrationDefinition = {
name: "PatchMon",
description:
"PatchMon is a patch management and monitoring solution for Linux hosts, tracking package updates and security patches across your infrastructure.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/patchmon.svg",
path: "../../integrations/patchmon",
};
@@ -1,57 +0,0 @@
---
title: "Traefik"
description: "Cloud-native reverse proxy and load balancer"
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { traefikIntegration } from ".";
import { traefikWidget } from "../../widgets/traefik";
<IntegrationHeader integration={traefikIntegration} categories={["Reverse proxy", "Monitoring"]} />
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: traefikWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the URL where Traefik exposes its dashboard/API, for example `http://traefik:8080` or `https://traefik.example.com`. Homarr calls Traefik endpoints under `/api` automatically.
:::
:::warning API requirement
Traefik must have the API enabled. For local or internal setups this is commonly exposed through `api.insecure=true`; for production setups, expose the `api@internal` service through an authenticated router.
:::
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [
"Choose this option when the Traefik API is only reachable from a trusted internal network and does not require authentication.",
],
},
{
credentials: ["username", "password"],
steps: [
"Use this option when your Traefik dashboard route is protected by Basic Auth.",
"Enter the same username and password required to open the Traefik dashboard.",
],
},
{
credentials: ["apiKey"],
steps: [
"Use this option when your Traefik dashboard route accepts a bearer token.",
"Paste the token into the API Key field in Homarr.",
],
},
]}
/>
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const traefikIntegration: IntegrationDefinition = {
name: "Traefik",
description: "Cloud-native reverse proxy and load balancer",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg",
path: "../../integrations/traefik",
};
+1 -13
View File
@@ -70,16 +70,4 @@ API key authentication enables programmatic access for:
- Automation scripts and CI/CD pipelines
- Third-party integrations
- Custom dashboards and monitoring tools
- Container orchestration systems that need to sync app definitions
## Board Automation
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
- Create, duplicate, rename and delete boards
- Change board visibility
- Update board visual and behavior settings
- Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
- Container orchestration systems that need to sync app definitions
+6 -9
View File
@@ -7,8 +7,8 @@ tags:
- Ping
---
Apps are the building blocks of your Homarr board.
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
Apps are the building blocks of your Homarr board.
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
Apps are used in the bookmark and app widget.
## Create an app
@@ -27,8 +27,8 @@ The name must be at least one character long and can be chosen freely.
### Icon URL
This is the URL to the icon that will be displayed on the app widget.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
This is the URL to the icon that will be displayed on the app widget.
It can be a url to an image in the internet, one from the included icon repositories or a local file uploaded through the upload button.
![Icon URL](./img/app-icon-url.png)
@@ -40,17 +40,14 @@ An optional description that can be shown as tooltip on the app widget.
The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL.
You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons.
### Ping URL
When you want to use another url for the simple status check you can set it here.
When you want to use another url for the simple status check you can set it here.
This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget.
It's useful when you want to ping the service internally for example with a custom ip but open the website externally.
Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings.
## List of apps
On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them.
![Apps list](./img/app-list.png)
![Apps list](./img/app-list.png)
-247
View File
@@ -1,247 +0,0 @@
---
tags:
- Assistant
- AI
- Administration
- Security
---
# Assistant
Administrators can configure the instance-wide AI assistant from **Management → Assistant**.
It is available to signed-in users from the user menu, with `Shift + A`, and from the
`Cmd/Ctrl + K` command menu. **Ask Homarr Assistant** is always available as a local result in the default
search. Selecting it with an existing query sends that query in the background; selecting it without a
query turns the command-menu search field into a prompt composer. Press
`Enter` to send the prompt in the background without opening the conversation. The search field receives
focus whenever the command menu opens, including when it is opened by an assistant tool. Sending from the
command menu preserves any draft in the expanded conversation. The local result is temporarily disabled while
the assistant is responding or waiting for an approval.
While a response is running, a compact activity bar appears at the bottom of the page and the user-menu
avatar shows a thinking indicator. The activity bar can be opened to follow the response. Completed
responses add an unread count to the user menu until they are opened or dismissed. The expanded
conversation has a minimize action that returns it to the activity bar without interrupting a response.
The transcript follows streamed output while it is already at the bottom, including fast responses. Scrolling
up releases that follow state until **Scroll to latest** is selected or the user returns to the bottom.
`Shift + A` opens the compact conversation panel directly. The shortcut is ignored while typing in text
fields, selectors, and editable content.
The **Assistant** board widget embeds the same persisted conversation and full interaction surface directly
on a dashboard. By default it follows the current conversation. Widget settings can instead pin a specific
conversation; the conversation picker stays hidden until **Pinned conversation** is selected.
When the assistant is unavailable, its command-menu result remains visible but disabled and explains that
an administrator must configure and enable it.
## Configure a model provider
Homarr includes presets for OpenRouter, OpenAI, Anthropic Claude, Google Gemini, xAI, Groq, Mistral AI,
DeepSeek, Together AI, Ollama, and LM Studio. Provider icons make the presets easy to identify. A custom
preset connects any other service that implements the OpenAI Chat Completions API.
The provider list also shows **Homarr — Coming soon** as a disabled option. The future service will be a
Homarr-hosted OpenRouter proxy with the same model discovery, tool-calling, and server-tool features. Keeping
it visible reserves a clear place for that service without making an unavailable backend selectable today.
The management page presents setup as three compact sections: provider and endpoint, credentials, then
model and availability. A status summary at the top shows whether the connection is ready, whether a key
is protected, which model is selected, and whether users can open the assistant.
1. Choose a provider preset or **Custom endpoint**, then confirm the API base URL. It usually ends in `/v1`.
2. Add an API key if the service requires bearer authentication. Additional authentication or routing
headers are available in the collapsed advanced section.
3. Save the provider connection.
4. Refresh the model list and select a discovered model. Homarr displays the friendly model name but
stores and sends the provider's exact model ID.
5. Enable the assistant and save the configuration.
The model discovery path defaults to `/models` and is appended to the API base URL. Leave it blank when
the service does not expose model discovery. A manual model ID can still be used when discovery is
disabled, unavailable, or returns no models.
API keys and additional header values are encrypted in the Homarr database. They are decrypted only on
the server for discovery and model requests, and they are never returned to the browser. Changing the
provider or base URL clears existing credentials unless replacements are submitted with the change. This
prevents credentials for one service from being sent to another endpoint.
When a key is already stored, the page shows its protected state instead of an ambiguous empty password
field. **Replace key** starts a two-step flow: enter the new key, then review the provider, destination,
and impact before confirming. The existing key remains active until confirmation, and its value cannot be
restored after replacement. Removing only the API key also requires explicit confirmation. **Remove all
credentials** uses a separate destructive confirmation because it also clears the selected model and
disables the assistant.
:::info Container networking
For a containerized Homarr installation, `localhost` refers to the Homarr container. Use a resolvable
container name, internal DNS name, or host gateway address to reach Ollama, LM Studio, or another local
model server.
:::
The configured endpoint must support streaming Chat Completions and structured tool calls for the full
assistant experience. Model discovery alone does not guarantee tool support, except when OpenRouter
explicitly advertises the model's `tools` capability.
OpenRouter web search is an opt-in option in the model section. It registers OpenRouter's model-callable
[`openrouter:web_search` server tool](https://openrouter.ai/docs/guides/features/server-tools/web-search)
alongside Homarr's tools, so OpenRouter performs each search without
exposing the stored credential to the browser. The model decides when live research is needed and can use the
grounded results and source links in responses or dashboard notes. Homarr limits each assistant request to
three searches with five results per search. OpenRouter charges web-search usage separately from model tokens.
Completed responses show a compact web-search activity card when OpenRouter reports search usage. OpenRouter
does not expose these server-side searches as ordinary client-executed tool calls, so Homarr shows the reported
search count and the safe title and URL from each returned `url_citation` annotation instead. Citation excerpts
stay in the provider response and are not persisted in the activity card.
The option is also available for custom endpoints that forward OpenRouter's server-tools request format. This
supports OpenRouter proxies, including the future Homarr Router provider. Leave it disabled for ordinary
OpenAI-compatible endpoints because the `openrouter:*` tool type is not part of the standard OpenAI API.
The Claude preset uses Anthropic's OpenAI SDK compatibility layer. Anthropic describes that layer as
suited to testing and comparison rather than as its full native API; unsupported OpenAI fields may be
ignored. Streaming responses and tool calls are supported, which are the capabilities Homarr Assistant
uses. Choose OpenRouter or another provider when you prefer a provider-neutral production endpoint.
## Conversations and tools
Conversations and messages are stored per user in the Homarr database. The assistant stays out of the
page layout until it is opened. Users can start, revisit, and delete conversations from the
conversation menu in the floating panel.
Messages support copy, edit-and-resend, response regeneration and branch navigation, Markdown export,
and helpful/not-helpful feedback.
Selecting text in a message exposes a quote action that carries the selected passage into the next prompt.
Files returned by a compatible model can be downloaded directly from the message.
Assistant responses render GitHub-flavored Markdown, including headings, links, lists, task lists, tables,
quotes, inline code, and fenced code blocks. Line breaks are preserved, and Homarr repairs common streamed
responses where a provider returns escaped line breaks or places a bold-label list on one line.
Fenced `mermaid` blocks render as diagrams in the current color scheme using Mermaid's strict security mode;
invalid diagrams fall back to their source instead of breaking the response.
When Homarr's icon search returns an image URL, visual result tables render that actual icon in a compact
image column instead of substituting an emoji. Assistant-provided Markdown images accept safe relative,
HTTP, and HTTPS sources and load without forwarding the Homarr page as the referrer.
Images up to 1 MB and text, Markdown, CSV, HTML, XML, or JSON documents up to 350 KB can be attached by
using the paperclip or dropping files onto the composer. A request can contain up to five attachments.
Image understanding depends on the selected model; document text is extracted locally by Homarr and sent
as part of the prompt.
Type `@` in the composer to add permission-checked context. The nested menu contains apps, integrations,
boards, widgets, and tools available to the current user. Homarr validates every referenced ID again on
the server before adding it to the model context. Selected references appear as compact, typed pills in the
composer and sent message, while preserving assistant-ui's underlying directive syntax for the model. When the
assistant cites one of those references in its own response, Homarr renders the same typed pill and uses the
stored app or integration icon when one is available. Type `/`
for common prompt shortcuts. Both menus support keyboard navigation and keep the active result visible while
moving through long lists. The selector below the composer combines searchable, provider-grouped models with
their description, context size and image-input capability, plus the thinking-level control. Model selection is
stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled, or set
from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning setting and
still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider
streams it. The **Thinking** disclosure can be opened or closed without interrupting the response.
Each completed response includes a circular context-window meter. Select it to open request details when
the provider reports them: input, output, cached-input and reasoning tokens, used and remaining context,
time to first output, total duration, output speed, agent-step timing, and cost. OpenRouter-reported cost
is labeled as reported.
While the next response is still streaming, the meter keeps the last completed reading for the same model and
context capacity instead of briefly showing an incorrect zero. It switches to the new request when token usage
arrives; incompatible model capacities are never mixed.
OpenRouter responses request inline usage and routing metadata. When request details are opened, Homarr also
loads the server-side generation record for each model step and briefly polls while OpenRouter finishes
processing it. The details separate Homarr's end-to-end timing from OpenRouter's provider latency,
generation time, and provider throughput, and can include normalized and native token counts, moderation
latency, cache discount, fallback count, service tier, data region, BYOK status, routed provider, generation
identifier, exact cost, and upstream inference cost. Provider credentials remain server-side; the browser
can request only generation identifiers already stored on a conversation owned by the signed-in user and
signed by the Homarr server for that user and conversation. Successful generation lookups are cached
briefly so polling and multi-step requests do not repeatedly query OpenRouter for the same record.
When a provider omits cost but model discovery includes prompt and completion pricing, Homarr labels the
calculated value as estimated. Missing provider data is shown as not reported rather than inferred.
The assistant can use the same internal tool definitions as the [Homarr MCP server](./mcp).
Each tool call runs as the signed-in user, so normal Homarr permission checks still apply. Read-only
procedures can run during the agent loop. Mutating procedures require an explicit approval in the
conversation before they execute. The approval appears as a large **Approve and run** or **Deny** action;
the assistant does not require a second typed confirmation first. Tool cards expose their status, timing,
validated input, result, and error state without flooding the conversation with raw JSON by default.
Common collection and widget results are summarized inline as compact responsive cards, while the full
structured result remains available in the tool card details. Icon searches show the returned image variants,
file formats, and repositories instead of the size of the complete icon catalog.
Large tool-driven requests can use up to 32 agent steps and a larger response budget within one request, so
multi-item dashboard changes normally finish without requiring a follow-up “Continue” message. Independent
read-only lookups may be performed together, while every mutation still pauses for its normal approval.
The assistant can create a formatted dashboard note by adding a **Notebook** widget. Ask it for a note with
headings, lists, tasks, links, emphasis, tables, or images; it generates compatible rich-text content, shows
the native widget configuration form and standard mutation approval, then places the widget in the next
available dashboard position. The generated content is retained even though the Notebook content field is
normally hidden from widget settings.
Every assistant-created dashboard widget now passes through Homarr's native widget editor before it is added.
The form fills widget defaults, validates widget-specific options, and only offers compatible integrations that
the current user can use. Required integrations cannot be left empty, single-integration widgets cannot receive
multiple integrations, and the reviewed values are passed unchanged into the final approval-gated mutation.
Custom-widget tools let an administrator list, inspect, create, and update definitions. The custom-widget
form's **Design with AI** action sends a focused request into the current conversation, where the assistant
can propose the matching mutation and use the same approval controls. Existing stored secret values are
reported only as configured/not configured and are preserved on update unless replacements are explicitly
provided.
The shield control in the conversation header can enable automatic approvals for the current conversation.
While enabled, Homarr accepts every affirmative approval step as it arrives: protected mutations are approved,
presented app details and board changes are used, and structured questions with an explicitly categorized
**Confirm** answer choose that answer. Neutral choices such as selecting a board still wait for input because
they do not have a safe affirmative default. Automatic approvals turn off when you switch conversations, and
the shared approval tracker prevents the same call from being handled twice when both a board widget and the
floating assistant are visible.
When the assistant genuinely needs missing information or a choice, it can pause with a structured
question. Each proposed answer is visibly categorized as **Confirm**, **Decline**, or **Option**, so the effect
of automatic approval is unambiguous. Users can select one of the proposed answers or choose **Other** and
enter their own response. While a question is unanswered, its controls stay in a dedicated area immediately
above the composer instead of requiring the user to find the tool call in the transcript. The answered choice
then remains in the conversation history.
Submitting the answer resumes the same agent run automatically. The minimized activity bar shows the
question or approval that needs attention and opens directly back to the request. While an answer, app
form, or mutation approval is pending, the composer preserves its draft but does not replace the pending
tool call with a new message. If the provider stops before an interactive tool supplies complete arguments,
Homarr replaces the loading state with an explicit error and confirms that no change was made.
App creation uses Homarr's native app form inside the conversation. The form reuses the local icon picker,
including automatic matching and searches through the `icon.findIcons` procedure, before the final
`app.create` approval is requested. App fields are filled only after the complete tool input has arrived, so
streaming partial arguments cannot replace the proposed values with empty fields. The model is instructed not
to invent external icon CDN URLs.
Board appearance changes use a similar review flow. The assistant first reads the board's current settings,
including its complete custom stylesheet, and then opens an editable form with Custom CSS, Appearance,
Background, and General tabs. The form highlights how many fields were proposed, preserves settings the
assistant did not change, and returns only values that differ from the current board. Custom CSS can be
copied or restored before continuing, and Homarr warns when it contains an imported stylesheet or remote
resource URL. Continuing from the form opens the normal mutation approval before anything is saved.
Ask for changes such as “make the cards denser on my Home board,” “add a mobile CSS rule without removing
my existing styles,” or “change the board colors and disable app status checks.” The current user must have
modify permission on the selected board. The empty conversation includes an **Improve a dashboard** starter,
and `/style` prepares the same guided workflow from the composer.
Homarr also exposes a small set of browser-only actions to the assistant. These actions can navigate to
safe internal routes, refresh the current view, and open Homarr interfaces such as the command menu and media request search.
They never receive provider credentials and cannot navigate to arbitrary external URLs.
After an approved mutation completes, Homarr automatically refetches active page and dashboard data so
board items, settings, custom CSS, apps, and other visible results update without a full browser reload.
The refresh action in the conversation header performs the same safe refetch manually. It keeps the
conversation open while refreshing both client queries and server-rendered route data.
:::warning
Assistant prompts and selected tool results are sent to the configured model endpoint. Review that
service's privacy and retention terms before enabling this feature for an instance.
:::
+1 -5
View File
@@ -142,10 +142,6 @@ Change the radius of your items / categories.
This is the field where you can enter custom CSS.
The styling will only be applied on the respective board page.
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's Custom
CSS and other visual or behavior settings. The assistant reads the current stylesheet first and presents an
editable review form before requesting approval to save the change.
[See this guide on how to work with custom CSS](/docs/advanced/styling/).
### Behaviour
@@ -259,4 +255,4 @@ For the global home board you can only select boards that are public.
### Mobile home board
Same as the home board, you can also set a mobile home board.
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
@@ -50,17 +50,9 @@ To create a custom widget, navigate to the **Custom Widgets** section in the man
| **Action Button** | Clickable button executing the API request | Triggers: restart, clear cache |
| **Custom JSX** | Custom JSX template with Mantine v9 components | Full layout control |
### Design with AI
### AI-Powered JSX Generation
The **Design with AI** action is available while creating or editing a custom widget. Describe what the
widget should show and, when [Homarr Assistant](../assistant) is enabled, send the request directly from
the form. The assistant receives the widget schema, the current non-secret configuration, and the latest
test response. It can then propose and run the matching create or update tool after you approve the
mutation in the conversation.
Run **Test** first when possible so the prompt includes the endpoint's real response shape. Saved secret
values are never added to the prompt. **Copy to clipboard** remains available when Homarr Assistant is
disabled or when you prefer to use another AI service such as ChatGPT, Claude, or Gemini.
When editing or creating a custom widget with the **Custom JSX** display type, you'll find a **Copy Prompt** button. Clicking it copies a pre-formatted prompt describing your widget's name, API endpoint, and available Mantine components. Paste this into your preferred AI assistant (ChatGPT, Claude, Copilot, etc.) to generate a JSX template tailored to your data.
<details>
<summary>Example: Pokédex widget using Custom JSX</summary>
+8 -7
View File
@@ -12,10 +12,6 @@ Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) serve
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
The optional [Homarr Assistant](./assistant) uses these same tool definitions internally.
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
approval before running mutating procedures.
## Getting Started
The MCP endpoint lives under the **API** page at `Management` → `Tools` → `API` → **MCP** tab.
@@ -77,7 +73,13 @@ Replace `<your-api-key>` with your actual API key. Never commit API keys to vers
"mcpServers": {
"homarr": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp/mcp", "--header", "ApiKey:<your-api-key>"]
"args": [
"-y",
"mcp-remote",
"https://your-homarr-instance.com/api/mcp/mcp",
"--header",
"ApiKey:<your-api-key>"
]
}
}
}
@@ -129,7 +131,7 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query**
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
| `apiKeys` | List API keys | GET |
| `beszel` | Systems status, alerts, historical system metrics | GET |
| `board` | List boards, read settings/CSS, create/update/delete board | GET + POST |
| `board` | List boards, get board by ID, create/update/delete board | GET + POST |
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
| `icon` | Search icons | GET |
| `info` | Server health, version info | GET |
@@ -139,7 +141,6 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query**
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
| `healthMonitoring` | Resource usage stats | GET |
| `patchmon` | Host patch and security update statistics | GET |
| `mediaRequests` | Approve/decline media requests | GET + POST |
| `mediaServer` | Media server sessions and stats | GET |
| `smartHome` | Execute automations, device states | GET + POST |
+53 -81
View File
@@ -9,125 +9,97 @@ tags:
---
# Settings
The settings page applies options to the whole Homarr application.
Some changes need a full page reload before they take effect.
The settings page has a few options that will be applied to the entire Homarr application.
They may require a full page reload for changes to apply.
## Analytics
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API.
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
We collect this data to see what features people ACTUALLY use.
We use this data to decide where we spend our development time.
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
We do not sell the data. We do not share the data with third parties.
The data does not contain personal information and does not identify you or your installation.
We understand that you might not want Homarr to communicate with our services.
By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall.
Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`.
The server sends the data once a week and when the server starts.
Additionally, you opt in to more data collection to help us provide more useful data.
### What we collect
### Integration data
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
Homarr collects technical data about your installation:
### Widget data
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
- Your Homarr version
- Your database type (SQLite, MySQL, or PostgreSQL)
- Whether Docker or Kubernetes is enabled
- Your enabled authentication providers
- Your operating system and CPU architecture
- Your default language
- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts
- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd)
- The widget types you use and how many you have of each
Homarr does NOT collect:
- URLs
- Names
- Usernames, emails, or passwords
- Integration secrets or API keys
- Error messages or logs
- Any data that can identify you
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above.
### Disable analytics
Analytics are enabled by default. To disable analytics:
1. Open the Settings page.
2. Uncheck the "Help us improve Homarr" checkbox.
3. Reload the full page.
When analytics are disabled, Homarr does not send any data to PostHog.
You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information.
You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked.
### Users data
Enabling this optional data will send the count of users and whether you activated SSO.
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
This is disabled by default.
## Search Engine Optimization (SEO)
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index.
### No index
This setting asks crawlers to not index your instance.
This setting is enabled by default. Disable this setting to allow indexing.
This will ask crawlers to not index your Homarr instance on any indexes.
It is up to the crawler to properly respect this request.
By default, this setting is enabled. Disabling it will allow indexing.
### No follow
Crawlers follow links to discover new pages.
This setting asks crawlers to not follow links. This setting is enabled by default.
Crawler behavior can differ between search engines.
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
By default, this setting is enabled and will ask crawlers to not follow any links.
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
### No translate
Some browsers offer to translate pages.
This setting asks crawlers to not offer translation. Some browsers ignore this setting.
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
This can lead to some shifting and can be undesired in some cases. By enabling this,
you ask crawlers to never translate and never prompt users for translation.
It should be noted that some browsers will ignore this and translate regardless.
### No site links search box
This setting applies to Google only.
Google can show a search box below your domain.
This setting asks Google to not show this box. This setting is disabled by default.
This setting is exclusive to Google.
[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing".
The user can search them directly in Google.
You can ask the crawlers to not allow searches by enabling this setting.
This setting is disabled by default.
## Boards
### Global home board
You can set a global home board.
This board is the fallback for users without their own home board. It is also the fallback for anonymous users.
The board must be public.
You can set a global home board that will be used for all users that have not set a home board themselves.
It will also be used for anonymous users.
Note that the board must be public to be used as a global home board.
### Global mobile board
You can set a global mobile board.
Homarr shows this board on mobile devices instead of the global home board.
Homarr detects mobile devices with the User-Agent header.
The board must be public.
You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header).
It will similar to the global home board be as fallback for normal users and for any anonymous users.
It also has to be public.
### App status
#### Enable status by default
You can decide if app status is enabled when you add a new app.
You can decide if the app status should be enabled by default when adding a new app to the board.
#### Force disable status
You can decide if app status is disabled on all boards.
You can decide if the app status should be disabled for all boards.
###
## Appearance
### Default color scheme
You can set the default color scheme for users without their own color scheme.
You can set the default color scheme for all users that have not set a color scheme themselves.
## Culture
### Default language
You can set the default language for users without their own language.
You can set the default language for all users that have not set a language themselves.
@@ -1,57 +0,0 @@
---
title: "Assistant"
description: "Have a complete assistant conversation directly on a board."
hide_title: true
---
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { assistantWidget } from ".";
<WidgetHeader widget={assistantWidget} categories={["Productivity"]} />
The Assistant widget brings the same conversation available from the user menu and command menu onto a
board. Messages, conversations, tool calls, approvals, files, mentions, model controls, reasoning controls,
usage details, and response actions remain synchronized with the app-wide assistant.
The assistant must be enabled by an administrator in **Management → Assistant**. Signed-out users and users
on an instance without an enabled model see an unavailable state instead of a broken composer.
### Adding the widget
<AddingWidget />
The default widget size is ten columns by four rows and automatically fits boards with fewer columns. Controls
and labels condense as the widget becomes narrower, while the conversation remains scrollable and usable on
touch devices. When a board layout does not provide enough height for conversation controls, the widget shows
a compact **Open assistant** action instead of squeezing an unusable composer into the card. Increase its height
in that layout to keep the complete conversation inline.
### Configuration
<WidgetConfig configuration={assistantWidget.configuration} />
**Current conversation** follows whichever conversation is active in the user-menu panel, `Shift + A`, or
the command menu. This is the default and keeps every assistant entry point synchronized.
**Pinned conversation** reveals a searchable conversation selector. A pinned widget never changes the
active conversation silently: select **Open conversation** inside the widget before continuing that thread.
### Conversation features
- Create, switch, rename, and delete conversations.
- Send text and supported file or image attachments.
- Mention permitted apps, integrations, boards, widgets, and tools with `@`.
- Use `/` shortcuts for common dashboard tasks.
- Review tool calls, approve mutations, and answer structured assistant questions inline.
- Answer pending structured questions from the dock above the composer without scrolling through the conversation.
- Enable conversation-scoped automatic approvals from the shield control to approve protected mutations, use presented form details, and choose answers categorized as affirmative without individual clicks.
- Edit user messages and copy, regenerate, branch, quote, export, or rate responses.
- Inspect Markdown, sources, generated files, tool results, reasoning, token use, context-window use, timing,
speed, provider routing, and cost.
- Change the model and reasoning effort for the active conversation.
- Refresh the current board from the conversation header. Approved mutations also refresh visible board data automatically.
The expanded assistant and board widget use the same persisted thread runtime. A response continues in the
background when the widget leaves the viewport or the expanded panel is minimized.
-25
View File
@@ -1,25 +0,0 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconRobot } from "@tabler/icons-react";
export const assistantWidget: WidgetDefinition = {
icon: IconRobot,
name: "Assistant",
description: "Have a complete assistant conversation directly on a board.",
path: "../../widgets/assistant",
configuration: {
items: [
{
name: "Conversation",
description: "Follow the active conversation or attach the widget to one persisted conversation.",
values: { type: "select", options: ["Current conversation", "Pinned conversation"] },
defaultValue: "Current conversation",
},
{
name: "Pinned conversation",
description: "Search for a conversation. This field is hidden when Current conversation is selected.",
values: { type: "string" },
defaultValue: "",
},
],
},
};
+22 -24
View File
@@ -1,45 +1,43 @@
---
title: "Audio Stats"
description: "Displays media and listening statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration."
title: 'Audio Stats'
description: 'Displays library statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration.'
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { audioStatsWidget } from ".";
import { navidromeIntegration } from "@site/docs/integrations/navidrome";
import { audiobookshelfIntegration } from "@site/docs/integrations/audiobookshelf";
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { audioStatsWidget } from '.';
import { navidromeIntegration } from '@site/docs/integrations/navidrome';
import { audiobookshelfIntegration } from '@site/docs/integrations/audiobookshelf';
<WidgetHeader widget={audioStatsWidget} categories={["Media"]} />
<WidgetHeader
widget={audioStatsWidget}
categories={['Media']}
/>
This widget adapts its display based on the linked integration:
- **Navidrome**: Shows artist, album, and song counts
- **Navidrome**: Shows artist, album, and song counts, plus a now playing section with active streams
- **Audiobookshelf**: Shows library count, audiobook/podcast totals, listening time, and active sessions
Only options relevant to your linked integration are shown in the configuration panel.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: navidromeIntegration,
note: "Displays music library statistics",
},
{
integration: audiobookshelfIntegration,
note: "Displays audiobook/podcast library stats and listening time",
},
]}
/>
<WidgetIntegrations items={[{
integration: navidromeIntegration,
note: "Displays music library stats and now playing"
}, {
integration: audiobookshelfIntegration,
note: "Displays audiobook/podcast library stats and listening time"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={audioStatsWidget.configuration} />
@@ -27,6 +27,24 @@ export const audioStatsWidget: WidgetDefinition = {
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show now playing",
description: "Displays currently playing tracks (Navidrome only)",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show now playing list",
description: "Displays a detailed list of now playing tracks (Navidrome only)",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Max now playing items",
description: "Maximum number of now playing tracks to display (Navidrome only)",
values: "Number (1-5)",
defaultValue: "3",
},
{
name: "Show library count",
description: "Displays the number of libraries (Audiobookshelf only)",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

-41
View File
@@ -1,41 +0,0 @@
---
title: 'Bazarr'
description: 'Displays missing subtitle counts and health indicators from your Bazarr instance.'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { bazarrWidget } from '.';
import { bazarrIntegration } from '@site/docs/integrations/bazarr';
<WidgetHeader
widget={bazarrWidget}
categories={['Subtitles', 'Media']}
/>
This widget shows subtitle status from your Bazarr instance, including missing episode and movie subtitles, provider issues, and health warnings.
### Screenshots
import widgetOverview from './img/widget-overview.png';
<div className="flex flex-col gap-2">
<img src={widgetOverview} alt="bazarr-widget-overview" width={128 * 4} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: bazarrIntegration,
note: "Displays missing subtitle counts and health indicators"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={bazarrWidget.configuration} />
-37
View File
@@ -1,37 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconBadgeCc } from "@tabler/icons-react";
export const bazarrWidget: WidgetDefinition = {
icon: IconBadgeCc,
name: "Bazarr",
description: "Displays missing subtitle counts and health indicators from your Bazarr instance.",
path: "../../widgets/bazarr",
configuration: {
items: [
{
name: "Show missing episodes",
description: "Displays the number of episodes with missing subtitles",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show missing movies",
description: "Displays the number of movies with missing subtitles",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show provider issues",
description: "Displays the number of subtitle providers with issues",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show health issues",
description: "Displays the number of Bazarr health issues",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
@@ -18,9 +18,7 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
### Screenshots
@@ -1,21 +1,24 @@
---
title: "Beszel System Stats"
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
title: 'Beszel System Stats'
description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.'
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { beszelSystemStatsWidget } from ".";
import { beszelIntegration } from "@site/docs/integrations/beszel";
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { beszelSystemStatsWidget } from '.';
import { beszelIntegration } from '@site/docs/integrations/beszel';
import beszelSystemStats from "./img/beszel-system-stats.png";
import beszelSystemStats from './img/beszel-system-stats.png';
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
<WidgetHeader
widget={beszelSystemStatsWidget}
categories={['System Monitoring', 'Charts']}
/>
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days.
### Screenshots
@@ -23,19 +26,14 @@ This widget displays time-series charts for a selected Beszel system, including
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system",
},
]}
/>
<WidgetIntegrations items={[{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={beszelSystemStatsWidget.configuration} />
@@ -17,16 +17,7 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Interactions
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
- **Resize columns** by dragging a header's right edge
- **Sort systems** by selecting a sortable column header
- **Open system statistics** by selecting a row
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
### Supported Integrations
@@ -28,8 +28,6 @@ import { icalIntegration } from '@site/docs/integrations/ical';
The calendar widget shows a month view of events from different integrations.
It allows you to view calendar events or upcoming releases from your media managers.
Calendar requests use normal human month numbers (`1` for January through `12` for December), including when
the same data is requested through Homarr's MCP or Assistant tools.
### Screenshots
@@ -40,19 +40,12 @@ import dockerTable from './img/table.png';
### Interactions
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
Each container row has an action menu (accessible via the three-dot icon) with the following options:
- **View logs** - opens a dedicated terminal page streaming the container's logs
- **Start** / **Stop** / **Restart** - manage the container's state
- **Remove** - permanently remove the container after a second confirmation click
- **Remove** - remove the container
- **Add to Homarr** - create a bookmark app from the container
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={dockerContainersWidget.configuration} />
@@ -8,12 +8,6 @@ export const dockerContainersWidget: WidgetDefinition = {
path: "../../widgets/docker-containers",
configuration: {
items: [
{
name: "Columns to show",
description: "Select which columns are visible in the table",
values: "Name, State, Host, CPU usage, Memory usage, and Actions",
defaultValue: "All columns",
},
{
name: "Enable items sorting",
description: "Allows to sort containers by clicking on the column headers",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+9 -35
View File
@@ -4,7 +4,6 @@ description: 'Allows you to view and manage your Downloads from both Torrent and
hide_title: true
---
import { aria2Integration } from '@site/docs/integrations/aria2';
import { delugeIntegration } from '@site/docs/integrations/deluge';
import { nzbgetIntegration } from '@site/docs/integrations/nzbget';
import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent';
@@ -15,6 +14,9 @@ import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { IconAd } from "@tabler/icons-react";
import TabItem from '@theme/TabItem';
import Tabs from '@theme/Tabs';
import { downloadsWidget } from '.';
@@ -23,37 +25,16 @@ import { downloadsWidget } from '.';
categories={['Torrent client', 'Usenet client', 'Downloads']}
/>
This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface.
This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information.
### Screenshots
import downloadsCard from './img/screenshot.png';
import contextMenuImg from './img/context-menu.png';
<div>
<img src={downloadsCard} alt="Downloads widget showing torrent and usenet items" width={128 * 4} />
<img src={downloadsCard} alt="downloads" width={128 * 4} />
</div>
#### Right-click context menu
<div>
<img src={contextMenuImg} alt="Downloads widget context menu with pause, delete, and copy actions" width={128 * 4} />
</div>
### Features
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
- **Sortable columns** — click any column header to sort ascending/descending
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
- **Client filtering** — filter downloads by integration or status using the footer filter panel
- **Global controls** — pause/resume all downloads across all clients from the footer bar
- **Status indicators** — color-coded badges show download state at a glance
- **Speed overview** — total download and upload speeds displayed in the footer
### Supported Integrations
<WidgetIntegrations items={[{
@@ -71,23 +52,16 @@ import contextMenuImg from './img/context-menu.png';
}, {
integration: qBittorentIntegration,
note: "Torrent client"
}, {
integration: aria2Integration,
note: "Multi-protocol download client"
}, {
integration: slskdIntegration,
note: "Soulseek client (read-only)"
note: "Soulseek client"
}]} />
### Interactions
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
- **Click** any row to expand it and see full download details inline
- **Hover** a download name for a quick stats tooltip
- **Stats toggle** — show or hide the global stats bar from the footer
- **Pause / Resume** all downloads using the footer controls
- **Filter** by integration or status using the filter button in the footer
- **Sort** by clicking column headers (with persistent sort direction)
- Pause / Resume one download by clicking on the according icon.
- Delete one download by clicking on the trash icon. (Can also delete the files)
- Pause / Resume all downloads by using the bottom button.
### Adding the widget
+32 -24
View File
@@ -2,23 +2,26 @@ import { WidgetDefinition } from "@site/src/types";
import { IconDownload } from "@tabler/icons-react";
const columnsList = [
"id",
"actions",
"added",
"category",
"downSpeed",
"index",
"integration",
"name",
"progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio",
"received",
"sent",
"category",
"integration",
"index",
"size",
"state",
"time",
"type",
"upSpeed",
];
const sortColumns = columnsList.filter((c) => !["state", "category", "integration"].includes(c));
const sortingExclusion = ["actions", "id", "state"];
const columnsSort = columnsList.filter((column) => !sortingExclusion.some((exclusion) => exclusion === column));
export const downloadsWidget: WidgetDefinition = {
icon: IconDownload,
@@ -29,20 +32,25 @@ export const downloadsWidget: WidgetDefinition = {
items: [
{
name: "Columns to show",
description:
"Select the columns you want to display in the widget. Columns automatically hide/show based on widget width.",
description: "Select the columns you want to display in the widget.",
values: `List of columns: ${columnsList.join(", ")}`,
defaultValue: ["name", "progress", "downSpeed", "time", "state"].join(", "),
defaultValue: ["integration", "name", "progress", "time", "actions"].join(", "),
},
{
name: "Default sort column",
description: "The column used for initial sorting when the widget loads.",
defaultValue: "progress",
values: `List of columns: ${sortColumns.join(", ")}`,
name: "Enable items sorting",
description: "Enables the sorting in the table.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Column used for sorting by default",
description: "By default it will sort by this column.",
defaultValue: "type",
values: `List of columns: ${columnsSort.join(", ")}`,
},
{
name: "Invert sorting",
description: "This will invert the default sorting order.",
description: "This will invert the sorting order of the table.",
values: { type: "boolean" },
defaultValue: "no",
},
@@ -68,13 +76,13 @@ export const downloadsWidget: WidgetDefinition = {
name: "Hide completed torrent under this threshold (in kiB/s)",
description:
"This will hide completed torrent entries that have a download speed below the specified threshold.",
values: "Any number above 0, 0 to disable",
values: "Any number above 1, 0 to disable",
defaultValue: "0",
},
{
name: "Categories/labels to filter",
description: "You can filter the items by categories or labels. Use a comma to separate multiple values.",
values: "Comma-separated list of categories or labels",
name: "Categories/names to filter",
description: "You can filter the items by categories or names. Use a comma to separate multiple values.",
values: "Comma-separated list of categories or names",
defaultValue: "-",
},
{
@@ -86,13 +94,13 @@ export const downloadsWidget: WidgetDefinition = {
},
{
name: "Use filter to calculate Ratio",
description: "This will use the category filter when calculating the global torrent ratio.",
description: "This will use the filter to calculate the ratio of items shown in the widget.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Limit items per integration",
description: "This will limit the number of items shown per integration, not globally.",
description: "This will limit the number of items shown per integration, not globally",
values: "Any number above 1",
defaultValue: "50",
},
@@ -1,6 +1,6 @@
---
title: 'Immich Album Carousel'
description: 'Shows a slideshow of pictures from an Immich album or your full library'
description: 'Shows a slideshow of your album pictures from Immich'
hide_title: true
---
@@ -37,3 +37,4 @@ import screen from './img/screen.png';
### Configuration
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
@@ -4,16 +4,15 @@ import { IconPhoto } from "@tabler/icons-react";
export const immichAlbumCarouselWidget: WidgetDefinition = {
icon: IconPhoto,
name: "Immich Album",
description: "Shows a slideshow of pictures from an Immich album or your full library",
description: "Shows a slideshow of your album pictures from Immich",
path: "../../widgets/immich-album-carousel",
configuration: {
items: [
{
name: "Album",
description:
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
description: "Select the Immich album to show in the slideshow.",
values: { type: "string" },
defaultValue: "All photos",
defaultValue: "",
},
{
name: "Interval in seconds",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

@@ -1,65 +0,0 @@
---
title: 'Media Missing & Queue'
description: 'Track missing media and active downloads from Radarr and Sonarr'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { mediaMissingWidget } from '.';
import { radarrIntegration } from '@site/docs/integrations/radarr';
import { sonarrIntegration } from '@site/docs/integrations/sonarr';
<WidgetHeader
widget={mediaMissingWidget}
categories={['Media', 'Downloads']}
/>
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
### Screenshots
import widgetMissing from './img/missing.png';
import widgetQueued from './img/queued.png';
import widgetCompact from './img/compact.png';
<div className="flex gap-2 flex-wrap">
<img src={widgetMissing} alt="media-missing widget showing missing movies and episodes in a grid" width={360} />
<img src={widgetQueued} alt="media-missing tab showing download queue with progress bars" width={256} />
<img src={widgetCompact} alt="media-missing widget in a compact wide-and-short size" width={256} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: radarrIntegration,
}, {
integration: sonarrIntegration,
}]} />
### Tabs
| Tab | Description |
|---|---|
| **Missing** | Lists movies (Radarr) and episodes (Sonarr) that are monitored but not yet available in your library. |
| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. |
### Sizing
The widget adapts to its container size, so it looks good at any grid size:
- **Wide** — fits as many columns of cards as the width allows, filling larger widgets instead of stretching a single column.
- **Short** — packs items into more, tighter columns so they stay visible in the limited height.
- **Narrow (1×N)** — tab labels collapse to counts only and posters scale down.
Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining.
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={mediaMissingWidget.configuration} />
@@ -1,31 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconMovie } from "@tabler/icons-react";
export const mediaMissingWidget: WidgetDefinition = {
icon: IconMovie,
name: "Media Missing & Queue",
description: "Track missing media and active downloads from Radarr and Sonarr",
path: "../../widgets/media-missing",
configuration: {
items: [
{
name: "Show Missing",
description: "Display the missing media tab, listing movies or episodes not yet downloaded",
values: { type: "boolean" },
defaultValue: "true",
},
{
name: "Show Queued",
description: "Display the download queue tab with progress for each item",
values: { type: "boolean" },
defaultValue: "true",
},
{
name: "Page Size",
description: "Maximum number of items to fetch per integration",
values: { type: "string" },
defaultValue: "10",
},
],
},
};
@@ -1,44 +1,41 @@
---
title: "Media Request List"
description: "See a list of all media requests from your integration"
title: 'Media Request List'
description: 'See a list of all media requests from your integration'
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { IconAd } from "@tabler/icons-react";
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import { mediaRequestListWidget } from '.';
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
import { overseerrIntegration } from '@site/docs/integrations/overseerr';
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
<WidgetHeader
widget={mediaRequestListWidget}
categories={['Media requests']}
/>
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
### Screenshots
import mediaRequestList from "./img/list.png";
import mediaRequestList from './img/list.png';
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: jellyseerrIntegration,
},
{
integration: overseerrIntegration,
},
]}
/>
<WidgetIntegrations items={[{
integration: jellyseerrIntegration,
}, {
integration: overseerrIntegration,
}]} />
### Interactions
@@ -50,5 +47,4 @@ import mediaRequestList from "./img/list.png";
<AddingWidget />
### Configuration
<WidgetConfig configuration={mediaRequestListWidget.configuration} />
@@ -14,18 +14,6 @@ export const mediaRequestListWidget: WidgetDefinition = {
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Statuses to show",
description: "Only requests with these statuses will be shown. Defaults to all statuses.",
values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'",
defaultValue: "All statuses",
},
{
name: "Show only recent requests",
description: "Limit to requests created within this many days. Set to 0 to disable the time filter.",
values: "0-365",
defaultValue: "0",
},
],
},
};
@@ -14,7 +14,6 @@ import TabItem from '@theme/TabItem';
import { mediaServerWidget } from '.';
import { embyIntegration } from '@site/docs/integrations/emby';
import { jellyfinIntegration } from '@site/docs/integrations/jellyfin';
import { navidromeIntegration } from '@site/docs/integrations/navidrome';
import { plexIntegration } from '@site/docs/integrations/plex';
@@ -40,9 +39,6 @@ import mediaTable from './img/table.png';
integration: embyIntegration,
}, {
integration: jellyfinIntegration,
}, {
integration: navidromeIntegration,
note: "Will always show only currently playing audio streams, not all active sessions."
}, {
integration: plexIntegration,
note: "Will always show only currently playing streams, not all active sessions."
@@ -54,3 +50,4 @@ import mediaTable from './img/table.png';
### Configuration
<WidgetConfig configuration={mediaServerWidget.configuration} />
+1 -1
View File
@@ -10,7 +10,7 @@ export const mediaServerWidget: WidgetDefinition = {
items: [
{
name: "Show only currently playing",
description: "Plex and Navidrome always show only currently playing streams.",
description: "Disabling this will not work for plex.",
values: { type: "boolean" },
defaultValue: "yes",
},
+20 -40
View File
@@ -1,25 +1,29 @@
---
title: "Notebook"
description: "A simple notebook widget that supports markdown."
title: 'Notebook'
description: 'A simple notebook widget that supports markdown.'
hide_title: true
---
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { notebookWidget } from ".";
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { notebookWidget } from '.';
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
<WidgetHeader
widget={notebookWidget}
categories={['Notes']}
/>
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
It is based on Tiptap.dev and supports most of its features.
### Screenshots
import editOff from "./img/normal.png";
import editOn from "./img/edit.png";
import editOff from './img/normal.png';
import editOn from './img/edit.png';
<div className="flex gap-1">
<div className='flex gap-1'>
<img src={editOff} alt="notebook edit-off" width={128 * 3} />
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div>
@@ -29,27 +33,24 @@ import editOn from "./img/edit.png";
<AddingWidget />
### Configuration
<WidgetConfig configuration={notebookWidget.configuration} />
### Editing
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
![](./img/notebook-enter-editing.webp)
You can then decide to save your changes or cancel them to exit editing, using the buttons placed in the same location.
![](./img/notebook-exit-editing.webp)
### Notebook toolbar functions
The notebook offers a variety of functions to edit the text and more.
The notebook offers a variaty of functions to edit the text and more.
![](./img/notebook-toolbar.webp)
#### Common text formatting
| Function | Description |
| ---------------------- | ------------------------------------------------------------------- |
| Bold | Makes the selected text bold |
@@ -61,51 +62,35 @@ The notebook offers a variety of functions to edit the text and more.
| Clear formatting | Erases any of the preceding function's effects on the selected text |
#### Heading
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
#### Text Alignment
Select the alignment on the line. Left, center or right.
#### Text direction
Set the current paragraph or heading to left-to-right or right-to-left. This is useful when writing right-to-left text while Homarr is using a left-to-right language, or vice versa.
#### Text Alignement
Select the alignement on the line. Left, center or right.
#### Misc functions
##### Blockquote
Puts the current line in a quoting block.
##### Horizontal line
Adds a line that goes from the far right to the far left as a separator.
#### Lists
##### Bullet lists
Simple list with a dot preceding each element.
##### Ordered lists
List with numbers preceding each element.
##### Check lists
List with a checkbox on each element.
##### Indentation changes
![](./img/notebook-toolbar-lists.webp)
When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
#### Insert functions
##### Link controls
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
Pasting a link directly into the text will automatically put the link anchor on it too.
@@ -117,20 +102,16 @@ The second button will remove the link while leaving the text intact.
This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab.
##### Image controls
![](./img/notebook-image-modal.webp)
##### Source
You can use a normal link to an image or you can also use locally stored images.
##### Width
The aspect ratio will always be respected, so only the width field is necessary.
You can provide a value in a plain number or you can also use percents to have your image size dynamically change on different screens.
#### Table
This last control allows you to add a table, for which you can determine the number of columns and row before inserting it.
![](./img/notebook-table-modal.webp)
@@ -138,7 +119,6 @@ This last control allows you to add a table, for which you can determine the num
Clicking that button while in a table will delete it.
##### Table controls
![](./img/notebook-toolbar-table.webp)
While in a table, further controls will appear to help edit the table.
@@ -154,4 +134,4 @@ All the following ones are for editing the wize of the table:
- Delete Column
- Add row on top
- Add row below
- Delete Row
- Delete Row
-89
View File
@@ -1,89 +0,0 @@
---
title: 'PatchMon'
description: 'Displays host patch statistics from PatchMon including update and security counts.'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { patchmonWidget } from '.';
import { patchmonIntegration } from '@site/docs/integrations/patchmon';
<WidgetHeader
widget={patchmonWidget}
categories={['Health Monitoring']}
/>
This widget displays patch statistics from your PatchMon instance, including total hosts, hosts needing updates, and security update counts. Each stat is shown with a dedicated icon and severity-tinted card background for quick visual scanning.
PatchMon does not render a built-in widget title. To add a label on your board, open **Edit item → Advanced options → Title** — the same optional overlay badge used by other Homarr widgets.
### Authentication
:::note
This widget requires a **signed-in Homarr session**. Anonymous visitors cannot load PatchMon data, even when the board is [public](/docs/management/boards#access-control). Placing this widget on a public board does not expose patch or security statistics to guests.
Stat visibility toggles in the widget settings control **display only**. Authenticated users with integration query access still receive the full API payload regardless of which stats are hidden in the UI.
:::
### Compliance hero
When **Show compliance hero** is enabled (default), the widget displays a ring showing the percentage of hosts that are fully up to date. The ring color follows the same threshold rules as the "Up-to-Date Hosts" stat. When the widget is **2 or more columns wide**, the hero also shows the "Patch Compliance" label and exact host count (e.g. "27 / 42") beside the ring.
In **1-column-wide** layouts (such as 1×1), the compliance hero switches to a ring-only view so useful compliance data remains visible in tight spaces. If the compliance hero is disabled, compact widgets fall back to the next selected content: OS distribution when enabled, then the first enabled stat.
### Threshold colors
When **Enable threshold colors** is on (default), stat values and card backgrounds are colored for at-a-glance severity:
| Stat | Green | Yellow | Red |
|------|-------|--------|-----|
| Needs Updates | 0 | 1–4 | 5+ |
| Security Updates | 0 | 1–9 | 10+ |
| Hosts with Security Updates | 0 | — | 1+ |
| Up-to-Date Hosts | 100% of total | 90–99% | below 90% |
| Outdated Packages | 0 | 1–49 | 50+ |
Enable **Advanced thresholds** to override warning and critical values per stat, using either absolute counts or percentages of total hosts.
### OS distribution
When **Show OS distribution** is enabled, the widget shows a breakdown of monitored hosts by operating system. Two display modes are available:
- **Horizontal bars** (default) — a scrollable list of colored progress bars showing each OS's share
- **Donut chart** — a compact donut chart visualization that scales down in narrow layouts
The OS distribution limit defaults to **All**. A widget size of **2×3** or larger is recommended when using this section alongside the default stats, but compact 1×1 and 2×1 layouts can still show multiple OS rows when OS distribution is the primary selected content. Wider donut layouts can also show a compact legend beside the chart.
### Responsive sizing
The widget adapts its layout based on the available space:
| Widget size | Layout |
|-------------|--------|
| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats |
| **2×1** / **1×2** | Compact primary content; **2×1** shows compliance label and host ratio beside the ring; **1×2** uses ring-only hero |
| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards |
| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer |
| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend |
### Last updated
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
### Supported Integrations
<WidgetIntegrations items={[{
integration: patchmonIntegration,
note: "Displays host patch and security update statistics"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={patchmonWidget.configuration} />
-200
View File
@@ -1,200 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconShieldCheck } from "@tabler/icons-react";
export const patchmonWidget: WidgetDefinition = {
icon: IconShieldCheck,
name: "PatchMon",
description:
"Displays host patch statistics including total hosts, hosts needing updates, and security update counts.",
path: "../../widgets/patchmon",
configuration: {
items: [
{
name: "Show compliance hero",
description: "Display a compliance ring showing the percentage of up-to-date hosts",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show total hosts",
description: "Displays the total number of monitored hosts",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show hosts needing updates",
description: "Displays hosts with at least one outdated package",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show security updates",
description: "Displays the total number of security updates available",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show up-to-date hosts",
description: "Displays hosts with zero outdated packages",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show hosts with security updates",
description: "Displays hosts requiring security patches",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show recent updates (24h)",
description: "Displays the number of updates applied in the last 24 hours",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show outdated packages",
description: "Displays the total number of outdated packages across all hosts",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show repositories",
description: "Displays the number of active repositories being monitored",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Enable threshold colors",
description: "Colors stat values green, yellow, or red based on built-in severity presets",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Advanced thresholds",
description: "Customize warning and critical thresholds per stat (absolute count or % of total hosts)",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Needs updates threshold mode",
description:
"Compare the needs-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Needs updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Needs updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "5",
},
{
name: "Security updates threshold mode",
description:
"Compare the security-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Security updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Security updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "10",
},
{
name: "Hosts with security updates threshold mode",
description:
"Compare the host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Hosts with security updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Hosts with security updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Up-to-date hosts threshold mode",
description:
"Compare the up-to-date host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Percent of total hosts",
},
{
name: "Up-to-date hosts warning threshold",
description: "Enter yellow when the value is at or above this threshold but below full coverage",
values: "Integer ≥ 0",
defaultValue: "90",
},
{
name: "Up-to-date hosts critical threshold",
description: "Enter red when the value falls below this threshold",
values: "Integer ≥ 0",
defaultValue: "90",
},
{
name: "Outdated packages threshold mode",
description:
"Compare the outdated-packages count as an absolute value or percentage of total hosts. Shown when threshold colors, advanced thresholds, and Show outdated packages are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Outdated packages warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Outdated packages critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "50",
},
{
name: "Show OS distribution",
description: "Displays a horizontal bar breakdown of monitored hosts by operating system",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "OS display mode",
description:
"Choose how to display the operating system distribution. Shown when Show OS distribution is enabled.",
values: { type: "select", options: ["Horizontal bars", "Donut chart"] },
defaultValue: "Horizontal bars",
},
{
name: "OS distribution limit",
description: "Maximum number of operating systems to display in the distribution section",
values: { type: "select", options: ["Top 3", "Top 5", "Top 10", "All"] },
defaultValue: "All",
},
{
name: "Show OS version",
description: "Append the OS version to each distribution label when available",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
-35
View File
@@ -1,35 +0,0 @@
---
title: "Traefik"
description: "Displays Traefik router, service, middleware, and entry point counts."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { traefikWidget } from ".";
import { traefikIntegration } from "@site/docs/integrations/traefik";
<WidgetHeader widget={traefikWidget} categories={["Monitoring", "Reverse proxy"]} />
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: traefikIntegration,
note: "Reads data from Traefik's dashboard/API endpoints",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={traefikWidget.configuration} />
-31
View File
@@ -1,31 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconRoute } from "@tabler/icons-react";
export const traefikWidget: WidgetDefinition = {
icon: IconRoute,
name: "Traefik",
description: "Overview of Traefik routers, services, middlewares, and entry points.",
path: "../../widgets/traefik",
configuration: {
items: [
{
name: "Show TCP",
description: "Display TCP routers, services, and middlewares.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show UDP",
description: "Display UDP routers and services.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show entry points",
description: "Display the configured Traefik entry point names.",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+1 -1
View File
@@ -43,7 +43,7 @@
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^14.0.5",
"swiper": "^14.0.1",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
@@ -47,16 +47,6 @@ const secretKinds = {
description: "The Secret part of the API Key for authentication.",
icon: IconPassword,
},
patchmonApiKey: {
name: "API Key",
description: "The API key from PatchMon's GetHomepage integration.",
icon: IconKey,
},
patchmonApiSecret: {
name: "API Secret",
description: "The API secret from PatchMon's GetHomepage integration.",
icon: IconPassword,
},
githubAppId: { name: "App ID", description: "The ID of the GitHub App", icon: IconCode },
githubInstallationId: {
name: "Installation ID",
+1 -1
View File
@@ -1 +1 @@
{"openapi":"3.1.0","info":{"title":"Homarr API documentation","version":"1.1.0"},"servers":[{"url":"http://localhost:3000"}],"externalDocs":{"url":"https://homarr.dev"},"paths":{"/api/apps/paginated":{"get":{"operationId":"appRouter-getPaginated","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"search","schema":{"type":"string"}},{"in":"query","name":"pageSize","schema":{"default":10,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},{"in":"query","name":"page","schema":{"default":1,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}},"totalCount":{"type":"number"}},"required":["items","totalCount"],"additionalProperties":false}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.NOT_FOUND"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps":{"get":{"operationId":"appRouter-all","tags":["apps"],"security":[{"apikey":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}},"post":{"operationId":"appRouter-create","tags":["apps"],"security":[{"apikey":[]}],"parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"anyOf":[{"type":"string","maxLength":512},{"type":"null"}]},"iconUrl":{"type":"string","minLength":1},"href":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^(?!javascript)[a-zA-Z]*:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]},"pingUrl":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^https?:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]}},"required":["name","description","iconUrl","href","pingUrl"]}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"appId":{"type":"string"}},"required":["appId"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}]}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps/search":{"get":{"operationId":"appRouter-search","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"query","schema":{"type":"string"},"required":true},{"in":"queLine truncated
{"openapi":"3.1.0","info":{"title":"Homarr API documentation","version":"1.0.0"},"servers":[{"url":"http://localhost:3000"}],"externalDocs":{"url":"https://homarr.dev"},"paths":{"/api/apps/paginated":{"get":{"operationId":"appRouter-getPaginated","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"search","schema":{"type":"string"}},{"in":"query","name":"pageSize","schema":{"default":10,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},{"in":"query","name":"page","schema":{"default":1,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}},"totalCount":{"type":"number"}},"required":["items","totalCount"],"additionalProperties":false}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.NOT_FOUND"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps":{"get":{"operationId":"appRouter-all","tags":["apps"],"security":[{"apikey":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}},"post":{"operationId":"appRouter-create","tags":["apps"],"security":[{"apikey":[]}],"parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"anyOf":[{"type":"string","maxLength":512},{"type":"null"}]},"iconUrl":{"type":"string","minLength":1},"href":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^(?!javascript)[a-zA-Z]*:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]},"pingUrl":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^https?:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]}},"required":["name","description","iconUrl","href","pingUrl"]}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"appId":{"type":"string"}},"required":["appId"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}]}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps/search":{"get":{"operationId":"appRouter-search","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"query","schema":{"type":"string"},"required":true},{"in":"queLine truncated
-11
View File
@@ -14,11 +14,6 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@ai-sdk/openai-compatible": "catalog:",
"@assistant-ui/react": "catalog:",
"@assistant-ui/react-ai-sdk": "catalog:",
"@assistant-ui/react-lexical": "catalog:",
"@assistant-ui/react-markdown": "catalog:",
"@dnd-kit/core": "catalog:",
"@dnd-kit/modifiers": "catalog:",
"@dnd-kit/sortable": "catalog:",
@@ -77,8 +72,6 @@
"@xterm/addon-fit": "catalog:",
"@xterm/xterm": "catalog:",
"adm-zip": "catalog:",
"ai": "catalog:",
"assistant-stream": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"better-sqlite3": "catalog:",
"chroma-js": "catalog:",
@@ -95,7 +88,6 @@
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"mcp-handler": "catalog:",
"mermaid": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
"posthog-js": "catalog:",
@@ -104,9 +96,6 @@
"react-dom": "catalog:",
"react-error-boundary": "catalog:",
"react-simple-code-editor": "catalog:",
"remark-breaks": "catalog:",
"remark-directive": "catalog:",
"remark-gfm": "catalog:",
"sass": "catalog:",
"sql.js": "catalog:",
"superjson": "catalog:",
@@ -2,7 +2,6 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
@@ -11,7 +10,6 @@ import {
createWSClient,
httpBatchStreamLink,
httpLink,
httpSubscriptionLink,
isNonJsonSerializable,
loggerLink,
splitLink,
@@ -34,7 +32,6 @@ import {
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets";
import { createWidgetQueryPersister } from "./query-cache-persister";
@@ -92,13 +89,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
},
});
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
for (const { definition } of Object.values(widgetImports)) {
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
if (def.refetchInterval === undefined) continue;
const key = def.queryKey ?? [["widget", def.kind]];
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
client.setQueryDefaults(key, { refetchInterval: interval });
}
return client;
});
@@ -111,17 +101,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}),
splitLink({
condition: ({ type }) => type === "subscription",
true: splitLink({
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
true: httpSubscriptionLink({
url: getTrpcUrl(),
transformer: superjson,
eventSourceOptions: { withCredentials: true },
}),
false: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
}),
true: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
}),
false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input),
@@ -4,7 +4,7 @@ import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
import { useDisclosure, useMounted } from "@mantine/hooks";
import { useDisclosure } from "@mantine/hooks";
import { z } from "zod/v4";
import { signIn } from "@homarr/auth/client";
@@ -32,7 +32,6 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
const searchParams = useSearchParams();
const isError = searchParams.has("error");
const router = useRouter();
const isMounted = useMounted();
const [isPending, setIsPending] = useState(false);
const form = useZodForm(extendedValidation, {
initialValues: {
@@ -133,7 +132,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
{providers.includes("credentials") && (
<Stack gap="sm">
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="credentials">
<SubmitButton isPending={isPending} form={form} provider="credentials">
{t("action.login.label")}
</SubmitButton>
<PasswordForgottenCollapse username={form.values.name} />
@@ -141,7 +140,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
)}
{providers.includes("ldap") && (
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="ldap">
<SubmitButton isPending={isPending} form={form} provider="ldap">
{t("action.login.labelWith", { provider: "LDAP" })}
</SubmitButton>
)}
@@ -152,7 +151,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
)}
{providers.includes("oidc") && (
<Button fullWidth variant="light" disabled={!isMounted} onClick={async () => await signInAsync("oidc")}>
<Button fullWidth variant="light" onClick={async () => await signInAsync("oidc")}>
{t("action.login.labelWith", { provider: oidcClientName })}
</Button>
)}
@@ -162,13 +161,12 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
};
interface SubmitButtonProps {
isMounted: boolean;
isPending: boolean;
form: UseFormReturnType<FormType>;
provider: "credentials" | "ldap";
}
const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
const isCurrentProviderActive = form.getValues().provider === provider;
return (
@@ -178,7 +176,7 @@ const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsW
fullWidth
onClick={() => form.setFieldValue("provider", provider)}
loading={isPending && isCurrentProviderActive}
disabled={!isMounted || (isPending && !isCurrentProviderActive)}
disabled={isPending && !isCurrentProviderActive}
>
{children}
</Button>
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
@@ -23,7 +22,6 @@ import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
@@ -42,7 +40,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
@@ -50,24 +48,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
page: async ({ params }: { params: Promise<TParams> }) => {
const resolvedParams = await params;
const queryClient = getQueryClient();
const session = await auth();
const board = await getInitialBoard(resolvedParams).catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login");
redirect("/auth/login");
}
notFound();
}
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
notFound();
}
throw error;
});
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind);
@@ -97,16 +79,14 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
const userId = session?.user.id ?? "anonymous";
return (
<>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
</>
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
);
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
@@ -127,8 +107,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
} catch (error) {
// Ignore not found and bad-request errors and return empty metadata
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
// Ignore not found errors and return empty metadata
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
return {};
}
@@ -41,12 +41,15 @@ import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dy
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
// Fall back to read-only if the query has no data (e.g. errored) so we never expose
// edit/save/settings UI that would then fail server-side.
const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
if (!hasChangeAccess) {
if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />;
}
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { formatBytes } from "@homarr/common";
import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{formatBytes(file.size)}
{humanFileSize(file.size)}
</Text>
</Group>
<Button
+1 -3
View File
@@ -26,7 +26,6 @@ import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics";
import { AssistantProvider } from "~/components/assistant/assistant-provider";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
@@ -124,7 +123,6 @@ export default async function Layout(props: {
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
(innerProps) => <AssistantProvider {...innerProps} />,
]);
const { locale } = await props.params;
@@ -144,7 +142,7 @@ export default async function Layout(props: {
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<body className={["font-sans", fontSans.variable].join(" ")}>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
@@ -16,10 +16,7 @@ export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params;
const session = await auth();
if (
!session?.user.permissions.includes("board-modify-all") ||
!session?.user.permissions.includes("app-modify-all")
) {
if (!session?.user.permissions.includes("app-modify-all")) {
notFound();
}
const app = await api.app.byId({ id: params.id });
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
export default async function AppNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
if (!session?.user.permissions.includes("app-create")) {
notFound();
}
@@ -1,5 +1,5 @@
import { Fragment } from "react";
import { notFound, redirect } from "next/navigation";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -33,9 +33,6 @@ export default async function AppsPage(props: AppsPageProps) {
if (!session) {
redirect("/auth/login");
}
if (!session.user.permissions.includes("board-modify-all")) {
notFound();
}
const searchParams = searchParamsSchema.parse(await props.searchParams);
@@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import { getAssistantConnectionState } from "./assistant-configuration-state";
describe("getAssistantConnectionState", () => {
it("marks a saved required-key provider as ready", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: false,
providerRequiresApiKey: true,
apiKeyConfigured: true,
}),
).toEqual({
hasStoredApiKey: true,
connectionPending: false,
connectionReady: true,
});
});
it("requires the endpoint to be saved before exposing model controls", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: true,
providerRequiresApiKey: true,
apiKeyConfigured: true,
}),
).toEqual({
hasStoredApiKey: false,
connectionPending: true,
connectionReady: false,
});
});
it("does not call a required-key connection ready after its key is removed", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: false,
providerRequiresApiKey: true,
apiKeyConfigured: false,
}),
).toEqual({
hasStoredApiKey: false,
connectionPending: true,
connectionReady: false,
});
});
it("allows a saved local endpoint without an API key", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: false,
providerRequiresApiKey: false,
apiKeyConfigured: false,
}),
).toEqual({
hasStoredApiKey: false,
connectionPending: false,
connectionReady: true,
});
});
});
@@ -1,23 +0,0 @@
interface AssistantConnectionStateInput {
connectionConfigured: boolean;
destinationChanged: boolean;
providerRequiresApiKey: boolean;
apiKeyConfigured: boolean;
}
export const getAssistantConnectionState = ({
connectionConfigured,
destinationChanged,
providerRequiresApiKey,
apiKeyConfigured,
}: AssistantConnectionStateInput) => {
const hasStoredApiKey = apiKeyConfigured && !destinationChanged;
const credentialsMissing = providerRequiresApiKey && !hasStoredApiKey;
const connectionPending = !connectionConfigured || destinationChanged || credentialsMissing;
return {
hasStoredApiKey,
connectionPending,
connectionReady: !connectionPending,
};
};
@@ -1,179 +0,0 @@
.configuration {
width: 100%;
max-width: 74rem;
}
.summary {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.summaryProvider {
min-width: 0;
}
.summaryProviderText {
min-width: 0;
}
.summaryEndpoint {
max-width: min(46rem, 70vw);
}
.section {
overflow: hidden;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background-color: var(--mantine-color-body);
}
.sectionHeader {
padding: var(--mantine-spacing-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.sectionHeading {
min-width: 0;
}
.stepNumber {
width: 1.75rem;
height: 1.75rem;
flex: 0 0 1.75rem;
border-radius: 50%;
background-color: var(--mantine-primary-color-light);
color: var(--mantine-primary-color-light-color);
font-size: var(--mantine-font-size-sm);
font-weight: 700;
line-height: 1.75rem;
text-align: center;
}
.sectionBody {
padding: var(--mantine-spacing-md);
}
.sectionActions {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.credentialSummary {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.credentialSummaryContent {
min-width: 0;
}
.credentialFlow {
padding: var(--mantine-spacing-md);
border: 1px solid light-dark(var(--mantine-color-yellow-4), var(--mantine-color-yellow-8));
border-radius: var(--mantine-radius-md);
background-color: light-dark(
var(--mantine-color-yellow-0),
color-mix(in srgb, var(--mantine-color-yellow-9) 18%, transparent)
);
}
.credentialStepper :global(.mantine-Stepper-stepLabel) {
font-size: var(--mantine-font-size-sm);
}
.credentialStepper :global(.mantine-Stepper-content) {
padding-top: var(--mantine-spacing-md);
}
.advancedControl {
min-height: 3.25rem;
}
.selectedModel {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.selectedModelText {
min-width: 0;
}
.selectedModelBadges {
flex-shrink: 0;
}
.capabilityRow,
.enableRow {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.securityNote {
max-width: 66ch;
}
.flowActions {
display: flex;
justify-content: flex-end;
gap: var(--mantine-spacing-xs);
}
@media (max-width: 48em) {
.summaryEndpoint {
max-width: 78vw;
}
.summaryActions,
.credentialActions,
.sectionActions > :global(.mantine-Group-root) {
width: 100%;
}
.summaryActions > *,
.credentialActions > *,
.flowActions > *,
.sectionActions :global(.mantine-Button-root) {
flex: 1 1 auto;
}
.credentialSummary {
align-items: stretch;
}
.flowActions {
display: grid;
grid-template-columns: 1fr;
}
.credentialStepper :global(.mantine-Stepper-stepLabel) {
display: none;
}
.selectedModel {
align-items: stretch;
flex-direction: column;
}
.selectedModelBadges {
justify-content: flex-start;
flex-shrink: 1;
}
.capabilityRow {
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.configuration *,
.configuration *::before,
.configuration *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
@@ -1,970 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Accordion,
ActionIcon,
Alert,
Badge,
Box,
Button,
Divider,
Group,
Image,
LoadingOverlay,
Modal,
Paper,
PasswordInput,
Select,
SimpleGrid,
Skeleton,
Stack,
Stepper,
Switch,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconArrowLeft,
IconCheck,
IconCircleCheck,
IconDatabaseSearch,
IconKey,
IconLock,
IconPlus,
IconSearch,
IconShieldCheck,
IconShieldLock,
IconTool,
IconTrash,
IconWorld,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import {
assistantProviderCanUseOpenRouterServerTools,
assistantProviderIds,
assistantProviderPresets,
getAssistantModelOptionLabel,
resolveAssistantModelId,
} from "@homarr/definitions";
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-configuration.module.css";
import { getAssistantConnectionState } from "./assistant-configuration-state";
type HeaderEntry = { id: number; name: string; value: string };
type CredentialFlow = "idle" | "replace" | "remove";
type AssistantProviderOption = AssistantProvider | "homarr";
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProviderOption; size?: number }) => {
if (providerId === "homarr") {
return <Image src="/logo/logo.png" alt="" aria-hidden w={size} h={size} fit="contain" />;
}
const preset = assistantProviderPresets[providerId];
if (!preset.iconUrl) return <IconWorld size={size} aria-hidden />;
const darkIconUrl = "darkIconUrl" in preset ? preset.darkIconUrl : null;
if (!darkIconUrl) {
return <Image src={preset.iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" />;
}
return (
<>
<Image src={preset.iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" darkHidden />
<Image src={darkIconUrl} alt="" aria-hidden w={size} h={size} fit="contain" lightHidden />
</>
);
};
interface ConfigurationSectionProps {
number: number;
title: string;
description: string;
status?: ReactNode;
actions?: ReactNode;
children: ReactNode;
}
const ConfigurationSection = ({ number, title, description, status, actions, children }: ConfigurationSectionProps) => (
<section className={classes.section}>
<Group className={classes.sectionHeader} justify="space-between" align="flex-start">
<Group className={classes.sectionHeading} gap="sm" align="flex-start" wrap="nowrap">
<Box className={classes.stepNumber} aria-hidden>
{number}
</Box>
<Box>
<Text fw={650}>{title}</Text>
<Text size="sm" c="dimmed" maw="66ch">
{description}
</Text>
</Box>
</Group>
{status}
</Group>
<Stack className={classes.sectionBody} gap="md">
{children}
</Stack>
{actions && <Box className={classes.sectionActions}>{actions}</Box>}
</section>
);
const ConfigurationSkeleton = ({ label }: { label: string }) => (
<Stack className={classes.configuration} gap="md" aria-label={label}>
<Paper withBorder p="md">
<Group justify="space-between">
<Group>
<Skeleton circle h={36} />
<Stack gap={5}>
<Skeleton h={14} w={180} />
<Skeleton h={10} w={280} />
</Stack>
</Group>
<Skeleton h={24} w={110} radius="xl" />
</Group>
</Paper>
{[1, 2, 3].map((number) => (
<Paper key={number} withBorder p="md">
<Stack gap="sm">
<Skeleton h={18} w={220} />
<Skeleton h={12} w="65%" />
<Skeleton h={42} />
</Stack>
</Paper>
))}
</Stack>
);
export const AssistantConfiguration = () => {
const t = useScopedI18n("management.page.settings.section.assistant");
const utils = clientApi.useUtils();
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
const [provider, setProvider] = useState<AssistantProvider>("openrouter");
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.openrouter.baseUrl);
const [modelDiscoveryPath, setModelDiscoveryPath] = useState<string>("/models");
const [apiKey, setApiKey] = useState("");
const [credentialFlow, setCredentialFlow] = useState<CredentialFlow>("idle");
const [credentialStep, setCredentialStep] = useState(0);
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
const [clearHeaders, setClearHeaders] = useState(false);
const [nextHeaderId, setNextHeaderId] = useState(1);
const [modelId, setModelId] = useState("");
const [enabled, setEnabled] = useState(false);
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
const [clearCredentialsOpened, setClearCredentialsOpened] = useState(false);
const replacementKeyInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setProvider(configuration?.provider ?? "openrouter");
setBaseUrl(configuration?.baseUrl ?? assistantProviderPresets.openrouter.baseUrl);
setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? "");
setModelId(configuration?.modelId ?? "");
setEnabled(configuration?.enabled ?? false);
setWebSearchEnabled(configuration?.webSearchEnabled ?? false);
setApiKey("");
setCredentialFlow("idle");
setCredentialStep(0);
}, [configuration]);
const normalizedBaseUrl = baseUrl.trim().replace(/\/$/, "");
const normalizedModelDiscoveryPath = modelDiscoveryPath.trim() || null;
const destinationChanged =
configuration?.connectionConfigured === true &&
(configuration.provider !== provider || configuration.baseUrl !== normalizedBaseUrl);
const connectionChanged =
configuration?.connectionConfigured === true &&
(destinationChanged || configuration.modelDiscoveryPath !== normalizedModelDiscoveryPath);
const {
data: models,
error: discoveryError,
isFetching: isDiscovering,
refetch: discoverModels,
} = clientApi.assistant.discoverModels.useQuery(undefined, {
enabled:
configuration?.connectionConfigured === true && configuration.modelDiscoveryPath !== null && !connectionChanged,
staleTime: 5 * 60_000,
retry: false,
});
const modelOptions = useMemo(
() =>
(models ?? []).map((model) => ({
value: model.id,
label: getAssistantModelOptionLabel(model),
})),
[models],
);
const providerOptions = useMemo(
() =>
(["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
const items = assistantProviderIds
.filter((providerId) => assistantProviderPresets[providerId].category === category)
.map((providerId) => ({
value: providerId,
label: t(`provider.options.${providerId}.label`),
}));
return {
group: t(`provider.groups.${category}`),
items:
category === "hosted"
? [
{
value: "homarr",
label: t("provider.options.homarr.label"),
disabled: true,
},
...items,
]
: items,
};
}),
[t],
);
const selectedModel = connectionChanged ? undefined : models?.find((model) => model.id === modelId);
const hasDiscoveredModels = !connectionChanged && (models?.length ?? 0) > 0;
useEffect(() => {
if (!models || models.length === 0) return;
const resolvedModelId = resolveAssistantModelId(models, modelId);
if (resolvedModelId && resolvedModelId !== modelId) {
setModelId(resolvedModelId);
}
}, [modelId, models]);
useEffect(() => {
if (credentialFlow !== "replace" || credentialStep !== 0) return;
const animationFrame = requestAnimationFrame(() => replacementKeyInputRef.current?.focus());
return () => cancelAnimationFrame(animationFrame);
}, [credentialFlow, credentialStep]);
const preset = assistantProviderPresets[provider];
const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0);
const { hasStoredApiKey, connectionPending, connectionReady } = getAssistantConnectionState({
connectionConfigured: configuration?.connectionConfigured === true,
destinationChanged,
providerRequiresApiKey: preset.requiresApiKey,
apiKeyConfigured: configuration?.apiKeyConfigured === true,
});
const hasEffectiveApiKey = apiKey.trim().length > 0 || hasStoredApiKey;
const connectionValid =
baseUrl.trim().length > 0 && headerValuesValid && (!preset.requiresApiKey || hasEffectiveApiKey);
const modelControlsDisabled = connectionPending || isDiscovering;
const canSaveConfiguration = !modelControlsDisabled && modelId.trim().length > 0;
const canUseOpenRouterServerTools = assistantProviderCanUseOpenRouterServerTools(provider);
const updateConnection = clientApi.assistant.updateConnection.useMutation({
onSuccess: async ({ credentialsClearedForDestinationChange }) => {
setApiKey("");
setCredentialFlow("idle");
setCredentialStep(0);
setHeaders([]);
setClearHeaders(false);
await Promise.all([
utils.assistant.getAdminConfiguration.invalidate(),
utils.assistant.discoverModels.invalidate(),
utils.assistant.getAvailability.invalidate(),
]);
showSuccessNotification({
title: t("notification.connectionSaved.title"),
message: credentialsClearedForDestinationChange
? t("notification.connectionSaved.credentialsCleared")
: t("notification.connectionSaved.message"),
});
},
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
});
const saveConfiguration = clientApi.assistant.updateConfiguration.useMutation({
onSuccess: async () => {
await Promise.all([
utils.assistant.getAdminConfiguration.invalidate(),
utils.assistant.getAvailability.invalidate(),
utils.assistant.getRuntimeOptions.invalidate(),
]);
showSuccessNotification({
title: t("notification.saved.title"),
message: t("notification.saved.message"),
});
},
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
});
const clearCredentials = clientApi.assistant.clearCredentials.useMutation({
onSuccess: async () => {
setClearCredentialsOpened(false);
setApiKey("");
setHeaders([]);
setModelId("");
setEnabled(false);
await Promise.all([
utils.assistant.getAdminConfiguration.invalidate(),
utils.assistant.discoverModels.invalidate(),
utils.assistant.getAvailability.invalidate(),
]);
showSuccessNotification({
title: t("notification.credentialsCleared.title"),
message: t("notification.credentialsCleared.message"),
});
},
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
});
const pending = updateConnection.isPending || saveConfiguration.isPending || clearCredentials.isPending;
const resetCredentialFlow = () => {
setApiKey("");
setCredentialFlow("idle");
setCredentialStep(0);
};
const clearDraftConnectionState = () => {
resetCredentialFlow();
setHeaders([]);
setClearHeaders(false);
setModelId("");
setEnabled(false);
setWebSearchEnabled(false);
};
const changeProvider = (value: string | null) => {
if (!value || !(value in assistantProviderPresets)) return;
const nextProvider = value as AssistantProvider;
const nextPreset = assistantProviderPresets[nextProvider];
clearDraftConnectionState();
setProvider(nextProvider);
setBaseUrl(nextPreset.baseUrl);
setModelDiscoveryPath(nextPreset.modelDiscoveryPath ?? "");
};
const changeBaseUrl = (value: string) => {
if (value !== baseUrl) {
clearDraftConnectionState();
}
setBaseUrl(value);
};
const changeModelDiscoveryPath = (value: string) => {
if (value !== modelDiscoveryPath) {
setModelId("");
setEnabled(false);
}
setModelDiscoveryPath(value);
};
const addHeader = () => {
setHeaders((current) => [...current, { id: nextHeaderId, name: "", value: "" }]);
setNextHeaderId((current) => current + 1);
setClearHeaders(false);
};
const updateHeader = (id: number, field: "name" | "value", value: string) => {
setHeaders((current) => current.map((header) => (header.id === id ? { ...header, [field]: value } : header)));
};
const saveConnection = (clearApiKey = false) => {
const customHeaders =
headers.length > 0 ? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value])) : undefined;
updateConnection.mutate({
provider,
baseUrl,
modelDiscoveryPath: modelDiscoveryPath.trim() || null,
apiKey: clearApiKey ? undefined : apiKey.trim() || undefined,
clearApiKey,
customHeaders,
clearCustomHeaders: clearHeaders,
});
};
const saveAssistantConfiguration = () => {
if (!canSaveConfiguration) return;
saveConfiguration.mutate({
enabled,
modelId: modelId.trim(),
webSearchEnabled: canUseOpenRouterServerTools && webSearchEnabled,
});
};
if (isLoading) {
return <ConfigurationSkeleton label={t("loading")} />;
}
return (
<>
<Stack className={classes.configuration} gap="md">
<Paper className={classes.summary} withBorder p="md" radius="md" pos="relative">
<LoadingOverlay visible={pending} />
<Group justify="space-between" align="center">
<Group className={classes.summaryProvider} gap="sm" wrap="nowrap">
<ThemeIcon variant="default" size="lg" radius="md">
<ProviderIcon providerId={provider} size={22} />
</ThemeIcon>
<Box className={classes.summaryProviderText}>
<Group gap="xs">
<Text fw={650}>{t(`provider.options.${provider}.label`)}</Text>
<Badge
size="sm"
variant="light"
color={connectionChanged ? "yellow" : connectionReady ? "green" : "gray"}
>
{connectionChanged
? t("overview.unsavedChanges")
: connectionReady
? t("overview.connectionReady")
: t("overview.setupRequired")}
</Badge>
</Group>
<Text className={classes.summaryEndpoint} size="sm" c="dimmed" truncate>
{configuration?.modelId ?? t("overview.noModel")} · {normalizedBaseUrl}
</Text>
</Box>
</Group>
<Group className={classes.summaryActions} gap="xs">
{configuration?.webSearchEnabled && (
<Badge variant="light" leftSection={<IconSearch size={12} />}>
{t("serverTools.webSearch.label")}
</Badge>
)}
{hasStoredApiKey && (
<Badge variant="light" color="teal" leftSection={<IconLock size={12} />}>
{t("overview.keyProtected")}
</Badge>
)}
<Badge variant="filled" color={configuration?.enabled ? "green" : "gray"}>
{configuration?.enabled ? t("overview.available") : t("overview.disabled")}
</Badge>
</Group>
</Group>
</Paper>
<Group gap="xs" align="flex-start" wrap="nowrap">
<IconShieldCheck size={18} color="var(--mantine-color-blue-6)" aria-hidden />
<Text className={classes.securityNote} size="sm" c="dimmed">
{t("security.description")}
</Text>
</Group>
<ConfigurationSection
number={1}
title={t("connection.endpointTitle")}
description={t("connection.description")}
status={
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
{t(`provider.options.${provider}.label`)}
</Badge>
}
>
<Stack gap="md">
<Select
label={t("provider.title")}
description={t(`provider.options.${provider}.description`)}
value={provider}
onChange={changeProvider}
data={providerOptions}
searchable
allowDeselect={false}
leftSection={<ProviderIcon providerId={provider} />}
renderOption={({ option }) => (
<Group gap="sm" wrap="nowrap" align={option.value === "homarr" ? "flex-start" : "center"} w="100%">
<ProviderIcon providerId={option.value as AssistantProviderOption} />
<Box flex={1} miw={0}>
<Group gap="xs" wrap="nowrap" justify="space-between">
<Text size="sm" truncate>
{option.label}
</Text>
{option.value === "homarr" && (
<Badge size="xs" variant="light" color="gray" flex="0 0 auto">
{t("provider.comingSoon")}
</Badge>
)}
</Group>
{option.value === "homarr" && (
<Text size="xs" c="dimmed" lineClamp={2} maw="46ch">
{t("provider.options.homarr.description")}
</Text>
)}
</Box>
</Group>
)}
/>
<TextInput
label={t("baseUrl.title")}
description={t("baseUrl.description")}
leftSection={<IconWorld size={16} />}
value={baseUrl}
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
placeholder="https://provider.example/v1"
/>
<TextInput
label={t("model.discoveryPath")}
description={t("model.discoveryPathDescription")}
value={modelDiscoveryPath}
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
placeholder="/models"
/>
</Stack>
{destinationChanged && (
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("destinationChanged.title")}>
{t("destinationChanged.description")}
</Alert>
)}
</ConfigurationSection>
<ConfigurationSection
number={2}
title={t("credentials.title")}
description={t("credentials.description")}
status={
<Badge variant="light" color={hasStoredApiKey ? "green" : preset.requiresApiKey ? "yellow" : "gray"}>
{hasStoredApiKey
? t("apiKey.configuredBadge")
: preset.requiresApiKey
? t("credentials.required")
: t("credentials.optional")}
</Badge>
}
actions={
<Group justify="space-between">
<Button
variant="subtle"
color="red"
onClick={() => setClearCredentialsOpened(true)}
disabled={!configuration?.apiKeyConfigured && !configuration?.customHeadersConfigured}
>
{t("connection.clearCredentials")}
</Button>
{credentialFlow === "idle" && (
<Button
leftSection={<IconCheck size={16} />}
onClick={() => saveConnection()}
disabled={!connectionValid}
loading={updateConnection.isPending}
>
{t("connection.save")}
</Button>
)}
</Group>
}
>
{hasStoredApiKey && credentialFlow === "idle" ? (
<Group className={classes.credentialSummary} justify="space-between" align="center">
<Group className={classes.credentialSummaryContent} gap="sm" wrap="nowrap">
<ThemeIcon color="teal" variant="light" radius="xl">
<IconShieldLock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} size="sm">
{t("apiKey.savedTitle")}
</Text>
<Text size="sm" c="dimmed">
{t("apiKey.savedDescription")}
</Text>
</Box>
</Group>
<Group className={classes.credentialActions} gap="xs">
<Button
variant="light"
leftSection={<IconKey size={16} />}
onClick={() => {
setCredentialFlow("replace");
setCredentialStep(0);
}}
>
{t("apiKey.replace")}
</Button>
<Button variant="subtle" color="red" onClick={() => setCredentialFlow("remove")}>
{t("apiKey.remove")}
</Button>
</Group>
</Group>
) : credentialFlow === "replace" ? (
<Box className={classes.credentialFlow}>
<Stepper
className={classes.credentialStepper}
active={credentialStep}
size="sm"
allowNextStepsSelect={false}
>
<Stepper.Step label={t("apiKey.replaceFlow.enterStep")}>
<Stack gap="md">
<Box>
<Text fw={650}>{t("apiKey.replaceFlow.title")}</Text>
<Text size="sm" c="dimmed">
{t("apiKey.replaceFlow.description")}
</Text>
</Box>
<PasswordInput
label={t("apiKey.replaceFlow.newKey")}
description={t("apiKey.replaceFlow.newKeyDescription")}
value={apiKey}
onChange={(event) => setApiKey(event.currentTarget.value)}
leftSection={<IconKey size={16} />}
placeholder={t("apiKey.replacementPlaceholder")}
autoComplete="new-password"
ref={replacementKeyInputRef}
/>
<Box className={classes.flowActions}>
<Button variant="default" onClick={resetCredentialFlow}>
{t("apiKey.replaceFlow.cancel")}
</Button>
<Button
rightSection={<IconCheck size={16} />}
disabled={apiKey.trim().length === 0}
onClick={() => setCredentialStep(1)}
>
{t("apiKey.replaceFlow.review")}
</Button>
</Box>
</Stack>
</Stepper.Step>
<Stepper.Step label={t("apiKey.replaceFlow.reviewStep")}>
<Stack gap="md">
<Alert
color="yellow"
icon={<IconAlertTriangle size={18} />}
title={t("apiKey.replaceFlow.warningTitle")}
>
{t("apiKey.replaceFlow.warningDescription")}
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<Box>
<Text size="xs" c="dimmed">
{t("apiKey.replaceFlow.provider")}
</Text>
<Text size="sm" fw={600}>
{t(`provider.options.${provider}.label`)}
</Text>
</Box>
<Box>
<Text size="xs" c="dimmed">
{t("apiKey.replaceFlow.endpoint")}
</Text>
<Text size="sm" fw={600} truncate>
{normalizedBaseUrl}
</Text>
</Box>
</SimpleGrid>
<Box className={classes.flowActions}>
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setCredentialStep(0)}
>
{t("apiKey.replaceFlow.back")}
</Button>
<Button
color="yellow"
variant="filled"
leftSection={<IconKey size={16} />}
onClick={() => saveConnection()}
disabled={!connectionValid}
loading={updateConnection.isPending}
>
{t("apiKey.replaceFlow.confirm")}
</Button>
</Box>
</Stack>
</Stepper.Step>
</Stepper>
</Box>
) : credentialFlow === "remove" ? (
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("apiKey.removeFlow.title")}>
<Stack gap="md">
<Text size="sm">
{preset.requiresApiKey
? t("apiKey.removeFlow.description")
: t("apiKey.removeFlow.optionalDescription")}
</Text>
<Box className={classes.flowActions}>
<Button variant="default" onClick={resetCredentialFlow}>
{t("apiKey.removeFlow.cancel")}
</Button>
<Button color="red" loading={updateConnection.isPending} onClick={() => saveConnection(true)}>
{t("apiKey.removeFlow.confirm")}
</Button>
</Box>
</Stack>
</Alert>
) : (
<PasswordInput
label={t("apiKey.title")}
description={preset.requiresApiKey ? t("apiKey.required") : t("apiKey.optional")}
value={apiKey}
onChange={(event) => setApiKey(event.currentTarget.value)}
leftSection={<IconKey size={16} />}
placeholder={t("apiKey.placeholder")}
autoComplete="new-password"
/>
)}
<Accordion variant="contained" radius="md">
<Accordion.Item value="headers">
<Accordion.Control className={classes.advancedControl} icon={<IconShieldLock size={18} />}>
<Group justify="space-between" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{t("headers.title")}
</Text>
<Text size="xs" c="dimmed">
{t("headers.description")}
</Text>
</Box>
{configuration?.customHeadersConfigured && (
<Badge variant="light" color={clearHeaders ? "yellow" : "green"}>
{clearHeaders ? t("headers.pendingRemoval") : t("headers.savedBadge")}
</Badge>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{configuration?.customHeadersConfigured ? t("headers.configured") : t("headers.empty")}
</Text>
<Button variant="light" size="compact-sm" leftSection={<IconPlus size={14} />} onClick={addHeader}>
{t("headers.add")}
</Button>
</Group>
{headers.map((header) => (
<Group key={header.id} align="flex-end" wrap="wrap">
<TextInput
flex={1}
miw="12rem"
label={t("headers.name")}
value={header.name}
onChange={(event) => updateHeader(header.id, "name", event.currentTarget.value)}
placeholder="X-Provider-Header"
/>
<PasswordInput
flex={2}
miw="12rem"
label={t("headers.value")}
value={header.value}
onChange={(event) => updateHeader(header.id, "value", event.currentTarget.value)}
placeholder={t("headers.valuePlaceholder")}
autoComplete="off"
/>
<Tooltip label={t("headers.remove")}>
<ActionIcon
size="lg"
variant="subtle"
color="red"
aria-label={t("headers.remove")}
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
>
<IconTrash size={17} />
</ActionIcon>
</Tooltip>
</Group>
))}
{configuration?.customHeadersConfigured && headers.length === 0 && (
<Button
variant="light"
color={clearHeaders ? "gray" : "red"}
onClick={() => setClearHeaders(!clearHeaders)}
>
{clearHeaders ? t("headers.keep") : t("headers.clear")}
</Button>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</ConfigurationSection>
<ConfigurationSection
number={3}
title={t("model.title")}
description={t("model.description")}
status={
configuration?.modelId ? (
<Badge variant="light" color={configuration.enabled ? "green" : "gray"}>
{configuration.enabled ? t("overview.available") : t("overview.disabled")}
</Badge>
) : (
<Badge variant="light" color="gray">
{t("overview.noModel")}
</Badge>
)
}
actions={
<Group justify="flex-end">
<Button
leftSection={<IconCircleCheck size={16} />}
onClick={saveAssistantConfiguration}
disabled={!canSaveConfiguration}
loading={saveConfiguration.isPending}
>
{t("save")}
</Button>
</Group>
}
>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{connectionPending ? t("model.saveConnectionFirst") : t("model.chooseModel")}
</Text>
<Button
variant="light"
size="compact-sm"
leftSection={<IconDatabaseSearch size={14} />}
loading={isDiscovering}
disabled={modelControlsDisabled || configuration?.modelDiscoveryPath === null}
onClick={() => void discoverModels()}
>
{t("model.refresh")}
</Button>
</Group>
{hasDiscoveredModels ? (
<Select
label={t("model.title")}
value={modelId || null}
onChange={(value) => setModelId(value ?? "")}
data={modelControlsDisabled ? [] : modelOptions}
disabled={modelControlsDisabled}
placeholder={t("model.placeholder")}
description={t("model.discovered", { count: models?.length ?? 0 })}
searchable
allowDeselect={false}
limit={100}
/>
) : (
<TextInput
label={t("model.title")}
value={modelId}
onChange={(event) => setModelId(event.currentTarget.value)}
disabled={modelControlsDisabled}
placeholder={t("model.placeholder")}
description={
connectionPending
? t("model.saveConnectionFirst")
: isDiscovering
? t("model.discovering")
: discoveryError || models?.length === 0
? t("model.manualFallback")
: t("model.manual")
}
/>
)}
{discoveryError && !connectionPending && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("model.discoveryFailed")}>
{discoveryError.message}
</Alert>
)}
{selectedModel && (
<Group className={classes.selectedModel} justify="space-between" align="flex-start" wrap="nowrap">
<Box className={classes.selectedModelText}>
<Text fw={600}>{selectedModel.name}</Text>
{selectedModel.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{selectedModel.description}
</Text>
)}
</Box>
<Group className={classes.selectedModelBadges} gap="xs" justify="flex-end">
{selectedModel.contextLength && (
<Badge variant="light">{t("model.context", { count: selectedModel.contextLength })}</Badge>
)}
<Badge
variant="light"
color={selectedModel.toolSupport === "confirmed" ? "green" : "gray"}
leftSection={<IconTool size={12} />}
>
{selectedModel.toolSupport === "confirmed" ? t("model.toolsConfirmed") : t("model.toolsUnknown")}
</Badge>
</Group>
</Group>
)}
{canUseOpenRouterServerTools && (
<Stack gap="xs">
<Divider label={t("serverTools.title")} labelPosition="left" />
<Group className={classes.capabilityRow} justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap">
<ThemeIcon variant="light" radius="xl" size="md">
<IconSearch size={17} />
</ThemeIcon>
<Box>
<Group gap="xs">
<Text size="sm" fw={600}>
{t("serverTools.webSearch.label")}
</Text>
<Badge size="xs" variant="light">
{t("serverTools.beta")}
</Badge>
</Group>
<Text size="sm" c="dimmed" maw="66ch">
{provider === "custom"
? t("serverTools.webSearch.proxyDescription")
: t("serverTools.webSearch.description")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t("serverTools.webSearch.limit")}
</Text>
</Box>
</Group>
<Switch
checked={webSearchEnabled}
onChange={(event) => setWebSearchEnabled(event.currentTarget.checked)}
disabled={modelControlsDisabled}
aria-label={t("serverTools.webSearch.label")}
size="md"
/>
</Group>
</Stack>
)}
<Group className={classes.enableRow} justify="space-between" align="center" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{t("enabled.label")}
</Text>
<Text size="sm" c="dimmed">
{t("enabled.description")}
</Text>
</Box>
<Switch
checked={enabled}
onChange={(event) => setEnabled(event.currentTarget.checked)}
disabled={!canSaveConfiguration}
aria-label={t("enabled.label")}
size="md"
/>
</Group>
</ConfigurationSection>
</Stack>
<Modal
opened={clearCredentialsOpened}
onClose={() => setClearCredentialsOpened(false)}
title={t("clearCredentials.title")}
centered
>
<Stack>
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("clearCredentials.warningTitle")}>
{t("clearCredentials.description")}
</Alert>
<Divider />
<Group justify="flex-end">
<Button variant="default" onClick={() => setClearCredentialsOpened(false)}>
{t("clearCredentials.cancel")}
</Button>
<Button color="red" loading={clearCredentials.isPending} onClick={() => clearCredentials.mutate()}>
{t("clearCredentials.confirm")}
</Button>
</Group>
</Stack>
</Modal>
</>
);
};
@@ -1,33 +0,0 @@
import { notFound } from "next/navigation";
import { Text } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { AssistantConfiguration } from "./_components/assistant-configuration";
export async function generateMetadata() {
const t = await getScopedI18n("management.page.settings.section.assistant");
return {
title: `${t("title")} • Homarr`,
};
}
export default async function AssistantPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const t = await getScopedI18n("management.page.settings.section.assistant");
return (
<ManagePageLayout title={t("title")}>
<Text c="dimmed" maw="70ch">
{t("description")}
</Text>
<AssistantConfiguration />
</ManagePageLayout>
);
}
@@ -1,55 +0,0 @@
import { describe, expect, test } from "vitest";
import { buildAiPrompt } from "./_copy-ai-prompt-button";
const schema = {
type: "object",
required: ["name", "url", "displayType", "displayConfig"],
};
describe("buildAiPrompt", () => {
test("asks Homarr Assistant to create a definition and includes the user's request", () => {
const prompt = buildAiPrompt(
schema,
'{"status":"online"}',
{
$schema: "homarr-custom-widget-v2",
name: "Status",
url: "https://example.com/status",
},
"Show a compact green status when the service is online.",
);
expect(prompt).toContain("`customWidget_create`");
expect(prompt).toContain("`secrets: []`");
expect(prompt).toContain("do not ask for typed confirmation");
expect(prompt).toContain('```json\n{"status":"online"}\n```');
expect(prompt).toContain('"name": "Status"');
expect(prompt).toContain("Show a compact green status when the service is online.");
});
test("asks Homarr Assistant to update the selected definition without replacing its secrets", () => {
const prompt = buildAiPrompt(
schema,
null,
{
name: "Existing widget",
authType: "bearer",
},
"Use a progress bar.",
"widget-definition-id",
);
expect(prompt).toContain("custom widget definition `widget-definition-id`");
expect(prompt).toContain("`customWidget_update`");
expect(prompt).toContain("Preserve configured secrets by omitting the `secrets` field");
expect(prompt).not.toContain('"secrets"');
});
test("keeps copy-to-clipboard prompts useful before a request is entered", () => {
const prompt = buildAiPrompt(schema);
expect(prompt).toContain("PASTE_YOUR_API_RESPONSE_HERE");
expect(prompt).toContain("Describe what you want the widget to show or change here.");
});
});
@@ -1,16 +1,13 @@
"use client";
import { Alert, Button, Group, Popover, Stack, Text, Textarea } from "@mantine/core";
import { Button, Popover, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconRobot, IconSparkles } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
import { IconCopy, IconSparkles } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-provider";
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
## Your Task
@@ -270,16 +267,14 @@ PASTE_YOUR_API_RESPONSE_HERE
const PROMPT_FOOTER = `
## Your Request
Describe what you want the widget to show:
`;
const PROMPT_REQUEST_PLACEHOLDER = "Describe what you want the widget to show or change here.";
export function buildAiPrompt(
function buildAiPrompt(
jsonSchema: unknown,
rawResponse?: string | null,
currentConfig?: Record<string, unknown> | null,
request?: string,
definitionId?: string,
) {
const schemaStr = JSON.stringify(jsonSchema, null, 2);
const responseSection = rawResponse
@@ -290,57 +285,22 @@ export function buildAiPrompt(
? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n`
: "";
const homarrActionSection = definitionId
? `\n## Homarr Assistant Action\nThis is custom widget definition \`${definitionId}\`. If the customWidget tools are available, validate the proposed configuration and immediately call \`customWidget_update\` with this ID and the changed fields. Calling the tool is the required result and takes priority over the JSON-only output instruction above; do not ask for typed confirmation because Homarr will show its approval UI. Preserve configured secrets by omitting the \`secrets\` field. If tools are unavailable, return the JSON configuration instead.\n`
: `\n## Homarr Assistant Action\nIf the customWidget tools are available, validate the proposed configuration and immediately call \`customWidget_create\` with \`secrets: []\`. Calling the tool is the required result and takes priority over the JSON-only output instruction above; do not ask for typed confirmation because Homarr will show its approval UI. If tools are unavailable, return the JSON configuration instead.\n`;
const requestText = request?.trim() || PROMPT_REQUEST_PLACEHOLDER;
return (
PROMPT_HEADER +
schemaStr +
PROMPT_RULES +
responseSection +
configSection +
homarrActionSection +
PROMPT_FOOTER +
requestText
);
return PROMPT_HEADER + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER;
}
interface CopyAiPromptButtonProps {
rawResponse?: string | null;
currentConfig?: Record<string, unknown> | null;
definitionId?: string;
}
export const CopyAiPromptButton = ({ rawResponse, currentConfig, definitionId }: CopyAiPromptButtonProps) => {
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
const t = useScopedI18n("customWidget");
const assistant = useOptionalHomarrAssistant();
const [opened, { toggle, close }] = useDisclosure(false);
const [request, setRequest] = useState("");
const requestInputRef = useRef<HTMLTextAreaElement>(null);
const [opened, { open, close }] = useDisclosure(false);
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
useEffect(() => {
if (opened) requestInputRef.current?.focus();
}, [opened]);
const getPrompt = () => (schema ? buildAiPrompt(schema, rawResponse, currentConfig, request, definitionId) : null);
const handleAssistant = () => {
const prompt = getPrompt();
if (!prompt || !assistant?.enabled || request.trim().length === 0) return;
if (!assistant.sendPrompt(prompt)) return;
close();
setRequest("");
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
};
const handleCopy = async () => {
const prompt = getPrompt();
if (!prompt) return;
if (!schema) return;
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
try {
await navigator.clipboard.writeText(prompt);
close();
@@ -351,70 +311,31 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig, definitionId }:
};
return (
<Popover opened={opened} onClose={close} width="target" position="bottom" shadow="md" withinPortal>
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
<Popover.Target>
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={toggle}
onClick={open}
loading={isLoading}
disabled={!schema}
fullWidth
size="sm"
>
{t("action.aiPrompt")}
{t("action.copyAiPrompt")}
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Text size="sm">{t("notification.aiPromptDescription")}</Text>
<Textarea
ref={requestInputRef}
label={t("notification.aiPromptRequestLabel")}
placeholder={t("notification.aiPromptRequestPlaceholder")}
value={request}
onChange={(event) => setRequest(event.currentTarget.value)}
autosize
minRows={3}
maxRows={6}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
handleAssistant();
}
}}
/>
{!rawResponse && (
<Text size="xs" c="dimmed" fs="italic">
{t("notification.aiPromptNoResponse")}
</Text>
)}
{assistant?.enabled ? (
<Button
type="button"
leftSection={<IconRobot size={16} />}
onClick={handleAssistant}
disabled={request.trim().length === 0 || assistant.isRunning}
fullWidth
>
{assistant.isRunning ? t("notification.aiPromptAssistantBusy") : t("notification.aiPromptAssistant")}
</Button>
) : (
<Alert color="gray" icon={<IconRobot size={16} />}>
{t("notification.aiPromptAssistantUnavailable")}
</Alert>
)}
<Group justify="flex-end">
<Button
type="button"
variant="default"
leftSection={<IconCopy size={16} />}
onClick={() => void handleCopy()}
>
{t("notification.aiPromptCopy")}
</Button>
</Group>
<Button leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
{t("notification.aiPromptCopy")}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
@@ -688,19 +688,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
}, [previewRefreshSignal]);
const previewInput = getPreviewInput();
const aiPromptCurrentConfig = {
$schema: "homarr-custom-widget-v2",
name: form.values.name,
description: form.values.description || undefined,
iconUrl: form.values.iconUrl || undefined,
url: form.values.url,
authType: form.values.authType,
headerName: form.values.headerName || undefined,
method: form.values.method,
requestBody: form.values.requestBody || undefined,
displayType: form.values.displayType,
displayConfig: previewInput.displayConfig,
};
return (
<form onSubmit={handleSubmit}>
@@ -822,22 +809,15 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
</Fieldset>
<Box hiddenFrom="md">
<Stack gap="sm">
<CopyAiPromptButton
rawResponse={previewFetchResult?.rawResponse}
currentConfig={aiPromptCurrentConfig}
definitionId={definitionId}
/>
<CustomWidgetPreview
getFormValues={getPreviewInput}
formValues={previewInput}
fetchResult={previewFetchResult}
cachedJson={previewJson}
onTest={() => void handlePreviewTest()}
isTesting={previewMutation.isPending}
testError={previewMutation.error?.message ?? null}
/>
</Stack>
<CustomWidgetPreview
getFormValues={getPreviewInput}
formValues={previewInput}
fetchResult={previewFetchResult}
cachedJson={previewJson}
onTest={() => void handlePreviewTest()}
isTesting={previewMutation.isPending}
testError={previewMutation.error?.message ?? null}
/>
</Box>
<Group justify="end">
@@ -865,8 +845,19 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
</Button>
<CopyAiPromptButton
rawResponse={previewFetchResult?.rawResponse}
currentConfig={aiPromptCurrentConfig}
definitionId={definitionId}
currentConfig={{
$schema: "homarr-custom-widget-v2",
name: form.values.name,
description: form.values.description,
iconUrl: form.values.iconUrl,
url: form.values.url,
authType: form.values.authType,
headerName: form.values.headerName,
method: form.values.method,
requestBody: form.values.requestBody,
displayType: form.values.displayType,
displayConfig: previewInput.displayConfig,
}}
/>
<CustomWidgetPreview
getFormValues={getPreviewInput}
@@ -26,8 +26,6 @@ export const integrationSecretIcons = {
url: IconLink,
opnsenseApiKey: IconKey,
opnsenseApiSecret: IconPassword,
patchmonApiKey: IconKey,
patchmonApiSecret: IconPassword,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
+5 -12
View File
@@ -20,7 +20,6 @@ import {
IconMailForward,
IconPhotoFilled,
IconPointerFilled,
IconRobot,
IconSearch,
IconSettingsFilled,
IconUserFilled,
@@ -62,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
hidden: !session?.user.permissions.includes("app-create"),
hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
@@ -72,20 +71,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session?.user.permissions.includes("integration-create"),
hidden: !session,
"data-onboarding-tour-id": "manage-integrations",
},
{
icon: IconApi,
href: "/manage/custom-widgets",
label: t("items.customWidgets"),
hidden: !session?.user.permissions.includes("admin"),
hidden: !session,
},
{
icon: IconSearch,
href: "/manage/search-engines",
label: t("items.searchEngies"),
hidden: !session?.user.permissions.includes("search-engine-create"),
hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
@@ -95,7 +94,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconPhotoFilled,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session?.user.permissions.includes("media-upload"),
hidden: !session,
"data-onboarding-tour-id": "manage-medias",
},
{
@@ -172,12 +171,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
],
},
{
label: t("items.assistant"),
href: "/manage/assistant",
icon: IconRobot,
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.settings"),
href: "/manage/settings",
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { formatBytes } from "@homarr/common";
import { humanFileSize } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server";
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
/>
</TableTd>
<TableTd>{media.name}</TableTd>
<TableTd>{formatBytes(media.size)}</TableTd>
<TableTd>{humanFileSize(media.size)}</TableTd>
<TableTd>
{media.creator ? (
<Group gap="sm">
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { formatBytes, useTimeAgo } from "@homarr/common";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{formatBytes(bytesUsage)}
{humanFileSize(bytesUsage)}
</Text>
);
},
@@ -193,10 +193,6 @@ export function DockerTable({ initialData }: DockerTableProps) {
positionToolbarAlertBanner: "top",
enableTableFooter: false,
enableBottomToolbar: false,
// MRT's built-in full-screen mode does not stretch the table to fill the
// viewport, so with few containers it renders as a thin strip (#6230). Disable
// it, matching the other management tables (users, invites).
enableFullScreenToggle: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(containers.length) }),
@@ -31,7 +31,6 @@ import {
userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema,
userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema,
userPingIconsEnabledSchema,
} from "@homarr/validation/user";
@@ -52,7 +51,6 @@ const userGeneralSettingsSchema = z.object({
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
});
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
@@ -80,7 +78,6 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
ddgBangsEnabled: user.ddgBangs,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled,
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
});
export const UserGeneralSettingsForm = ({
@@ -98,7 +95,6 @@ export const UserGeneralSettingsForm = ({
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues);
@@ -118,7 +114,6 @@ export const UserGeneralSettingsForm = ({
changeSearchPreferencesMutation,
changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation,
changeEnableRightClickOnWidgetsMutation,
];
const isPending = mutations.some((m) => m.isPending);
@@ -174,14 +169,6 @@ export const UserGeneralSettingsForm = ({
action: () =>
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
},
{
when: changed("enableRightClickOnWidgets"),
action: () =>
changeEnableRightClickOnWidgetsMutation.mutateAsync({
id: user.id,
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
}),
},
];
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
@@ -318,11 +305,6 @@ export const UserGeneralSettingsForm = ({
label={t("user.field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/>
<Switch
label={t("user.field.enableRightClickOnWidgets.label")}
description={t("user.field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack>
</Card>
</SimpleGrid>
@@ -17,7 +17,6 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { optionalEmailSchema } from "@homarr/validation/email";
import { userPasswordSchema } from "@homarr/validation/user";
import { GroupSelectModal } from "~/components/access/group-select-modal";
@@ -61,7 +60,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
const generalForm = useZodForm(
z.object({
username: z.string().min(1),
email: optionalEmailSchema,
email: z.string().email().or(z.string().length(0).optional()),
}),
{
initialValues: {
Loaded 100 of 558 files, more files were not shown because too many files have changed in this diff. Show more