Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cecd58626 | ||
|
|
e4b061920c |
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
|
||||
@@ -6,14 +6,7 @@ npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.turbo
|
||||
.pnpm-store
|
||||
**/.pnpm-store
|
||||
coverage
|
||||
**/coverage
|
||||
**/.docusaurus
|
||||
**/build
|
||||
.git
|
||||
.github
|
||||
dev
|
||||
.build
|
||||
e2e
|
||||
|
||||
@@ -33,7 +33,6 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.70.0
|
||||
- 1.69.2
|
||||
- 1.69.0
|
||||
- 1.68.0
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
.secrets
|
||||
!.secrets.example
|
||||
@@ -1,4 +0,0 @@
|
||||
TURBO_API=
|
||||
TURBO_TEAM=
|
||||
TURBO_TOKEN=
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
|
||||
@@ -1,36 +0,0 @@
|
||||
# Running CI locally
|
||||
|
||||
Install [Act](https://nektosact.com/installation/index.html) and ensure Docker is running, then use:
|
||||
|
||||
```bash
|
||||
pnpm ci:act:fast
|
||||
pnpm ci:act:docker
|
||||
pnpm ci:act:all
|
||||
pnpm ci:act:cleanup
|
||||
```
|
||||
|
||||
`fast` runs the quality gate. `docker` and `all` run the gate followed by the single container build, E2E suite, and memory regression test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
|
||||
|
||||
Remote caching is optional locally. Copy `.github/act/.secrets.example` to `.github/act/.secrets`, populate it with rotated credentials, and run with:
|
||||
|
||||
```bash
|
||||
ACT_SECRET_FILE=.github/act/.secrets pnpm ci:act:all
|
||||
```
|
||||
|
||||
Act never publishes or deletes GHCR images. The secret file is ignored by Git.
|
||||
|
||||
## PR preview images
|
||||
|
||||
Successful non-draft pull requests from this repository publish an amd64 image:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/homarr-labs/homarr-test:pr-<number>
|
||||
```
|
||||
|
||||
The tag is replaced when the pull request is updated and deleted when it closes. Production Homarr images remain the supported multi-architecture releases. The `homarr-test` package must be configured as public in GHCR for unauthenticated testers.
|
||||
|
||||
## Dokploy preview cache
|
||||
|
||||
Keep `needs-demo` as the Dokploy preview label. Under the application's **Preview Build-time Secrets**, configure `TURBO_API`, `TURBO_TEAM`, `TURBO_TOKEN`, and `TURBO_REMOTE_CACHE_SIGNATURE_KEY`. Dokploy passes these to the Dockerfile as BuildKit secrets. Do not configure the token or signature key as Docker build arguments.
|
||||
|
||||
Rotate any credentials that have appeared in chat, logs, or committed configuration before enabling the cache.
|
||||
@@ -11,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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
- name: Validate workspace configuration
|
||||
run: pnpm lint:ws
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
container:
|
||||
name: Container and E2E
|
||||
needs: fast-gate
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: "!env.ACT"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build container once
|
||||
if: "!env.ACT"
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
network: host
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
push: false
|
||||
tags: |
|
||||
homarr-ci
|
||||
homarr-e2e
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
|
||||
cache-from: type=gha,scope=homarr-linux-amd64
|
||||
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Build container once with Act
|
||||
if: env.ACT
|
||||
env:
|
||||
TURBO_API: ${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
run: |
|
||||
secret_args=()
|
||||
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
|
||||
if [ -n "${!secret:-}" ]; then
|
||||
secret_args+=(--secret "id=${secret},env=${secret}")
|
||||
fi
|
||||
done
|
||||
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
|
||||
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: "!env.ACT"
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright system deps
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: pnpm exec playwright install-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pnpm test:e2e
|
||||
|
||||
- name: Upload E2E artifacts
|
||||
if: failure() && !env.ACT
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Determine preview tag
|
||||
id: preview
|
||||
if: |
|
||||
!env.ACT &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
run: echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.preview.outputs.tag != ''
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push preview image
|
||||
if: steps.preview.outputs.tag != ''
|
||||
env:
|
||||
PREVIEW_TAG: ${{ steps.preview.outputs.tag }}
|
||||
run: |
|
||||
docker tag homarr-ci "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
||||
docker push "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
||||
|
||||
- name: Show Act publication result
|
||||
if: env.ACT
|
||||
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
|
||||
|
||||
cleanup:
|
||||
name: Clean up preview image
|
||||
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
packages: write
|
||||
env:
|
||||
IMAGE_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
steps:
|
||||
- name: Validate cleanup input
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
|
||||
|
||||
- name: Delete GHCR package version
|
||||
if: "!env.ACT"
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const packageName = "homarr-test";
|
||||
const org = context.repo.owner;
|
||||
const tag = process.env.IMAGE_TAG;
|
||||
const versions = await github.paginate(
|
||||
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
|
||||
{ package_type: "container", package_name: packageName, org, per_page: 100 },
|
||||
);
|
||||
const target = versions.find((version) =>
|
||||
version.metadata?.container?.tags?.includes(tag),
|
||||
);
|
||||
if (!target) {
|
||||
core.info(`No package version found with tag "${tag}"`);
|
||||
return;
|
||||
}
|
||||
await github.rest.packages.deletePackageVersionForOrg({
|
||||
package_type: "container",
|
||||
package_name: packageName,
|
||||
org,
|
||||
package_version_id: target.id,
|
||||
});
|
||||
core.info(`Deleted package version ${target.id} (${tag})`);
|
||||
|
||||
- name: Show Act cleanup result
|
||||
if: env.ACT
|
||||
run: echo "Act validated cleanup for ${IMAGE_TAG}; external GHCR writes are disabled locally."
|
||||
@@ -0,0 +1,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:
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
## [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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && \
|
||||
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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| 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.70.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 |
|
||||
|
||||
|
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",
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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
|
||||
@@ -141,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 |
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -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} />
|
||||
@@ -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. 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,9 +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, and the table supports sorting by any column. 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.
|
||||
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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 21 KiB |
@@ -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 via the widget options panel
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
You can then decide to save your changes or cancel them to exit editing, using the buttons placed in the same location.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
#### 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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
##### 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.
|
||||
|
||||

|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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
|
||||
@@ -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} />
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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} />
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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 @@
|
||||
{"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
|
||||
@@ -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),
|
||||
|
||||
@@ -22,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";
|
||||
@@ -41,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,
|
||||
}),
|
||||
@@ -80,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> => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -142,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" />
|
||||
|
||||
@@ -26,8 +26,6 @@ export const integrationSecretIcons = {
|
||||
url: IconLink,
|
||||
opnsenseApiKey: IconKey,
|
||||
opnsenseApiSecret: IconPassword,
|
||||
patchmonApiKey: IconKey,
|
||||
patchmonApiSecret: IconPassword,
|
||||
githubAppId: IconCode,
|
||||
githubInstallationId: IconPlug,
|
||||
privateKey: IconKey,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ImageProxy } from "@homarr/image-proxy";
|
||||
|
||||
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await props.params;
|
||||
|
||||
const imageProxy = new ImageProxy();
|
||||
const result = await imageProxy.forwardImageAsync(id);
|
||||
|
||||
if ("error" in result) {
|
||||
const status = result.error === "upstream-error" ? result.statusCode : result.error === "fetch-error" ? 502 : 404;
|
||||
return new Response(null, {
|
||||
status,
|
||||
headers: {
|
||||
"X-Homarr-Image-Proxy-Error": result.error,
|
||||
},
|
||||
});
|
||||
const image = await imageProxy.forwardImageAsync(id);
|
||||
if (!image) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return new Response(result.image, {
|
||||
return new Response(image, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
|
||||
...(result.contentType ? { "Content-Type": result.contentType } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -30,19 +30,7 @@ export const InheritAccessTable = <TPermission extends string>({
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{Array.from(
|
||||
accessQueryData.inherited
|
||||
.reduce((map, entry) => {
|
||||
const existing = map.get(entry.group.id);
|
||||
if (!existing) {
|
||||
map.set(entry.group.id, entry);
|
||||
} else if (getPermissionsWithChildren([entry.permission]).includes(existing.permission)) {
|
||||
map.set(entry.group.id, entry);
|
||||
}
|
||||
return map;
|
||||
}, new Map<string, (typeof accessQueryData.inherited)[number]>())
|
||||
.values(),
|
||||
).map(({ group, permission }) => {
|
||||
{accessQueryData.inherited.map(({ group, permission }) => {
|
||||
const entityPermission =
|
||||
permission in mapPermissions
|
||||
? mapPermissions[permission]
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconCopy,
|
||||
IconLayoutKanban,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
|
||||
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
@@ -48,7 +38,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
const tMenu = useScopedI18n("item.menu.label");
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -60,10 +49,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const widgetQueryKey = useMemo(
|
||||
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
|
||||
[currentDefinition, item.kind],
|
||||
);
|
||||
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
|
||||
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
|
||||
const handleRefetch = useCallback(() => {
|
||||
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
|
||||
@@ -169,7 +155,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
|
||||
if (!session) return <>{children}</>;
|
||||
if (!isRightClickEnabled) return <>{children}</>;
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||
|
||||
@@ -244,14 +229,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
{tMenu("refresh")}
|
||||
<WidgetQueryStatus
|
||||
queryClient={queryClient}
|
||||
queryKey={widgetQueryKey}
|
||||
isFetching={isWidgetFetching}
|
||||
t={t}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
|
||||
</Text>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
@@ -288,65 +270,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
};
|
||||
|
||||
interface WidgetQueryStatusProps {
|
||||
const WidgetCacheAge = ({
|
||||
queryClient,
|
||||
queryKey,
|
||||
isFetching,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
queryKey: QueryKey;
|
||||
queryKey: unknown[];
|
||||
isFetching: boolean;
|
||||
t: TranslationFunction;
|
||||
}
|
||||
}) => {
|
||||
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
|
||||
const queries = queryClient.getQueryCache().findAll({ queryKey });
|
||||
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
|
||||
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
||||
const ageLabel = useTimeAgo(new Date(latest));
|
||||
if (isFetching) return <Loader size={12} />;
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Loader size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.loading")}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
const timestamps = queryClient
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey })
|
||||
.map((q) => q.state.dataUpdatedAt)
|
||||
.filter(Boolean);
|
||||
if (timestamps.length === 0) return null;
|
||||
|
||||
if (queries.length === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.idle")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const hasError = queries.some((q) => q.state.status === "error");
|
||||
|
||||
if (hasError) {
|
||||
const errorQuery = queries.find((q) => q.state.status === "error");
|
||||
const errorMessage =
|
||||
errorQuery?.state.error instanceof Error
|
||||
? errorQuery.state.error.message
|
||||
: String(errorQuery?.state.error ?? "Unknown error");
|
||||
|
||||
return (
|
||||
<Tooltip label={errorMessage} multiline position="left" w={250}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" c="red.6">
|
||||
{t("item.menu.status.error")} · {ageLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.success")} · {ageLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
const oldest = Math.min(...timestamps);
|
||||
const seconds = Math.floor((Date.now() - oldest) / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
return `${Math.floor(seconds / 60)}m ago`;
|
||||
};
|
||||
@@ -105,7 +105,7 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("welcome.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
documentationHref={createDocumentationLink("/docs/category/management")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ export const env = createEnv({
|
||||
server: {
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
},
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.13.1"
|
||||
"pnpm": ">=11.9.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.13.1"
|
||||
"packageManager": "pnpm@11.9.0"
|
||||
}
|
||||
@@ -95,27 +95,20 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/bazarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg" alt="Bazarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Bazarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/beszel" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg" alt="Beszel" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Beszel</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/coolify" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg" alt="Coolify" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Coolify</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
|
||||
<br/>
|
||||
@@ -156,15 +149,15 @@
|
||||
<br/>
|
||||
<p align="center">Gotify</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/home-assistant" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg" alt="Home Assistant" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Home<br/>Assistant</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/ical" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg" alt="iCal" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -205,15 +198,15 @@
|
||||
<br/>
|
||||
<p align="center">Navidrome</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/nextcloud" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg" alt="Nextcloud" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Nextcloud</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/ntfy" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg" alt="ntfy" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -254,13 +247,6 @@
|
||||
<br/>
|
||||
<p align="center">Paperless-ngx</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/patchmon" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg" alt="PatchMon" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">PatchMon</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
@@ -268,8 +254,8 @@
|
||||
<br/>
|
||||
<p align="center">PeaNUT</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -303,8 +289,8 @@
|
||||
<br/>
|
||||
<p align="center">qBittorrent</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/radarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg" alt="Radarr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -317,8 +303,8 @@
|
||||
<br/>
|
||||
<p align="center">Readarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -352,13 +338,6 @@
|
||||
<br/>
|
||||
<p align="center">Speedtest<br/>Tracker</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/synology" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg" alt="Synology DiskStation" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Synology<br/>DiskStation</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
|
||||
@@ -373,8 +352,8 @@
|
||||
<br/>
|
||||
<p align="center">Technitium<br/>DNS</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -382,13 +361,6 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/traefik" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg" alt="Traefik" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Traefik</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/transmission" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg" alt="Transmission" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -401,8 +373,8 @@
|
||||
<br/>
|
||||
<p align="center">TrueNAS</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/umami" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg" alt="Umami" width="90" height="90" />
|
||||
<br/>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.71.0",
|
||||
"version": "1.69.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
"clean": "git clean -xdf node_modules",
|
||||
"clean:workspaces": "turbo clean",
|
||||
"ci:act:all": "bash scripts/ci/act.sh all",
|
||||
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
|
||||
"ci:act:docker": "bash scripts/ci/act.sh docker",
|
||||
"ci:act:fast": "bash scripts/ci/act.sh fast",
|
||||
"cli": "pnpm with-env tsx packages/cli/index.ts",
|
||||
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
|
||||
"db:migration:mysql:run": "pnpm -F db migration:mysql:run",
|
||||
@@ -37,9 +33,6 @@
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "catalog:",
|
||||
"@semantic-release/changelog": "catalog:",
|
||||
@@ -50,6 +43,7 @@
|
||||
"@semantic-release/release-notes-generator": "catalog:",
|
||||
"@testcontainers/redis": "catalog:",
|
||||
"@turbo/gen": "catalog:",
|
||||
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:",
|
||||
@@ -69,7 +63,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.13.1"
|
||||
"pnpm": ">=11.9.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.13.1"
|
||||
"packageManager": "pnpm@11.9.0",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import { iconsRouter } from "./router/icons";
|
||||
import { infoRouter } from "./router/info";
|
||||
import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { bazarrRouter } from "./router/widgets/bazarr";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { beszelRouter } from "./router/widgets/beszel";
|
||||
import { calendarRouter } from "./router/widgets/calendar";
|
||||
import { dnsHoleRouter } from "./router/widgets/dns-hole";
|
||||
@@ -18,7 +16,6 @@ import { downloadsRouter } from "./router/widgets/downloads";
|
||||
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
|
||||
import { mediaRequestsRouter } from "./router/widgets/media-requests";
|
||||
import { mediaServerRouter } from "./router/widgets/media-server";
|
||||
import { patchmonRouter } from "./router/widgets/patchmon";
|
||||
import { smartHomeRouter } from "./router/widgets/smart-home";
|
||||
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
|
||||
|
||||
@@ -31,8 +28,6 @@ export const mcpRouter = createTRPCRouter({
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
bazarr: bazarrRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
beszel: beszelRouter,
|
||||
calendar: calendarRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
@@ -40,7 +35,6 @@ export const mcpRouter = createTRPCRouter({
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
mediaRequests: mediaRequestsRouter,
|
||||
mediaServer: mediaServerRouter,
|
||||
patchmon: patchmonRouter,
|
||||
smartHome: smartHomeRouter,
|
||||
widgetSecrets: widgetSecretsRouter,
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { appRouter } from "./router/app";
|
||||
import { boardRouter } from "./router/board";
|
||||
import { infoRouter } from "./router/info";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { userRouter } from "./router/user";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
@@ -15,14 +14,13 @@ export const openApiRouter = createTRPCRouter({
|
||||
boardRouter,
|
||||
infoRouter,
|
||||
inviteRouter,
|
||||
serverSettingsRouter,
|
||||
userRouter,
|
||||
});
|
||||
|
||||
export const openApiDocument = (base: string) =>
|
||||
generateOpenApiDocument(openApiRouter, {
|
||||
title: "Homarr API documentation",
|
||||
version: "1.1.0",
|
||||
version: "1.0.0",
|
||||
baseUrl: base,
|
||||
docsUrl: "https://homarr.dev",
|
||||
securitySchemes: {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const setActiveQueryCacheBoardId = (boardId: string | null) => {
|
||||
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
|
||||
|
||||
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping"]);
|
||||
|
||||
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
|
||||
const first = queryKey[0];
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
everyoneGroup,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
widgetDefaultSizes,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
@@ -331,16 +330,7 @@ export const boardRouter = createTRPCRouter({
|
||||
}),
|
||||
duplicateBoard: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.meta({
|
||||
openapi: { method: "POST", path: "/api/boards/{id}/duplicate", tags: ["boards"], protect: true },
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Duplicate an existing board into a new board. Requires board-create permission and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
|
||||
},
|
||||
})
|
||||
.input(boardDuplicateSchema)
|
||||
.output(z.object({ boardId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.name);
|
||||
@@ -554,57 +544,33 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { boardId: newBoardId };
|
||||
}),
|
||||
renameBoard: protectedProcedure
|
||||
.meta({
|
||||
openapi: { method: "PATCH", path: "/api/boards/{id}/name", tags: ["boards"], protect: true },
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Rename a board by ID. Requires full permission on the board. REQUIRED: id (board ID), name (new unique board name)",
|
||||
},
|
||||
})
|
||||
.input(boardRenameSchema)
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||
renameBoard: protectedProcedure.input(boardRenameSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
|
||||
|
||||
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
|
||||
}),
|
||||
changeBoardVisibility: protectedProcedure
|
||||
.meta({
|
||||
openapi: { method: "PATCH", path: "/api/boards/{id}/visibility", tags: ["boards"], protect: true },
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Change board visibility. Requires full permission on the board. REQUIRED: id (board ID), visibility ('public' or 'private'). Home boards cannot be made private",
|
||||
},
|
||||
})
|
||||
.input(boardChangeVisibilitySchema)
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
|
||||
}),
|
||||
changeBoardVisibility: protectedProcedure.input(boardChangeVisibilitySchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
|
||||
if (
|
||||
input.visibility !== "public" &&
|
||||
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot make home board private",
|
||||
});
|
||||
}
|
||||
if (
|
||||
input.visibility !== "public" &&
|
||||
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot make home board private",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(boards)
|
||||
.set({ isPublic: input.visibility === "public" })
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
await ctx.db
|
||||
.update(boards)
|
||||
.set({ isPublic: input.visibility === "public" })
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
deleteBoard: protectedProcedure
|
||||
.meta({
|
||||
openapi: { method: "DELETE", path: "/api/boards/{id}", tags: ["boards"], protect: true },
|
||||
@@ -621,38 +587,16 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await ctx.db.delete(boards).where(eq(boards.id, input.id));
|
||||
}),
|
||||
setHomeBoard: protectedProcedure
|
||||
.meta({
|
||||
openapi: { method: "PATCH", path: "/api/boards/{id}/home", tags: ["boards"], protect: true },
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Set the current user's desktop home board. Requires view permission on the board. REQUIRED: id (board ID)",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
|
||||
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
setMobileHomeBoard: protectedProcedure
|
||||
.meta({
|
||||
openapi: { method: "PATCH", path: "/api/boards/{id}/mobile-home", tags: ["boards"], protect: true },
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Set the current user's mobile home board. Requires view permission on the board. REQUIRED: id (board ID)",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
|
||||
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const user = userId
|
||||
@@ -808,16 +752,7 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
savePartialBoardSettings: protectedProcedure
|
||||
.meta({
|
||||
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update visual and behavior settings for a board. Requires modify permission. REQUIRED: id (board ID). Optional fields include pageTitle, metaTitle, logoImageUrl, faviconImageUrl, backgroundImageUrl, colors, opacity, customCss, itemRadius, and disableStatus",
|
||||
},
|
||||
})
|
||||
.input(boardSavePartialSettingsSchema.extend({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.input(boardSavePartialSettingsSchema.and(z.object({ id: z.string() })))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||
|
||||
@@ -1503,33 +1438,21 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
|
||||
|
||||
const fitsAt = (x: number, y: number) => {
|
||||
if (x + defaultSize.width > layout.columnCount) return false;
|
||||
for (let dy = 0; dy < defaultSize.height; dy++) {
|
||||
const row = occupied[y + dy];
|
||||
if (!row) continue;
|
||||
for (let dx = 0; dx < defaultSize.width; dx++) {
|
||||
if (row[x + dx]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
let placed = false;
|
||||
for (let y = 0; y < 9999 && !placed; y++) {
|
||||
if (!occupied[y]) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
|
||||
const occupiedRow = occupied[y];
|
||||
if (!occupiedRow) continue;
|
||||
for (let x = 0; x < layout.columnCount && !placed; x++) {
|
||||
if (fitsAt(x, y)) {
|
||||
if (!occupiedRow[x]) {
|
||||
layoutRows.push({
|
||||
itemId,
|
||||
sectionId: emptySection.id,
|
||||
layoutId: layout.id,
|
||||
xOffset: x,
|
||||
yOffset: y,
|
||||
width: defaultSize.width,
|
||||
height: defaultSize.height,
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
placed = true;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, asc, eq, like } from "@homarr/db";
|
||||
import { asc, eq, like } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { searchEngines, users } from "@homarr/db/schema";
|
||||
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
|
||||
@@ -130,15 +130,9 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
|
||||
return null;
|
||||
}),
|
||||
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||
search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.searchEngines.findMany({
|
||||
// Public dashboards have no session: restrict anonymous users to generic
|
||||
// (non-integration) engines so custom search engines work there too (#4132),
|
||||
// while integration-backed engines stay available only when signed in.
|
||||
where: and(
|
||||
like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
|
||||
ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
|
||||
),
|
||||
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
|
||||
with: {
|
||||
integration: {
|
||||
columns: {
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { and, eq, inArray } from "@homarr/db";
|
||||
import {
|
||||
getServerSettingByKeyAsync,
|
||||
getServerSettingsAsync,
|
||||
insertServerSettingByKeyAsync,
|
||||
updateServerSettingByKeyAsync,
|
||||
} from "@homarr/db/queries";
|
||||
import { boards, serverSettings } from "@homarr/db/schema";
|
||||
import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { settingsInitSchema } from "@homarr/validation/settings";
|
||||
@@ -16,15 +8,6 @@ import { settingsInitSchema } from "@homarr/validation/settings";
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
const boardServerSettingsSchema = z.object({
|
||||
homeBoardId: z.string().nullable(),
|
||||
mobileHomeBoardId: z.string().nullable(),
|
||||
enableStatusByDefault: z.boolean(),
|
||||
forceDisableStatus: z.boolean(),
|
||||
}) satisfies z.ZodType<ServerSettings["board"]>;
|
||||
|
||||
const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial();
|
||||
|
||||
export const serverSettingsRouter = createTRPCRouter({
|
||||
getCulture: publicProcedure.query(async ({ ctx }) => {
|
||||
return await getServerSettingByKeyAsync(ctx.db, "culture");
|
||||
@@ -32,67 +15,6 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
return await getServerSettingsAsync(ctx.db);
|
||||
}),
|
||||
getBoardSettings: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
openapi: { method: "GET", path: "/api/settings/board", tags: ["settings"], protect: true },
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
|
||||
},
|
||||
})
|
||||
.input(z.void())
|
||||
.output(boardServerSettingsSchema)
|
||||
.query(async ({ ctx }) => {
|
||||
return await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
}),
|
||||
updateBoardSettings: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
openapi: { method: "PATCH", path: "/api/settings/board", tags: ["settings"], protect: true },
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
|
||||
},
|
||||
})
|
||||
.input(boardServerSettingsUpdateSchema)
|
||||
.output(boardServerSettingsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const inputBoardIds = [input.homeBoardId, input.mobileHomeBoardId].filter(
|
||||
(id) => id !== undefined && id !== null,
|
||||
);
|
||||
|
||||
if (inputBoardIds.length > 0) {
|
||||
const publicBoards = await ctx.db.query.boards.findMany({
|
||||
columns: { id: true },
|
||||
where: and(inArray(boards.id, inputBoardIds), eq(boards.isPublic, true)),
|
||||
});
|
||||
const publicBoardIds = new Set(publicBoards.map((board) => board.id));
|
||||
const invalidBoardIds = inputBoardIds.filter((id) => !publicBoardIds.has(id));
|
||||
if (invalidBoardIds.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Board settings home board IDs must reference public boards: ${invalidBoardIds.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
const next = { ...current, ...input };
|
||||
const existing = await ctx.db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, "board"),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await updateServerSettingByKeyAsync(ctx.db, "board", next);
|
||||
} else {
|
||||
await insertServerSettingByKeyAsync(ctx.db, "board", next);
|
||||
}
|
||||
|
||||
return next;
|
||||
}),
|
||||
saveSettings: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { stringify } from "superjson";
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { boards, serverSettings } from "@homarr/db/schema";
|
||||
import { serverSettings } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("getAll server settings", () => {
|
||||
await db.insert(serverSettings).values([
|
||||
{
|
||||
settingKey: defaultServerSettingsKeys[0],
|
||||
value: stringify(defaultServerSettings.analytics),
|
||||
value: SuperJSON.stringify(defaultServerSettings.analytics),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("saveSettings", () => {
|
||||
await db.insert(serverSettings).values([
|
||||
{
|
||||
settingKey: defaultServerSettingsKeys[0],
|
||||
value: stringify(defaultServerSettings.analytics),
|
||||
value: SuperJSON.stringify(defaultServerSettings.analytics),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("saveSettings", () => {
|
||||
expect(dbSettings).toStrictEqual([
|
||||
{
|
||||
settingKey: "analytics",
|
||||
value: stringify({
|
||||
value: SuperJSON.stringify({
|
||||
enableGeneral: true,
|
||||
instanceId: null,
|
||||
}),
|
||||
@@ -90,91 +90,3 @@ describe("saveSettings", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("board settings API", () => {
|
||||
test("getBoardSettings should return board defaults", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
|
||||
});
|
||||
|
||||
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "default", isPublic: true });
|
||||
|
||||
const result = await caller.updateBoardSettings({
|
||||
homeBoardId: boardId,
|
||||
forceDisableStatus: true,
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
...defaultServerSettings.board,
|
||||
homeBoardId: boardId,
|
||||
forceDisableStatus: true,
|
||||
});
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
{
|
||||
settingKey: "board",
|
||||
value: stringify(result),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("updateBoardSettings should update existing settings", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "default", isPublic: true });
|
||||
|
||||
await caller.updateBoardSettings({ homeBoardId: boardId });
|
||||
const result = await caller.updateBoardSettings({
|
||||
mobileHomeBoardId: boardId,
|
||||
enableStatusByDefault: false,
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
...defaultServerSettings.board,
|
||||
homeBoardId: boardId,
|
||||
mobileHomeBoardId: boardId,
|
||||
enableStatusByDefault: false,
|
||||
});
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
{
|
||||
settingKey: "board",
|
||||
value: stringify(result),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("updateBoardSettings should reject private home boards", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "private", isPublic: false });
|
||||
|
||||
const actAsync = async () => await caller.updateBoardSettings({ homeBoardId: boardId });
|
||||
|
||||
await expect(actAsync()).rejects.toThrow("must reference public boards");
|
||||
});
|
||||
});
|
||||
@@ -357,83 +357,6 @@ describe("delete should delete user", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeEnableRightClickOnWidgets should toggle the right-click preference", () => {
|
||||
test("non-admin can toggle their own preference", async () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
await db.insert(users).values({
|
||||
id: defaultOwnerId,
|
||||
name: "owner",
|
||||
});
|
||||
|
||||
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: false });
|
||||
|
||||
const updated = await db.query.users.findFirst({
|
||||
where: eq(users.id, defaultOwnerId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(updated?.enableRightClickOnWidgets).toBe(false);
|
||||
|
||||
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: true });
|
||||
|
||||
const restored = await db.query.users.findFirst({
|
||||
where: eq(users.id, defaultOwnerId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(restored?.enableRightClickOnWidgets).toBe(true);
|
||||
});
|
||||
|
||||
test("non-admin cannot toggle another user's preference", async () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const otherUserId = createId();
|
||||
await db.insert(users).values({ id: defaultOwnerId });
|
||||
await db.insert(users).values({ id: otherUserId, name: "other" });
|
||||
|
||||
await expect(
|
||||
caller.changeEnableRightClickOnWidgets({ id: otherUserId, enableRightClickOnWidgets: false }),
|
||||
).rejects.toThrow("User not found");
|
||||
|
||||
const other = await db.query.users.findFirst({
|
||||
where: eq(users.id, otherUserId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(other?.enableRightClickOnWidgets).toBe(true);
|
||||
});
|
||||
|
||||
test("admin can toggle another user's preference", async () => {
|
||||
const db = createDb();
|
||||
const adminSession = createSession(["admin"]);
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: adminSession,
|
||||
});
|
||||
|
||||
const targetUserId = createId();
|
||||
await db.insert(users).values({ id: defaultOwnerId });
|
||||
await db.insert(users).values({ id: targetUserId, name: "target" });
|
||||
|
||||
await caller.changeEnableRightClickOnWidgets({ id: targetUserId, enableRightClickOnWidgets: false });
|
||||
|
||||
const target = await db.query.users.findFirst({
|
||||
where: eq(users.id, targetUserId),
|
||||
columns: { enableRightClickOnWidgets: true },
|
||||
});
|
||||
expect(target?.enableRightClickOnWidgets).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
|
||||
await db.insert(onboarding).values({
|
||||
id: createId(),
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { boards, integrationItems, integrations, integrationUserPermissions, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { PatchMonStats } from "@homarr/integrations";
|
||||
|
||||
import { patchmonRouter } from "../../widgets/patchmon";
|
||||
|
||||
const samplePatchMonStats = {
|
||||
totalHosts: 42,
|
||||
hostsNeedingUpdates: 15,
|
||||
securityUpdates: 23,
|
||||
upToDateHosts: 27,
|
||||
hostsWithSecurityUpdates: 8,
|
||||
recentUpdates24h: 34,
|
||||
totalOutdatedPackages: 156,
|
||||
totalRepos: 12,
|
||||
lastUpdated: "2025-10-11T12:34:56.789Z",
|
||||
osDistribution: [],
|
||||
} satisfies PatchMonStats;
|
||||
|
||||
vi.mock("@homarr/request-handler/patchmon", () => ({
|
||||
patchmonStatsRequestHandler: {
|
||||
handler: () => ({
|
||||
getDataAsync: async () => ({
|
||||
data: samplePatchMonStats,
|
||||
timestamp: new Date(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: [],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCaller = (db: Database, session: Session | null) =>
|
||||
patchmonRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session,
|
||||
});
|
||||
|
||||
const createPatchMonIntegrationOnBoardAsync = async (
|
||||
db: Database,
|
||||
options: {
|
||||
isPublic: boolean;
|
||||
boardCreatorId: string;
|
||||
},
|
||||
) => {
|
||||
const integrationId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "patchmon",
|
||||
name: "PatchMon",
|
||||
url: "https://patchmon.example.com",
|
||||
});
|
||||
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "patchmon-board",
|
||||
creatorId: options.boardCreatorId,
|
||||
isPublic: options.isPublic,
|
||||
});
|
||||
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
kind: "patchmon",
|
||||
boardId,
|
||||
options: "{}",
|
||||
});
|
||||
|
||||
await db.insert(integrationItems).values({
|
||||
integrationId,
|
||||
itemId,
|
||||
});
|
||||
|
||||
return { integrationId, boardId, itemId };
|
||||
};
|
||||
|
||||
describe("patchmonRouter.getStats access control", () => {
|
||||
test("should throw UNAUTHORIZED for unauthenticated users even when integration is on a public board", async () => {
|
||||
const db = createDb();
|
||||
const otherUserId = createId();
|
||||
await db.insert(users).values({ id: otherUserId });
|
||||
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
|
||||
isPublic: true,
|
||||
boardCreatorId: otherUserId,
|
||||
});
|
||||
const caller = createCaller(db, null);
|
||||
|
||||
await expect(caller.getStats({ integrationId })).rejects.toThrow(
|
||||
new TRPCError({ code: "UNAUTHORIZED" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
|
||||
const db = createDb();
|
||||
const boardOwnerId = createId();
|
||||
const sessionUserId = createId();
|
||||
await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]);
|
||||
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
|
||||
isPublic: false,
|
||||
boardCreatorId: boardOwnerId,
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
await expect(caller.getStats({ integrationId })).rejects.toThrow(
|
||||
new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to query at least one of the specified integration",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("should return stats for authenticated users when integration is on a public board", async () => {
|
||||
const db = createDb();
|
||||
const boardOwnerId = createId();
|
||||
const sessionUserId = createId();
|
||||
await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]);
|
||||
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
|
||||
isPublic: true,
|
||||
boardCreatorId: boardOwnerId,
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
const result = await caller.getStats({ integrationId });
|
||||
|
||||
expect(result).toStrictEqual(samplePatchMonStats);
|
||||
});
|
||||
|
||||
test("should return stats for authenticated users with integration use permission and no board placement", async () => {
|
||||
const db = createDb();
|
||||
const sessionUserId = createId();
|
||||
const integrationId = createId();
|
||||
await db.insert(users).values({ id: sessionUserId });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "patchmon",
|
||||
name: "PatchMon",
|
||||
url: "https://patchmon.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId,
|
||||
userId: sessionUserId,
|
||||
permission: "use",
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
const result = await caller.getStats({ integrationId });
|
||||
|
||||
expect(result).toStrictEqual(samplePatchMonStats);
|
||||
});
|
||||
|
||||
test("should throw NOT_FOUND when integration id does not exist", async () => {
|
||||
const db = createDb();
|
||||
const sessionUserId = createId();
|
||||
await db.insert(users).values({ id: sessionUserId });
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
await expect(caller.getStats({ integrationId: createId() })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw NOT_FOUND when integration is not a PatchMon integration", async () => {
|
||||
const db = createDb();
|
||||
const sessionUserId = createId();
|
||||
const integrationId = createId();
|
||||
await db.insert(users).values({ id: sessionUserId });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "sonarr",
|
||||
name: "Sonarr",
|
||||
url: "https://sonarr.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId,
|
||||
userId: sessionUserId,
|
||||
permission: "use",
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
await expect(caller.getStats({ integrationId })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
userCreateSchema,
|
||||
userDdgBangsSchema,
|
||||
userEditProfileSchema,
|
||||
userEnableRightClickOnWidgetsSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userInitSchema,
|
||||
userPingIconsEnabledSchema,
|
||||
@@ -346,7 +345,6 @@ export const userRouter = createTRPCRouter({
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -383,7 +381,6 @@ export const userRouter = createTRPCRouter({
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -654,32 +651,6 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
changeEnableRightClickOnWidgets: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "PATCH",
|
||||
path: "/api/users/right-click-widgets",
|
||||
tags: ["users"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(convertIntersectionToZodObject(userEnableRightClickOnWidgetsSchema.and(byIdSchema)))
|
||||
.output(z.void())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changePingIconsEnabled: protectedProcedure
|
||||
.input(userPingIconsEnabledSchema.and(byIdSchema))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const bazarrRouter = createTRPCRouter({
|
||||
getBadges: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "bazarr"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
});
|
||||
@@ -1,24 +1,18 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { LiveStatsEvent } from "@homarr/integrations/types";
|
||||
import {
|
||||
beszelAlertsRequestHandler,
|
||||
beszelStatsRequestHandler,
|
||||
beszelSystemsRequestHandler,
|
||||
} from "@homarr/request-handler/beszel";
|
||||
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "beszelRouter" });
|
||||
|
||||
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
export const beszelRouter = createTRPCRouter({
|
||||
getSystems: publicProcedure
|
||||
.meta({
|
||||
@@ -32,9 +26,8 @@ export const beszelRouter = createTRPCRouter({
|
||||
.query(async ({ ctx }) => {
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
|
||||
const results = await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
return {
|
||||
@@ -44,18 +37,25 @@ export const beszelRouter = createTRPCRouter({
|
||||
systems: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
systems: [],
|
||||
updatedAt: new Date(0),
|
||||
error: errorMessage(error),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const results = settled.map((result, index) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
const integration = ctx.integrations[index];
|
||||
logger.warn("getSystems integration failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration?.id,
|
||||
error: result.reason,
|
||||
});
|
||||
return {
|
||||
integrationId: integration?.id ?? "unknown",
|
||||
integrationName: integration?.name ?? "unknown",
|
||||
integrationUrl: integration?.url ?? "",
|
||||
systems: [],
|
||||
updatedAt: new Date(0),
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
};
|
||||
});
|
||||
logger.debug("getSystems completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds,
|
||||
@@ -88,9 +88,8 @@ export const beszelRouter = createTRPCRouter({
|
||||
includeHistory: input.includeHistory,
|
||||
maxHistoryItems: input.maxHistoryItems,
|
||||
});
|
||||
const results = await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||
includeHistory: input.includeHistory,
|
||||
maxHistoryItems: input.maxHistoryItems,
|
||||
@@ -116,19 +115,26 @@ export const beszelRouter = createTRPCRouter({
|
||||
systemNameMap,
|
||||
updatedAt: alertsResult.timestamp,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
alerts: [],
|
||||
history: [],
|
||||
systemNameMap: {},
|
||||
updatedAt: new Date(0),
|
||||
error: errorMessage(error),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const results = settled.map((result, index) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
const integration = ctx.integrations[index];
|
||||
logger.warn("getAlerts integration failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration?.id,
|
||||
error: result.reason,
|
||||
});
|
||||
return {
|
||||
integrationId: integration?.id ?? "unknown",
|
||||
integrationName: integration?.name ?? "unknown",
|
||||
alerts: [],
|
||||
history: [],
|
||||
systemNameMap: {},
|
||||
updatedAt: new Date(0),
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
};
|
||||
});
|
||||
logger.debug("getAlerts completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds,
|
||||
@@ -191,110 +197,8 @@ export const beszelRouter = createTRPCRouter({
|
||||
systemStats: [],
|
||||
containerStats: [],
|
||||
updatedAt: new Date(0),
|
||||
error: errorMessage(error),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
}),
|
||||
)
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<LiveStatsEvent>((emit) => {
|
||||
const controller = new AbortController();
|
||||
let isActive = true;
|
||||
let emittedEventCount = 0;
|
||||
|
||||
logger.debug("Beszel realtime subscription started", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds: ctx.integrations.map((integration) => integration.id),
|
||||
systemId: input.systemId,
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
const integration = ctx.integrations[0];
|
||||
if (!integration) {
|
||||
emit.error(
|
||||
new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
if (typeof instance.subscribeRealtimeMetrics === "function") {
|
||||
await instance.subscribeRealtimeMetrics(
|
||||
input.systemId,
|
||||
(event) => {
|
||||
if (!isActive) return;
|
||||
emittedEventCount += 1;
|
||||
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
|
||||
logger.debug("Forwarding Beszel realtime events", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
eventType: event.type,
|
||||
emittedEventCount,
|
||||
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
|
||||
});
|
||||
}
|
||||
emit.next(event);
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
} else {
|
||||
emit.error(
|
||||
new TRPCError({
|
||||
code: "METHOD_NOT_SUPPORTED",
|
||||
message: `Integration ${integration.kind} does not support realtime metrics`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isActive) {
|
||||
logger.warn("Beszel realtime subscription failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
emittedEventCount,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
emit.error(
|
||||
error instanceof TRPCError
|
||||
? error
|
||||
: new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
logger.debug("Beszel realtime subscription completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
emittedEventCount,
|
||||
});
|
||||
emit.complete();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
logger.debug("Beszel realtime subscription cancelled", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds: ctx.integrations.map((integration) => integration.id),
|
||||
systemId: input.systemId,
|
||||
emittedEventCount,
|
||||
});
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -49,7 +49,6 @@ export const downloadsRouter = createTRPCRouter({
|
||||
await integrationInstance.pauseQueueAsync();
|
||||
}),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
pauseItem: protectedProcedure
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
@@ -61,7 +60,6 @@ export const downloadsRouter = createTRPCRouter({
|
||||
await integrationInstance.pauseItemAsync(input.item);
|
||||
}),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
resume: protectedProcedure
|
||||
.meta({
|
||||
@@ -79,7 +77,6 @@ export const downloadsRouter = createTRPCRouter({
|
||||
await integrationInstance.resumeQueueAsync();
|
||||
}),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
resumeItem: protectedProcedure
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
@@ -91,7 +88,6 @@ export const downloadsRouter = createTRPCRouter({
|
||||
await integrationInstance.resumeItemAsync(input.item);
|
||||
}),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
deleteItem: protectedProcedure
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
@@ -103,6 +99,5 @@ export const downloadsRouter = createTRPCRouter({
|
||||
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
|
||||
}),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
});
|
||||
@@ -9,8 +9,6 @@ export const widgetRouter = createTRPCRouter({
|
||||
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
||||
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
|
||||
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
|
||||
patchmon: lazy(() => import("./patchmon").then((mod) => mod.patchmonRouter)),
|
||||
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
|
||||
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
||||
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
||||
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
|
||||
@@ -22,7 +20,6 @@ export const widgetRouter = createTRPCRouter({
|
||||
calendar: lazy(() => import("./calendar").then((mod) => mod.calendarRouter)),
|
||||
downloads: lazy(() => import("./downloads").then((mod) => mod.downloadsRouter)),
|
||||
mediaRequests: lazy(() => import("./media-requests").then((mod) => mod.mediaRequestsRouter)),
|
||||
mediaOrganizer: lazy(() => import("./media-organizer").then((mod) => mod.mediaOrganizerRouter)),
|
||||
rssFeed: lazy(() => import("./rssFeed").then((mod) => mod.rssFeedRouter)),
|
||||
indexerManager: lazy(() => import("./indexer-manager").then((mod) => mod.indexerManagerRouter)),
|
||||
healthMonitoring: lazy(() => import("./health-monitoring").then((mod) => mod.healthMonitoringRouter)),
|
||||
@@ -41,7 +38,6 @@ export const widgetRouter = createTRPCRouter({
|
||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
|
||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
|
||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const mediaOrganizerRouter = createTRPCRouter({
|
||||
getData: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
|
||||
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
...data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
});
|
||||
@@ -3,10 +3,7 @@ import { z } from "zod/v4";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import {
|
||||
mediaRequestListInputSchema,
|
||||
mediaRequestListRequestHandler,
|
||||
} from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
@@ -19,14 +16,13 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
|
||||
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.input(mediaRequestListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
|
||||
const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
|
||||
});
|
||||
return results
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
|
||||
export const patchmonRouter = createTRPCRouter({
|
||||
getStats: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "patchmon"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { escapeForRegEx } from "@tiptap/react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, items, widgetSecrets } from "@homarr/db/schema";
|
||||
import { releaseProviderKinds } from "@homarr/definitions";
|
||||
@@ -12,8 +11,6 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
|
||||
const logger = createLogger({ module: "releasesRouter" });
|
||||
|
||||
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
|
||||
if (!versionFilter) return undefined;
|
||||
|
||||
@@ -48,7 +45,7 @@ export const releasesRouter = createTRPCRouter({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const tokensByProvider = new Map<string, string>();
|
||||
const savedRepos = new Map<string, { providerUrl?: string }>();
|
||||
let allowedRepoIds: Set<string> | null = null;
|
||||
|
||||
if (input.itemId) {
|
||||
try {
|
||||
@@ -57,15 +54,9 @@ export const releasesRouter = createTRPCRouter({
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
|
||||
|
||||
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
|
||||
const repos = options.repositories as
|
||||
| Array<{ provider?: string; identifier: string; providerUrl?: string }>
|
||||
| undefined;
|
||||
const repos = options.repositories as Array<{ id?: string }> | undefined;
|
||||
if (repos) {
|
||||
for (const repo of repos) {
|
||||
if (repo.provider && repo.identifier) {
|
||||
savedRepos.set(`${repo.provider}:${repo.identifier}`, { providerUrl: repo.providerUrl });
|
||||
}
|
||||
}
|
||||
allowedRepoIds = new Set(repos.map((r) => r.id).filter((id): id is string => Boolean(id)));
|
||||
}
|
||||
|
||||
const secrets = await ctx.db.query.widgetSecrets.findMany({
|
||||
@@ -74,29 +65,29 @@ export const releasesRouter = createTRPCRouter({
|
||||
for (const secret of secrets) {
|
||||
try {
|
||||
tokensByProvider.set(secret.kind, decryptSecret(secret.value));
|
||||
} catch (error) {
|
||||
logger.warn("Failed to decrypt widget secret", { itemId: input.itemId, kind: secret.kind, error });
|
||||
} catch {
|
||||
// Skip corrupt secrets
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Failed to load release widget tokens", { itemId: input.itemId, error });
|
||||
} catch {
|
||||
// Access denied or table not yet migrated -- proceed without tokens
|
||||
}
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
input.repositories.map(async (repository) => {
|
||||
const repositoryId = repository.id ?? repository.identifier;
|
||||
const savedRepo = savedRepos.get(`${repository.provider}:${repository.identifier}`);
|
||||
try {
|
||||
const useToken = allowedRepoIds === null || allowedRepoIds.has(repositoryId);
|
||||
const response = await releasesRequestHandler
|
||||
.handler({
|
||||
id: repositoryId,
|
||||
provider: repository.provider,
|
||||
identifier: repository.identifier,
|
||||
versionRegex: formatVersionFilterRegex(repository.versionFilter),
|
||||
providerUrl: savedRepo?.providerUrl ?? repository.providerUrl,
|
||||
token: savedRepo ? tokensByProvider.get(repository.provider) : undefined,
|
||||
providerUrl: repository.providerUrl,
|
||||
token: useToken ? tokensByProvider.get(repository.provider) : undefined,
|
||||
})
|
||||
.getDataAsync();
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const traefikRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "traefik"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = traefikRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -9,14 +9,9 @@ interface IntegrationLike {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface Options<TIntegration extends IntegrationLike, TResult> {
|
||||
fallback?: (integration: TIntegration, error: unknown) => TResult;
|
||||
}
|
||||
|
||||
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
|
||||
integrations: TIntegration[],
|
||||
fn: (integration: TIntegration) => Promise<TResult>,
|
||||
options?: Options<TIntegration, TResult>,
|
||||
): Promise<TResult[]> {
|
||||
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
|
||||
return settled.flatMap((result, index) => {
|
||||
@@ -29,7 +24,6 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
|
||||
{ cause: result.reason },
|
||||
),
|
||||
);
|
||||
if (options?.fallback && integration) return [options.fallback(integration, result.reason)];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
@@ -2,20 +2,16 @@ import { expect, test, vi } from "vitest";
|
||||
import { extractToolsFromProcedures } from "trpc-to-mcp";
|
||||
|
||||
import { appRouter } from "../router/app";
|
||||
import { boardRouter } from "../router/board";
|
||||
import { infoRouter } from "../router/info";
|
||||
import { inviteRouter } from "../router/invite";
|
||||
import { serverSettingsRouter } from "../router/serverSettings";
|
||||
import { createTRPCRouter } from "../trpc";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
const mcpTestRouter = createTRPCRouter({
|
||||
app: appRouter,
|
||||
board: boardRouter,
|
||||
info: infoRouter,
|
||||
invite: inviteRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
});
|
||||
|
||||
test("MCP tools should be extractable from the router", () => {
|
||||
@@ -32,12 +28,8 @@ test("MCP tools should contain expected procedures", () => {
|
||||
expect(toolNames).toContain("app_all");
|
||||
expect(toolNames).toContain("app_byId");
|
||||
expect(toolNames).toContain("app_create");
|
||||
expect(toolNames).toContain("board_savePartialBoardSettings");
|
||||
expect(toolNames).toContain("board_duplicateBoard");
|
||||
expect(toolNames).toContain("info_getInfo");
|
||||
expect(toolNames).toContain("invite_getAll");
|
||||
expect(toolNames).toContain("serverSettings_getBoardSettings");
|
||||
expect(toolNames).toContain("serverSettings_updateBoardSettings");
|
||||
});
|
||||
|
||||
test("MCP tools should have descriptions", () => {
|
||||
|
||||
@@ -14,12 +14,3 @@ test("OpenAPI documentation should be generated", () => {
|
||||
// Assert
|
||||
expect(act).not.toThrow();
|
||||
});
|
||||
|
||||
test("OpenAPI documentation should expose board automation endpoints", () => {
|
||||
const document = openApiDocument("https://homarr.dev");
|
||||
|
||||
expect(document.info.version).toBe("1.1.0");
|
||||
expect(document.paths).toHaveProperty("/api/boards/{id}/settings");
|
||||
expect(document.paths).toHaveProperty("/api/boards/{id}/duplicate");
|
||||
expect(document.paths).toHaveProperty("/api/settings/board");
|
||||
});
|
||||
@@ -67,8 +67,6 @@ describe("isPersistableWidgetQueryKey", () => {
|
||||
[[["docker", "getContainers"], { type: "query" }], true],
|
||||
[[["integration", "byIds"], { type: "query" }], true],
|
||||
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
|
||||
[[["widget", "beszel", "getSystemStats"], { type: "query" }], false],
|
||||
[[["widget", "beszel", "getSystems"], { type: "query" }], true],
|
||||
[[["app", "selectable"], { type: "query" }], false],
|
||||
[[["board", "getBoardByName"], { type: "query" }], false],
|
||||
[["widget"], false],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CredentialsSignin } from "@auth/core/errors";
|
||||
import type { z } from "zod/v4";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -7,7 +7,6 @@ import type { Database, InferInsertModel } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema";
|
||||
import type { ldapSignInSchema } from "@homarr/validation/user";
|
||||
import { utf8EmailSchema } from "@homarr/validation/email";
|
||||
|
||||
import { env } from "../../../env";
|
||||
import { LdapClient } from "../ldap-client";
|
||||
@@ -53,7 +52,8 @@ export const authorizeWithLdapCredentialsAsync = async (
|
||||
throw new CredentialsSignin(`User not found in LDAP username="${credentials.name}"`);
|
||||
}
|
||||
|
||||
const mailResult = await utf8EmailSchema.safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]);
|
||||
// Validate email
|
||||
const mailResult = await z.string().email().safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]);
|
||||
|
||||
if (!mailResult.success) {
|
||||
logger.error("User found in LDAP but with invalid or non-existing Email", {
|
||||
|
||||
@@ -21,17 +21,11 @@ export const randomInt = (min: number, max: number) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Number of bytes to a human-readable string using binary (KiB) suffixes.
|
||||
* Does not accept floats; size in bytes should be an integer.
|
||||
* Will return "NaI" and log a warning if a float is passed.
|
||||
* `concat` is appended after the unit so it is omitted when the returned
|
||||
* value is "NaI" or "∞". Returns "∞" if the size is too large to be
|
||||
* represented in the current format.
|
||||
*
|
||||
* @deprecated Use `formatBytes` for single values, `formatBytesPair` for
|
||||
* paired "used / total" displays, and `formatByteRate` for byte-per-second
|
||||
* rates. This function is kept for backwards compatibility but should not
|
||||
* be used in new code.
|
||||
* Number of bytes to si format. (Division by 1024)
|
||||
* Does not accept floats, size in bytes should be an integer.
|
||||
* Will return "NaI" and logs a warning if a float is passed.
|
||||
* Concat as parameters so it is not added if the returned value is "NaI" or "∞".
|
||||
* Returns "∞" if the size is too large to be represented in the current format.
|
||||
*/
|
||||
export const humanFileSize = (size: number, concat = ""): string => {
|
||||
//64bit limit for Number stops at EiB
|
||||
@@ -54,99 +48,6 @@ export const humanFileSize = (size: number, concat = ""): string => {
|
||||
return "∞";
|
||||
};
|
||||
|
||||
const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] as const;
|
||||
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
|
||||
|
||||
export type ByteUnitSystem = "binary" | "decimal";
|
||||
|
||||
export interface FormatBytesOptions {
|
||||
/**
|
||||
* Unit system to use. "binary" uses 1024 as the base and KiB/MiB/GiB/TiB suffixes,
|
||||
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "binary".
|
||||
*/
|
||||
unit?: ByteUnitSystem;
|
||||
}
|
||||
|
||||
const pickUnitIndex = (bytes: number, base: number, lastIndex: number): number => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
|
||||
let index = 0;
|
||||
let value = bytes;
|
||||
while (value >= base && index < lastIndex) {
|
||||
value /= base;
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const sanitizeBytes = (bytes: number): number => (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
|
||||
|
||||
const resolveUnitConfig = (options: FormatBytesOptions) => {
|
||||
const { unit = "binary" } = options;
|
||||
return {
|
||||
units: unit === "binary" ? BINARY_UNITS : DECIMAL_UNITS,
|
||||
base: unit === "binary" ? 1024 : 1000,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a byte value as a human-readable string with a unit suffix.
|
||||
*
|
||||
* Picks the largest unit that keeps the scaled value below the base. Use
|
||||
* `formatBytesPair` when two related values (e.g. used and available) should
|
||||
* share a common unit.
|
||||
*
|
||||
* @example
|
||||
* formatBytes(0); // "0.0 B"
|
||||
* formatBytes(1024); // "1.0 KiB"
|
||||
* formatBytes(985828802560); // "918.1 GiB"
|
||||
* formatBytes(985828802560, { unit: "decimal" }); // "985.8 GB"
|
||||
*/
|
||||
export const formatBytes = (bytes: number, options: FormatBytesOptions = {}): string => {
|
||||
const { units, base } = resolveUnitConfig(options);
|
||||
const safe = sanitizeBytes(bytes);
|
||||
const index = pickUnitIndex(safe, base, units.length - 1);
|
||||
const scaled = safe / base ** index;
|
||||
return `${scaled.toFixed(1)} ${units[index]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format two related byte values (typically `used` and `available`) with a
|
||||
* shared unit. The unit is picked from the sum of the two values so the
|
||||
* combined display stays consistent.
|
||||
*
|
||||
* @example
|
||||
* formatBytesPair(985828802560, 2858736793190);
|
||||
* // { used: "0.9 TiB", available: "2.6 TiB" }
|
||||
*/
|
||||
export const formatBytesPair = (
|
||||
used: number,
|
||||
available: number,
|
||||
options: FormatBytesOptions = {},
|
||||
): { used: string; available: string } => {
|
||||
const { units, base } = resolveUnitConfig(options);
|
||||
const safeUsed = sanitizeBytes(used);
|
||||
const safeAvailable = sanitizeBytes(available);
|
||||
const index = pickUnitIndex(safeUsed + safeAvailable, base, units.length - 1);
|
||||
const suffix = units[index];
|
||||
return {
|
||||
used: `${(safeUsed / base ** index).toFixed(1)} ${suffix}`,
|
||||
available: `${(safeAvailable / base ** index).toFixed(1)} ${suffix}`,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a byte-per-second rate (e.g. network throughput) as a human-readable
|
||||
* string with a `/s` suffix. The value is formatted with the same rules as
|
||||
* `formatBytes` and a trailing `/s` is appended.
|
||||
*
|
||||
* @example
|
||||
* formatByteRate(0); // "0.0 B/s"
|
||||
* formatByteRate(1024); // "1.0 KiB/s"
|
||||
* formatByteRate(985828802560, { unit: "decimal" }); // "985.8 GB/s"
|
||||
*/
|
||||
export const formatByteRate = (bytes: number, options: FormatBytesOptions = {}): string =>
|
||||
`${formatBytes(bytes, options)}/s`;
|
||||
|
||||
const IMPERIAL_MULTIPLIER = 1.609344;
|
||||
|
||||
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatByteRate, formatBytes, formatBytesPair } from "../number";
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("returns bytes for small values", () => {
|
||||
expect(formatBytes(0)).toBe("0.0 B");
|
||||
expect(formatBytes(1)).toBe("1.0 B");
|
||||
expect(formatBytes(1023)).toBe("1023.0 B");
|
||||
});
|
||||
|
||||
it("scales binary units correctly", () => {
|
||||
expect(formatBytes(1024)).toBe("1.0 KiB");
|
||||
expect(formatBytes(1024 ** 2)).toBe("1.0 MiB");
|
||||
expect(formatBytes(1024 ** 3)).toBe("1.0 GiB");
|
||||
expect(formatBytes(1024 ** 4)).toBe("1.0 TiB");
|
||||
});
|
||||
|
||||
it("scales decimal units when requested", () => {
|
||||
expect(formatBytes(1000, { unit: "decimal" })).toBe("1.0 KB");
|
||||
expect(formatBytes(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB");
|
||||
expect(formatBytes(1000 ** 3, { unit: "decimal" })).toBe("1.0 GB");
|
||||
expect(formatBytes(1000 ** 4, { unit: "decimal" })).toBe("1.0 TB");
|
||||
});
|
||||
|
||||
it("formats a typical storage value with one decimal", () => {
|
||||
expect(formatBytes(985828802560)).toBe("918.1 GiB");
|
||||
expect(formatBytes(985828802560, { unit: "decimal" })).toBe("985.8 GB");
|
||||
});
|
||||
|
||||
it("caps at the largest unit to avoid overflow", () => {
|
||||
expect(formatBytes(Number.MAX_SAFE_INTEGER)).toMatch(/PiB$/);
|
||||
});
|
||||
|
||||
it("returns the zero value for negative or non-finite input", () => {
|
||||
expect(formatBytes(-1)).toBe("0.0 B");
|
||||
expect(formatBytes(Number.NaN)).toBe("0.0 B");
|
||||
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0.0 B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytesPair", () => {
|
||||
it("picks the unit of the larger total and applies it to both values", () => {
|
||||
const { used, available } = formatBytesPair(985828802560, 2858736793190);
|
||||
expect(used).toBe("0.9 TiB");
|
||||
expect(available).toBe("2.6 TiB");
|
||||
});
|
||||
|
||||
it("uses a smaller unit when both values fit in it", () => {
|
||||
const { used, available } = formatBytesPair(1024 * 1024 * 100, 1024 * 1024 * 200);
|
||||
expect(used).toBe("100.0 MiB");
|
||||
expect(available).toBe("200.0 MiB");
|
||||
});
|
||||
|
||||
it("falls back to zero when both inputs are invalid", () => {
|
||||
const { used, available } = formatBytesPair(Number.NaN, -1);
|
||||
expect(used).toBe("0.0 B");
|
||||
expect(available).toBe("0.0 B");
|
||||
});
|
||||
|
||||
it("uses the same unit across both values when one is invalid", () => {
|
||||
const { used, available } = formatBytesPair(Number.NaN, 1024 ** 4);
|
||||
expect(used).toBe("0.0 TiB");
|
||||
expect(available).toBe("1.0 TiB");
|
||||
});
|
||||
|
||||
it("honours the decimal unit option", () => {
|
||||
const { used, available } = formatBytesPair(500_000_000_000, 1_500_000_000_000, { unit: "decimal" });
|
||||
expect(used).toBe("0.5 TB");
|
||||
expect(available).toBe("1.5 TB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatByteRate", () => {
|
||||
it("appends a /s suffix to the formatted value", () => {
|
||||
expect(formatByteRate(0)).toBe("0.0 B/s");
|
||||
expect(formatByteRate(1024)).toBe("1.0 KiB/s");
|
||||
expect(formatByteRate(1024 ** 3)).toBe("1.0 GiB/s");
|
||||
});
|
||||
|
||||
it("honours the decimal unit option", () => {
|
||||
expect(formatByteRate(1000, { unit: "decimal" })).toBe("1.0 KB/s");
|
||||
expect(formatByteRate(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB/s");
|
||||
});
|
||||
|
||||
it("returns zero for invalid input", () => {
|
||||
expect(formatByteRate(-1)).toBe("0.0 B/s");
|
||||
expect(formatByteRate(Number.NaN)).toBe("0.0 B/s");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import type { AgentOptions } from "node:https";
|
||||
import { Agent as HttpsAgent } from "node:https";
|
||||
import { checkServerIdentity } from "node:tls";
|
||||
import axios from "axios";
|
||||
import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
|
||||
import type { RequestInfo, RequestInit, Response } from "undici";
|
||||
import { fetch } from "undici";
|
||||
|
||||
import {
|
||||
@@ -29,15 +29,11 @@ export const createCustomCheckServerIdentity = (
|
||||
};
|
||||
};
|
||||
|
||||
export const createCertificateAgentAsync = async (
|
||||
override?: {
|
||||
ca: string | string[];
|
||||
checkServerIdentity: typeof checkServerIdentity;
|
||||
},
|
||||
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
|
||||
) => {
|
||||
export const createCertificateAgentAsync = async (override?: {
|
||||
ca: string | string[];
|
||||
checkServerIdentity: typeof checkServerIdentity;
|
||||
}) => {
|
||||
return new UndiciHttpAgent({
|
||||
...agentOptions,
|
||||
connect: override ?? {
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
@@ -65,18 +61,14 @@ export const createAxiosCertificateInstanceAsync = async (
|
||||
|
||||
export const fetchWithTrustedCertificatesAsync = async (
|
||||
url: RequestInfo,
|
||||
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
|
||||
options?: RequestInit & { timeout?: number },
|
||||
): Promise<Response> => {
|
||||
const agent = await createCertificateAgentAsync(
|
||||
undefined,
|
||||
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
|
||||
);
|
||||
const agent = await createCertificateAgentAsync(undefined);
|
||||
if (options?.timeout) {
|
||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
|
||||
return await withTimeoutAsync(
|
||||
async (signal) =>
|
||||
fetch(url, {
|
||||
...fetchOptions,
|
||||
...options,
|
||||
signal,
|
||||
dispatcher: agent,
|
||||
}),
|
||||
@@ -84,9 +76,8 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
);
|
||||
}
|
||||
|
||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
|
||||
return fetch(url, {
|
||||
...fetchOptions,
|
||||
...options,
|
||||
dispatcher: agent,
|
||||
});
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `user` ADD `enable_right_click_on_widgets` boolean DEFAULT true NOT NULL;
|
||||
@@ -302,13 +302,6 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0042_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "5",
|
||||
"when": 1783852800000,
|
||||
"tag": "0043_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "user" ADD COLUMN "enable_right_click_on_widgets" boolean DEFAULT true NOT NULL;
|
||||