Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d3de49875 |
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,8 +33,6 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.72.0
|
||||
- 1.71.0
|
||||
- 1.70.0
|
||||
- 1.69.2
|
||||
- 1.69.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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||
extends: ["config:recommended", ":dependencyDashboardApproval"],
|
||||
extends: ["config:recommended"],
|
||||
packageRules: [
|
||||
{
|
||||
matchPackageNames: ["^@homarr/"],
|
||||
@@ -47,7 +47,7 @@
|
||||
rangeStrategy: "bump",
|
||||
automerge: false,
|
||||
baseBranchPatterns: ["dev"],
|
||||
dependencyDashboard: true,
|
||||
dependencyDashboard: false,
|
||||
// prevent special characters in branch names (otherwise git errors occur on windows)
|
||||
branchNameStrict: true,
|
||||
}
|
||||
@@ -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.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
|
||||
|
||||
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
|
||||
|
||||
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
|
||||
|
||||
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
|
||||
|
||||
+11
-30
@@ -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" }
|
||||
|
||||
@@ -55,9 +55,9 @@ A random secret can be generated by using the following command: `openssl rand -
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||
| ``DOCKER_PORTS`` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
|
||||
| ``DOCKER_SOCKET_PATHS`` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
| ``DOCKER_HOSTNAMES`` | Comma seperated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||
| ``DOCKER_PORTS`` | Comma seperated list of ports to connect to | For example `2375,2376` | - |
|
||||
| ``DOCKER_SOCKET_PATHS`` | Comma seperated list of paths to sockets | For example `/var/run/docker.sock,/run/user/1000/podman/podman.sock` | - |
|
||||
|
||||
## Database
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
|
||||
@@ -127,8 +127,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
|
||||
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
|
||||
|
||||
To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
Example Setup
|
||||
@@ -206,7 +204,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
|
||||
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
|
||||
| ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic |
|
||||
|
||||
### Permission System
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -259,7 +259,7 @@ httproute:
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: homarr
|
||||
port: 7575
|
||||
port: 8080
|
||||
````
|
||||
</details>
|
||||
|
||||
@@ -419,17 +419,17 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` |
|
||||
| fullnameOverride | string | `""` | Overrides chart's fullname |
|
||||
| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" |
|
||||
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
|
||||
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
|
||||
| httproute.enabled | bool | `false` | Enable HTTPRoute |
|
||||
| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) |
|
||||
| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. |
|
||||
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
|
||||
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
|
||||
| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) |
|
||||
| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression |
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.72.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.69.2"` | 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 |
|
||||
|
||||
@@ -55,74 +55,6 @@ services:
|
||||
With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`.
|
||||
In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock`
|
||||
|
||||
### Using Podman
|
||||
|
||||
On Linux, Homarr can connect to Podman's [Docker-compatible API](https://docs.podman.io/en/latest/markdown/podman-system-service.1.html).
|
||||
The Podman API socket must be running and mounted into the Homarr container.
|
||||
The socket grants full access to Podman, including arbitrary code execution as the user running the API, so only mount it into trusted containers.
|
||||
|
||||
For rootless Podman, enable the socket for the user that runs Homarr:
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now podman.socket
|
||||
```
|
||||
|
||||
The rootless socket is located at `$XDG_RUNTIME_DIR/podman/podman.sock` (usually `/run/user/<uid>/podman/podman.sock`).
|
||||
You can confirm the path with:
|
||||
|
||||
```bash
|
||||
podman info --format '{{.Host.RemoteSocket.Path}}'
|
||||
```
|
||||
|
||||
Mount that socket into the Homarr container and point `DOCKER_SOCKET_PATHS` to its path **inside** the container:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
container_name: homarr
|
||||
image: ghcr.io/homarr-labs/homarr:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
|
||||
- ./homarr/appdata:/appdata
|
||||
environment:
|
||||
SECRET_ENCRYPTION_KEY: your_64_character_hex_string
|
||||
DOCKER_SOCKET_PATHS: /var/run/podman.sock
|
||||
ports:
|
||||
- "7575:7575"
|
||||
```
|
||||
|
||||
Start Homarr with `podman compose up -d`.
|
||||
Run the compose project as the same user that owns the rootless Podman socket so `$XDG_RUNTIME_DIR` resolves to the correct path.
|
||||
To keep the user socket available after logout and across reboots, enable lingering with `loginctl enable-linger <user>`.
|
||||
|
||||
For rootful Podman, enable the system socket with `sudo systemctl enable --now podman.socket`.
|
||||
Its default path is `/run/podman/podman.sock`; mount that path instead and start Homarr with `sudo podman compose up -d`.
|
||||
|
||||
:::note SELinux
|
||||
On systems with SELinux enabled, Podman's documentation recommends disabling label separation for a container that accesses the API socket.
|
||||
Add the following to the Homarr service:
|
||||
|
||||
```yaml
|
||||
security_opt:
|
||||
- label=disable
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
You can connect Homarr to multiple Docker-compatible sockets by mounting each socket and listing their in-container paths, separated by commas:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
image: ghcr.io/homarr-labs/homarr:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
|
||||
environment:
|
||||
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
||||
```
|
||||
|
||||
### Managing your Docker containers within Homarr
|
||||
Homarr allows you to interact with Docker containers running on your system.
|
||||
Each container row has an action menu with the following options:
|
||||
|
||||
@@ -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>
|
||||
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
|
||||
|
||||
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
|
||||
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. 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.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 21 KiB |
@@ -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",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Immich Album Carousel'
|
||||
description: 'Shows a slideshow of pictures from an Immich album or your full library'
|
||||
description: 'Shows a slideshow of your album pictures from Immich'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
@@ -37,3 +37,4 @@ import screen from './img/screen.png';
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
|
||||
|
||||
@@ -4,16 +4,15 @@ import { IconPhoto } from "@tabler/icons-react";
|
||||
export const immichAlbumCarouselWidget: WidgetDefinition = {
|
||||
icon: IconPhoto,
|
||||
name: "Immich Album",
|
||||
description: "Shows a slideshow of pictures from an Immich album or your full library",
|
||||
description: "Shows a slideshow of your album pictures from Immich",
|
||||
path: "../../widgets/immich-album-carousel",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Album",
|
||||
description:
|
||||
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
|
||||
description: "Select the Immich album to show in the slideshow.",
|
||||
values: { type: "string" },
|
||||
defaultValue: "All photos",
|
||||
defaultValue: "",
|
||||
},
|
||||
{
|
||||
name: "Interval in seconds",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
@@ -34,7 +33,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 +90,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;
|
||||
});
|
||||
|
||||
|
||||
@@ -16,10 +16,7 @@ export default async function AppEditPage(props: AppEditPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (
|
||||
!session?.user.permissions.includes("board-modify-all") ||
|
||||
!session?.user.permissions.includes("app-modify-all")
|
||||
) {
|
||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||
notFound();
|
||||
}
|
||||
const app = await api.app.byId({ id: params.id });
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
export default async function AppNewPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
|
||||
if (!session?.user.permissions.includes("app-create")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -33,9 +33,6 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
if (!session.user.permissions.includes("board-modify-all")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
hidden: !session,
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
@@ -71,20 +71,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session?.user.permissions.includes("integration-create"),
|
||||
hidden: !session,
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
icon: IconApi,
|
||||
href: "/manage/custom-widgets",
|
||||
label: t("items.customWidgets"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
href: "/manage/search-engines",
|
||||
label: t("items.searchEngies"),
|
||||
hidden: !session?.user.permissions.includes("search-engine-create"),
|
||||
hidden: !session,
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
@@ -94,7 +94,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconPhotoFilled,
|
||||
href: "/manage/medias",
|
||||
label: t("items.medias"),
|
||||
hidden: !session?.user.permissions.includes("media-upload"),
|
||||
hidden: !session,
|
||||
"data-onboarding-tour-id": "manage-medias",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
-18
@@ -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>
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -19,7 +19,6 @@ import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
@@ -113,7 +112,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
||||
reset();
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
"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 { 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, QueryKey } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -19,12 +11,10 @@ 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();
|
||||
@@ -169,7 +158,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 +232,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 +273,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
};
|
||||
|
||||
interface WidgetQueryStatusProps {
|
||||
const WidgetCacheAge = ({
|
||||
queryClient,
|
||||
queryKey,
|
||||
isFetching,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
queryKey: QueryKey;
|
||||
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 latest = Math.max(...timestamps);
|
||||
const seconds = Math.floor((Date.now() - latest) / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
return `${Math.floor(seconds / 60)}m ago`;
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
|
||||
describe("removePersistedWidgetQueries", () => {
|
||||
test("removes stale widget queries and allows fresh data to be fetched", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
await expect(
|
||||
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
||||
).resolves.toBe("fresh");
|
||||
});
|
||||
|
||||
test("removes persisted queries outside the widget router", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(dockerQueryKey, "stale");
|
||||
queryClient.setQueryData(appQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
|
||||
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
});
|
||||
|
||||
test("removes persisted widget queries when the widget kind and query path differ", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
||||
|
||||
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
||||
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
||||
};
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.15.1"
|
||||
"pnpm": ">=11.10.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.15.1"
|
||||
"packageManager": "pnpm@11.10.0"
|
||||
}
|
||||
+6
-10
@@ -1,15 +1,11 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.72.0",
|
||||
"version": "1.70.0",
|
||||
"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:",
|
||||
@@ -69,7 +62,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.15.1"
|
||||
"pnpm": ">=11.10.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.15.1"
|
||||
"packageManager": "pnpm@11.10.0",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,7 @@ import { AppAccessControl } from "./app/app-access-control";
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("board-modify-all")
|
||||
getPaginated: protectedProcedure
|
||||
.input(paginatedSchema)
|
||||
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
||||
.meta({
|
||||
|
||||
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("board-modify-all")) {
|
||||
if (ctx.session?.user) {
|
||||
statistics.push({
|
||||
titleKey: "app",
|
||||
subtitleKey: "resources",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -76,33 +76,6 @@ describe("all should return all apps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPaginated should require board modification access", () => {
|
||||
test("should throw FORBIDDEN without board-modify-all", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
test("should return apps with board-modify-all", async () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["board-modify-all"]),
|
||||
});
|
||||
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).resolves.toMatchObject({
|
||||
totalCount: 1,
|
||||
items: [{ id: "1" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("byId should return an app by id", () => {
|
||||
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,45 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { BoundedAsyncQueue } from "../bounded-async-queue";
|
||||
|
||||
describe("BoundedAsyncQueue", () => {
|
||||
test("keeps only the newest values when the queue is full", async () => {
|
||||
const queue = new BoundedAsyncQueue<number>(2);
|
||||
queue.push(1);
|
||||
queue.push(2);
|
||||
queue.push(3);
|
||||
queue.close();
|
||||
|
||||
await expect(queue.next()).resolves.toEqual({ value: 2, done: false });
|
||||
await expect(queue.next()).resolves.toEqual({ value: 3, done: false });
|
||||
await expect(queue.next()).resolves.toEqual({ value: undefined, done: true });
|
||||
});
|
||||
|
||||
test("resolves a pending read as soon as a value arrives", async () => {
|
||||
const queue = new BoundedAsyncQueue<string>(1);
|
||||
const next = queue.next();
|
||||
|
||||
queue.push("latest");
|
||||
|
||||
await expect(next).resolves.toEqual({ value: "latest", done: false });
|
||||
});
|
||||
|
||||
test("resolves a pending read when the producer closes", async () => {
|
||||
const queue = new BoundedAsyncQueue<number>(1);
|
||||
const next = queue.next();
|
||||
|
||||
queue.close();
|
||||
|
||||
await expect(next).resolves.toEqual({ value: undefined, done: true });
|
||||
});
|
||||
|
||||
test("rejects a pending read when the producer fails", async () => {
|
||||
const queue = new BoundedAsyncQueue<number>(1);
|
||||
const next = queue.next();
|
||||
const error = new Error("upstream failed");
|
||||
|
||||
queue.fail(error);
|
||||
|
||||
await expect(next).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -13,10 +14,8 @@ import {
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { BoundedAsyncQueue } from "./bounded-async-queue";
|
||||
|
||||
const logger = createLogger({ module: "beszelRouter" });
|
||||
const MAX_PENDING_LIVE_EVENTS = 4;
|
||||
|
||||
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -204,94 +203,98 @@ export const beszelRouter = createTRPCRouter({
|
||||
systemId: z.string(),
|
||||
}),
|
||||
)
|
||||
.subscription(async function* ({ ctx, input, signal }) {
|
||||
const integration = ctx.integrations[0];
|
||||
if (!integration) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
|
||||
}
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<LiveStatsEvent>((emit) => {
|
||||
const controller = new AbortController();
|
||||
let isActive = true;
|
||||
let emittedEventCount = 0;
|
||||
|
||||
const queue = new BoundedAsyncQueue<LiveStatsEvent>(MAX_PENDING_LIVE_EVENTS);
|
||||
const controller = new AbortController();
|
||||
let emittedEventCount = 0;
|
||||
logger.debug("Beszel realtime subscription started", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds: ctx.integrations.map((integration) => integration.id),
|
||||
systemId: input.systemId,
|
||||
});
|
||||
|
||||
const stop = () => {
|
||||
controller.abort();
|
||||
void queue.return();
|
||||
};
|
||||
if (signal?.aborted) return;
|
||||
signal?.addEventListener("abort", stop, { once: true });
|
||||
|
||||
logger.debug("Beszel realtime subscription started", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds: ctx.integrations.map((candidate) => candidate.id),
|
||||
systemId: input.systemId,
|
||||
});
|
||||
|
||||
try {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
if (controller.signal.aborted) return;
|
||||
void (async () => {
|
||||
try {
|
||||
if (typeof instance.subscribeRealtimeMetrics !== "function") {
|
||||
throw new TRPCError({
|
||||
code: "METHOD_NOT_SUPPORTED",
|
||||
message: `Integration ${integration.kind} does not support realtime metrics`,
|
||||
});
|
||||
}
|
||||
|
||||
await instance.subscribeRealtimeMetrics(
|
||||
input.systemId,
|
||||
(event) => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
queue.push(event);
|
||||
},
|
||||
controller.signal,
|
||||
const integration = ctx.integrations[0];
|
||||
if (!integration) {
|
||||
emit.error(
|
||||
new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }),
|
||||
);
|
||||
queue.close();
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
queue.close();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn("Beszel realtime subscription failed", {
|
||||
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,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
queue.fail(
|
||||
error instanceof TRPCError
|
||||
? error
|
||||
: new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
emit.complete();
|
||||
}
|
||||
})();
|
||||
|
||||
for await (const event of queue) yield event;
|
||||
} finally {
|
||||
stop();
|
||||
signal?.removeEventListener("abort", stop);
|
||||
logger.debug("Beszel realtime subscription stopped", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
emittedEventCount,
|
||||
});
|
||||
}
|
||||
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();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
type QueueResult<T> = IteratorResult<T, undefined>;
|
||||
|
||||
type PendingRead<T> = {
|
||||
resolve: (result: QueueResult<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A small async queue for live streams.
|
||||
*
|
||||
* Live metrics are snapshots, so retaining every snapshot while a client is
|
||||
* slow is both unnecessary and dangerous. When the queue is full, discard
|
||||
* the oldest pending snapshot and keep the newest one.
|
||||
*/
|
||||
export class BoundedAsyncQueue<T> implements AsyncIterableIterator<T> {
|
||||
private readonly values: T[] = [];
|
||||
private pendingRead: PendingRead<T> | undefined;
|
||||
private closed = false;
|
||||
private failed = false;
|
||||
private failure: unknown;
|
||||
|
||||
public constructor(private readonly maxSize: number) {
|
||||
if (!Number.isInteger(maxSize) || maxSize < 1) {
|
||||
throw new Error("A bounded async queue requires a positive integer max size");
|
||||
}
|
||||
}
|
||||
|
||||
public push(value: T): void {
|
||||
if (this.closed) return;
|
||||
|
||||
if (this.pendingRead) {
|
||||
const pendingRead = this.pendingRead;
|
||||
this.pendingRead = undefined;
|
||||
pendingRead.resolve({ value, done: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.values.length >= this.maxSize) this.values.shift();
|
||||
this.values.push(value);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
|
||||
if (this.pendingRead) {
|
||||
const pendingRead = this.pendingRead;
|
||||
this.pendingRead = undefined;
|
||||
pendingRead.resolve({ value: undefined, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
public fail(error: unknown): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.failed = true;
|
||||
this.failure = error;
|
||||
this.values.length = 0;
|
||||
|
||||
if (this.pendingRead) {
|
||||
const pendingRead = this.pendingRead;
|
||||
this.pendingRead = undefined;
|
||||
pendingRead.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
public next(): Promise<QueueResult<T>> {
|
||||
if (this.values.length > 0) {
|
||||
return Promise.resolve({ value: this.values.shift() as T, done: false });
|
||||
}
|
||||
if (this.failed) return Promise.reject(this.failure);
|
||||
if (this.closed) return Promise.resolve({ value: undefined, done: true });
|
||||
|
||||
return new Promise<QueueResult<T>>((resolve, reject) => {
|
||||
this.pendingRead = { resolve, reject };
|
||||
});
|
||||
}
|
||||
|
||||
public return(): Promise<QueueResult<T>> {
|
||||
this.values.length = 0;
|
||||
this.close();
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
}
|
||||
|
||||
public [Symbol.asyncIterator](): AsyncIterableIterator<T> {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const immichRouter = createTRPCRouter({
|
||||
.concat(createImmichIntegrationMiddleware("query"))
|
||||
.input(
|
||||
z.object({
|
||||
albumId: z.string().optional(),
|
||||
albumId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,13 +71,13 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,15 +19,8 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
|
||||
options?: Options<TIntegration, TResult>,
|
||||
): Promise<TResult[]> {
|
||||
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
|
||||
const results: TResult[] = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
settled.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
results.push(result.value);
|
||||
return;
|
||||
}
|
||||
|
||||
return settled.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") return [result.value];
|
||||
const integration = integrations[index];
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
@@ -36,18 +29,7 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
|
||||
{ cause: result.reason },
|
||||
),
|
||||
);
|
||||
|
||||
if (options?.fallback && integration) {
|
||||
results.push(options.fallback(integration, result.reason));
|
||||
return;
|
||||
}
|
||||
|
||||
errors.push(result.reason);
|
||||
if (options?.fallback && integration) return [options.fallback(integration, result.reason)];
|
||||
return [];
|
||||
});
|
||||
|
||||
if (results.length === 0 && errors.length > 0) {
|
||||
throw errors[0];
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { settleIntegrationQueries } from "../settle-integrations";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
logger: { warn: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => mocks.logger,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs/error", () => ({
|
||||
ErrorWithMetadata: Error,
|
||||
}));
|
||||
|
||||
const integrations = [
|
||||
{ id: "first", name: "First", kind: "plex" },
|
||||
{ id: "second", name: "Second", kind: "jellyfin" },
|
||||
];
|
||||
|
||||
describe("settleIntegrationQueries", () => {
|
||||
test("returns an empty result when no integrations are configured", async () => {
|
||||
await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps successful integrations when another integration fails", async () => {
|
||||
const error = new Error("Plex unavailable");
|
||||
|
||||
await expect(
|
||||
settleIntegrationQueries(integrations, (integration) =>
|
||||
integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name),
|
||||
),
|
||||
).resolves.toEqual(["Second"]);
|
||||
});
|
||||
|
||||
test("rejects when every integration fails", async () => {
|
||||
const error = new Error("Integration unavailable");
|
||||
|
||||
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toBe(error);
|
||||
});
|
||||
|
||||
test("uses configured fallbacks when every integration fails", async () => {
|
||||
await expect(
|
||||
settleIntegrationQueries(integrations, () => Promise.reject(new Error("Integration unavailable")), {
|
||||
fallback: (integration) => integration.name,
|
||||
}),
|
||||
).resolves.toEqual(["First", "Second"]);
|
||||
});
|
||||
});
|
||||
@@ -6,24 +6,20 @@ import { and, eq } from "@homarr/db";
|
||||
import { accounts, sessions, users } from "@homarr/db/schema";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
|
||||
export const createAdapter = (
|
||||
db: Database,
|
||||
provider: SupportedAuthProvider | "unknown",
|
||||
enableDangerousCredentialsLinking = false,
|
||||
): Adapter => {
|
||||
export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
|
||||
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
|
||||
|
||||
return {
|
||||
...drizzleAdapter,
|
||||
// Keep users isolated by their primary provider. The explicit credentials-linking
|
||||
// opt-in is the only exception and keeps credentials as the primary provider.
|
||||
// We override the default implementation as we want to have a provider
|
||||
// flag in the user instead of the account to not intermingle users from different providers
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getUserByEmail: async (email) => {
|
||||
if (provider === "unknown") {
|
||||
throw new Error("Unable to get user by email for unknown provider");
|
||||
}
|
||||
|
||||
let user = await db.query.users.findFirst({
|
||||
const user = await db.query.users.findFirst({
|
||||
where: and(eq(users.email, email), eq(users.provider, provider)),
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -34,22 +30,6 @@ export const createAdapter = (
|
||||
},
|
||||
});
|
||||
|
||||
if (!user && provider === "oidc" && enableDangerousCredentialsLinking) {
|
||||
// Credentials accounts do not have an email-verification flow, so their
|
||||
// Auth.js emailVerified field is always null. The OIDC profile's strict
|
||||
// email_verified check is the security boundary for this opt-in.
|
||||
user = await db.query.users.findFirst({
|
||||
where: and(eq(users.email, email), eq(users.provider, "credentials")),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const createConfiguration = (
|
||||
headers: ReadonlyHeaders | null,
|
||||
useSecureCookies: boolean,
|
||||
) => {
|
||||
const adapter = createAdapter(db, provider, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING);
|
||||
const adapter = createAdapter(db, provider);
|
||||
return NextAuth({
|
||||
logger: {
|
||||
error: (error) => {
|
||||
|
||||
@@ -46,7 +46,6 @@ export const env = createEnv({
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
|
||||
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: createBooleanSchema(false),
|
||||
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: z
|
||||
.enum(["client_secret_basic", "client_secret_post", "client_secret_jwt", "none"])
|
||||
.default("client_secret_basic"),
|
||||
|
||||
@@ -24,7 +24,6 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
name: true,
|
||||
image: true,
|
||||
colorScheme: true,
|
||||
provider: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,7 +33,6 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
// Groups from oidc provider are provided from the profile, it's not typed.
|
||||
if (
|
||||
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
|
||||
dbUser.provider === "oidc" &&
|
||||
profile &&
|
||||
groupsKey in profile &&
|
||||
Array.isArray(profile[groupsKey])
|
||||
@@ -59,7 +57,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
});
|
||||
}
|
||||
|
||||
if (profile && dbUser.provider === "oidc") {
|
||||
if (profile) {
|
||||
const profileUsername = extractProfileName(profile);
|
||||
if (!profileUsername) {
|
||||
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
|
||||
|
||||
import { env } from "../../env";
|
||||
import { createRedirectUri } from "../../redirect";
|
||||
import { assertVerifiedEmailForCredentialsLinking } from "./verified-email";
|
||||
|
||||
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
|
||||
id: "oidc",
|
||||
@@ -19,8 +18,7 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
|
||||
token_endpoint_auth_method: env.AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD,
|
||||
},
|
||||
issuer: env.AUTH_OIDC_ISSUER,
|
||||
allowDangerousEmailAccountLinking:
|
||||
env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING || env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING,
|
||||
allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: env.AUTH_OIDC_SCOPE_OVERWRITE,
|
||||
@@ -57,7 +55,6 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
|
||||
if (!profile.sub) {
|
||||
throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`);
|
||||
}
|
||||
assertVerifiedEmailForCredentialsLinking(profile, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING);
|
||||
const name = extractProfileName(profile);
|
||||
if (!name) {
|
||||
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export const assertVerifiedEmailForCredentialsLinking = (
|
||||
profile: { email?: unknown; email_verified?: unknown },
|
||||
isCredentialsLinkingEnabled: boolean,
|
||||
) => {
|
||||
if (
|
||||
isCredentialsLinkingEnabled &&
|
||||
(profile.email_verified !== true || typeof profile.email !== "string" || profile.email.trim().length === 0)
|
||||
) {
|
||||
throw new Error("OIDC provider did not return a verified email while credentials linking is enabled");
|
||||
}
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { OidcProvider } from "../oidc/oidc-provider";
|
||||
import { assertVerifiedEmailForCredentialsLinking } from "../oidc/verified-email";
|
||||
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
AUTH_OIDC_CLIENT_ID: "client-id",
|
||||
AUTH_OIDC_CLIENT_NAME: "OIDC",
|
||||
AUTH_OIDC_CLIENT_SECRET: "client-secret",
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: false,
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: false,
|
||||
AUTH_OIDC_FORCE_USERINFO: false,
|
||||
AUTH_OIDC_ISSUER: "https://example.com",
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: undefined,
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: "openid email profile",
|
||||
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: "client_secret_basic" as const,
|
||||
}));
|
||||
|
||||
vi.mock("../../env", () => ({ env: mockEnv }));
|
||||
vi.mock("next-auth", () => ({ customFetch: Symbol("customFetch") }));
|
||||
|
||||
afterEach(() => {
|
||||
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = false;
|
||||
});
|
||||
|
||||
describe("assertVerifiedEmailForCredentialsLinking", () => {
|
||||
test("allows a strictly verified OIDC email when credentials linking is enabled", () => {
|
||||
expect(() =>
|
||||
assertVerifiedEmailForCredentialsLinking({ email: "test@example.com", email_verified: true }, true),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ email: "test@example.com", email_verified: false },
|
||||
{ email: "test@example.com", email_verified: "true" },
|
||||
{ email_verified: true },
|
||||
{ email: null, email_verified: true },
|
||||
{ email: " ", email_verified: true },
|
||||
{},
|
||||
])("rejects an OIDC profile without a strictly verified email when credentials linking is enabled", (profile) => {
|
||||
expect(() => assertVerifiedEmailForCredentialsLinking(profile, true)).toThrow(
|
||||
"OIDC provider did not return a verified email while credentials linking is enabled",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not require the claim when credentials linking is disabled", () => {
|
||||
expect(() => assertVerifiedEmailForCredentialsLinking({}, false)).not.toThrow();
|
||||
});
|
||||
|
||||
test("OIDC provider rejects an unverified profile when credentials linking is enabled", () => {
|
||||
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = true;
|
||||
const provider = OidcProvider(null);
|
||||
expect(provider.allowDangerousEmailAccountLinking).toBe(true);
|
||||
const mapProfile = provider.profile;
|
||||
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
|
||||
|
||||
expect(() =>
|
||||
mapProfile(
|
||||
{
|
||||
sub: "subject",
|
||||
preferred_username: "test",
|
||||
email: "test@example.com",
|
||||
email_verified: false,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toThrow("OIDC provider did not return a verified email while credentials linking is enabled");
|
||||
});
|
||||
|
||||
test("OIDC provider accepts an unverified profile when credentials linking is disabled", () => {
|
||||
const provider = OidcProvider(null);
|
||||
const mapProfile = provider.profile;
|
||||
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
|
||||
|
||||
expect(() =>
|
||||
mapProfile(
|
||||
{
|
||||
sub: "subject",
|
||||
preferred_username: "test",
|
||||
email: "test@example.com",
|
||||
email_verified: false,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import { accounts, apiKeys, groupMembers, groups, users } from "@homarr/db/schema";
|
||||
import { users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { createAdapter } from "../adapter";
|
||||
@@ -65,91 +64,4 @@ describe("createAdapter should create drizzle adapter", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Unable to get user by email for unknown provider");
|
||||
});
|
||||
|
||||
test("getUserByEmail should return a credentials user for oidc when credentials linking is enabled", async () => {
|
||||
const db = createDb();
|
||||
const adapter = createAdapter(db, "oidc", true);
|
||||
const email = "test@example.com";
|
||||
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
|
||||
|
||||
const user = await adapter.getUserByEmail?.(email);
|
||||
|
||||
expect(user?.id).toBe("credentials-user");
|
||||
});
|
||||
|
||||
test("getUserByEmail should not return credentials users for non-oidc providers", async () => {
|
||||
const db = createDb();
|
||||
const adapter = createAdapter(db, "ldap", true);
|
||||
const email = "test@example.com";
|
||||
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
|
||||
|
||||
const user = await adapter.getUserByEmail?.(email);
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
test("getUserByEmail should prefer an existing oidc user over a credentials user", async () => {
|
||||
const db = createDb();
|
||||
const adapter = createAdapter(db, "oidc", true);
|
||||
const email = "test@example.com";
|
||||
await db.insert(users).values([
|
||||
{ id: "credentials-user", name: "credentials", email, provider: "credentials" },
|
||||
{ id: "oidc-user", name: "oidc", email, provider: "oidc" },
|
||||
]);
|
||||
|
||||
const user = await adapter.getUserByEmail?.(email);
|
||||
|
||||
expect(user?.id).toBe("oidc-user");
|
||||
});
|
||||
|
||||
test("linkAccount should preserve the credentials user and their data", async () => {
|
||||
const db = createDb();
|
||||
const adapter = createAdapter(db, "oidc", true);
|
||||
const email = "test@example.com";
|
||||
await db.insert(users).values({
|
||||
id: "credentials-user",
|
||||
name: "test",
|
||||
email,
|
||||
password: "hashed-password",
|
||||
provider: "credentials",
|
||||
colorScheme: "light",
|
||||
completedBoardTour: true,
|
||||
});
|
||||
await db.insert(groups).values({ id: "group", name: "Test group", position: 1 });
|
||||
await db.insert(groupMembers).values({ groupId: "group", userId: "credentials-user" });
|
||||
await db.insert(apiKeys).values({ id: "api-key", apiKey: "secret", userId: "credentials-user" });
|
||||
|
||||
const user = await adapter.getUserByEmail?.(email);
|
||||
if (!user) throw new Error("Expected credentials user to be found for OIDC linking");
|
||||
await adapter.linkAccount?.({
|
||||
userId: user.id,
|
||||
type: "oidc",
|
||||
provider: "oidc",
|
||||
providerAccountId: "oidc-subject",
|
||||
});
|
||||
|
||||
const linkedUser = await db.query.users.findFirst({ where: eq(users.id, "credentials-user") });
|
||||
const account = await db.query.accounts.findFirst({ where: eq(accounts.providerAccountId, "oidc-subject") });
|
||||
const membership = await db.query.groupMembers.findFirst({
|
||||
where: eq(groupMembers.userId, "credentials-user"),
|
||||
});
|
||||
const apiKey = await db.query.apiKeys.findFirst({ where: eq(apiKeys.userId, "credentials-user") });
|
||||
const userByAccount = await adapter.getUserByAccount?.({
|
||||
provider: "oidc",
|
||||
providerAccountId: "oidc-subject",
|
||||
});
|
||||
const allUsers = await db.query.users.findMany();
|
||||
expect(linkedUser).toMatchObject({
|
||||
id: "credentials-user",
|
||||
password: "hashed-password",
|
||||
provider: "credentials",
|
||||
colorScheme: "light",
|
||||
completedBoardTour: true,
|
||||
});
|
||||
expect(account?.userId).toBe("credentials-user");
|
||||
expect(membership?.groupId).toBe("group");
|
||||
expect(apiKey?.id).toBe("api-key");
|
||||
expect(userByAccount?.id).toBe("credentials-user");
|
||||
expect(allUsers).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -135,7 +135,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
test("should add missing group membership", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db, "oidc");
|
||||
await createUserAsync(db);
|
||||
await createGroupAsync(db);
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
test("should remove group membership", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db, "oidc");
|
||||
await createUserAsync(db);
|
||||
await createGroupAsync(db);
|
||||
await db.insert(groupMembers).values({
|
||||
userId: "1",
|
||||
@@ -179,7 +179,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
test("should not remove group membership for everyone group", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db, "oidc");
|
||||
await createUserAsync(db);
|
||||
await createGroupAsync(db, everyoneGroup);
|
||||
await db.insert(groupMembers).values({
|
||||
userId: "1",
|
||||
@@ -204,7 +204,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
// Arrange
|
||||
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = true;
|
||||
const db = createDb();
|
||||
await createUserAsync(db, "oidc");
|
||||
await createUserAsync(db);
|
||||
await createGroupAsync(db);
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
@@ -229,10 +229,10 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
["ldap" as const, { name: "test-new" }, undefined],
|
||||
["oidc" as const, { name: "test" }, { preferred_username: "test-new" }],
|
||||
["oidc" as const, { name: "test" }, { preferred_username: "test@example.com", name: "test-new" }],
|
||||
])("signInEventHandler should update username for %s provider", async (provider, user, profile) => {
|
||||
])("signInEventHandler should update username for %s provider", async (_provider, user, profile) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db, provider);
|
||||
await createUserAsync(db);
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
// Act
|
||||
@@ -251,32 +251,6 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
});
|
||||
expect(dbUser?.name).toBe("test-new");
|
||||
});
|
||||
test("signInEventHandler should preserve credentials-owned data when the user signs in with linked oidc", async () => {
|
||||
const db = createDb();
|
||||
await createUserAsync(db, "credentials", "local-avatar");
|
||||
await createGroupAsync(db);
|
||||
await db.insert(groupMembers).values({ userId: "1", groupId: "1" });
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
await eventHandler?.({
|
||||
user: { id: "1", name: "test" },
|
||||
profile: {
|
||||
preferred_username: "oidc-name",
|
||||
picture: "oidc-avatar",
|
||||
someRandomGroupsKey: [],
|
||||
},
|
||||
account: null,
|
||||
});
|
||||
|
||||
const dbUser = await db.query.users.findFirst({ where: eq(users.id, "1") });
|
||||
const membership = await db.query.groupMembers.findFirst({ where: eq(groupMembers.userId, "1") });
|
||||
expect(dbUser).toMatchObject({
|
||||
name: "test",
|
||||
image: "local-avatar",
|
||||
provider: "credentials",
|
||||
});
|
||||
expect(membership?.groupId).toBe("1");
|
||||
});
|
||||
test("signInEventHandler should set color-scheme cookie", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
@@ -301,17 +275,11 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const createUserAsync = async (
|
||||
db: Database,
|
||||
provider: "credentials" | "ldap" | "oidc" = "credentials",
|
||||
image?: string,
|
||||
) =>
|
||||
const createUserAsync = async (db: Database) =>
|
||||
await db.insert(users).values({
|
||||
id: "1",
|
||||
name: "test",
|
||||
colorScheme: "dark",
|
||||
image,
|
||||
provider,
|
||||
});
|
||||
|
||||
const createGroupAsync = async (db: Database, name = "test") =>
|
||||
|
||||
@@ -67,14 +67,12 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
url: RequestInfo,
|
||||
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
|
||||
): Promise<Response> => {
|
||||
const agent =
|
||||
options?.dispatcher ??
|
||||
(await createCertificateAgentAsync(
|
||||
undefined,
|
||||
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
|
||||
));
|
||||
const agent = await createCertificateAgentAsync(
|
||||
undefined,
|
||||
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
|
||||
);
|
||||
if (options?.timeout) {
|
||||
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options;
|
||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
|
||||
return await withTimeoutAsync(
|
||||
async (signal) =>
|
||||
fetch(url, {
|
||||
@@ -86,7 +84,7 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
);
|
||||
}
|
||||
|
||||
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
|
||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
|
||||
return fetch(url, {
|
||||
...fetchOptions,
|
||||
dispatcher: agent,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `user` ADD `enable_right_click_on_widgets` boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -64,13 +64,6 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0008_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1783786677702,
|
||||
"tag": "0009_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `user` ADD `enable_right_click_on_widgets` integer DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -288,13 +288,6 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0040_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1783786658943,
|
||||
"tag": "0041_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,6 @@ export const users = mysqlTable("user", {
|
||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean().default(false).notNull(),
|
||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||
completedManageTour: boolean().default(false).notNull(),
|
||||
completedBoardTour: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -85,7 +85,6 @@ export const users = pgTable("user", {
|
||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean().default(false).notNull(),
|
||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||
completedManageTour: boolean().default(false).notNull(),
|
||||
completedBoardTour: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -68,7 +68,6 @@ export const users = sqliteTable("user", {
|
||||
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
|
||||
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
|
||||
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -37,5 +37,5 @@ describe("Mysql Migration", () => {
|
||||
|
||||
connection.end();
|
||||
await mysqlContainer.stop();
|
||||
}, 120_000);
|
||||
}, 40_000);
|
||||
});
|
||||
@@ -467,6 +467,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
|
||||
category: ["archiving"],
|
||||
documentationUrl: null,
|
||||
defaultUrl: "http://localhost:8001",
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
|
||||
@@ -19,12 +19,6 @@ const createUrlByIdChannel = (id: string) =>
|
||||
headers: `${string}.${string}`;
|
||||
}>(`image-proxy:url:${id}`);
|
||||
|
||||
export type ForwardImageResult =
|
||||
| { image: ArrayBuffer; contentType: string | null }
|
||||
| { error: "not-found" }
|
||||
| { error: "upstream-error"; statusCode: number }
|
||||
| { error: "fetch-error" };
|
||||
|
||||
export class ImageProxy {
|
||||
private static hmacKey: Buffer<ArrayBuffer> | null = null;
|
||||
|
||||
@@ -53,34 +47,17 @@ export class ImageProxy {
|
||||
return this.createImageUrl(id);
|
||||
}
|
||||
|
||||
public async forwardImageAsync(id: string): Promise<ForwardImageResult> {
|
||||
public async forwardImageAsync(id: string): Promise<ArrayBuffer | null> {
|
||||
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
|
||||
if (!urlAndHeaders) {
|
||||
return { error: "not-found" };
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
|
||||
headers: urlAndHeaders.headers ?? {},
|
||||
});
|
||||
|
||||
const proxyUrl = this.createImageUrl(id);
|
||||
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
|
||||
try {
|
||||
response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
|
||||
headers: urlAndHeaders.headers ?? {},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to connect to image source",
|
||||
{
|
||||
id,
|
||||
url: this.redactUrl(urlAndHeaders.url),
|
||||
headers: this.redactHeaders(urlAndHeaders.headers),
|
||||
proxyUrl,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
return { error: "fetch-error" };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata("Failed to fetch image", {
|
||||
@@ -91,7 +68,7 @@ export class ImageProxy {
|
||||
statusCode: response.status,
|
||||
}),
|
||||
);
|
||||
return { error: "upstream-error", statusCode: response.status };
|
||||
return null;
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
@@ -103,7 +80,7 @@ export class ImageProxy {
|
||||
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
|
||||
});
|
||||
|
||||
return { image: arrayBuffer, contentType: response.headers.get("content-type") };
|
||||
return arrayBuffer;
|
||||
}
|
||||
|
||||
private createImageUrl(id: string): string {
|
||||
|
||||
@@ -23,15 +23,10 @@ export const createSessionStore = <TValue>(integration: { id: string }) => {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param value session to store
|
||||
* @param options pass `ttlSeconds` when the credential has a known lifetime, so a stored
|
||||
* session can never outlive the token it holds
|
||||
*/
|
||||
async setAsync(value: TValue, options?: { ttlSeconds?: number }) {
|
||||
logger.debug("Updating session in store", { store: channelName, ttlSeconds: options?.ttlSeconds });
|
||||
async setAsync(value: TValue) {
|
||||
logger.debug("Updating session in store", { store: channelName });
|
||||
try {
|
||||
await channel.setAsync(encryptSecret(superjson.stringify(value)), options);
|
||||
await channel.setAsync(encryptSecret(superjson.stringify(value)));
|
||||
} catch (error) {
|
||||
logger.error(new ErrorWithMetadata("Failed to save session", { store: channelName }, { cause: error }));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import type { Response as UndiciResponse } from "undici";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
@@ -82,47 +80,11 @@ export const normalizeRealtimeSnapshot = (
|
||||
return events;
|
||||
};
|
||||
|
||||
export interface BeszelSession {
|
||||
interface BeszelSession {
|
||||
token: string;
|
||||
userId: string;
|
||||
/** Epoch milliseconds, taken from the token's `exp` claim. Undefined when it could not be read. */
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-authenticate this long before the token actually lapses, so a request can never be sent
|
||||
* with a token that expires while it is in flight.
|
||||
*/
|
||||
const sessionExpiryLeewayMs = 60_000;
|
||||
|
||||
/**
|
||||
* Reads the expiration out of a PocketBase auth token.
|
||||
*
|
||||
* @returns expiry as epoch milliseconds, or null when the token is malformed or carries no
|
||||
* numeric `exp` claim — in which case we fall back to the existing 401 retry path.
|
||||
*/
|
||||
export const parseTokenExpiration = (token: string): number | null => {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) return null;
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
||||
const { exp } = JSON.parse(decoded) as { exp?: unknown };
|
||||
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Beszel answers requests carrying an expired token with `200` and an empty list rather than `401`,
|
||||
* because its collection rules filter on `@request.auth.id` instead of rejecting. The 401 retry in
|
||||
* `fetchWithAuthAsync` therefore never fires, so a stored session would otherwise be trusted
|
||||
* forever. Sessions have to expire themselves.
|
||||
*/
|
||||
export const isSessionExpired = (session: BeszelSession, now = Date.now()) =>
|
||||
session.expiresAt !== undefined && session.expiresAt - sessionExpiryLeewayMs <= now;
|
||||
|
||||
export class BeszelIntegration extends Integration {
|
||||
private readonly sessionStore: SessionStore<BeszelSession>;
|
||||
|
||||
@@ -133,19 +95,11 @@ export class BeszelIntegration extends Integration {
|
||||
|
||||
private async authenticateAsync(): Promise<BeszelSession> {
|
||||
const existingSession = await this.sessionStore.getAsync();
|
||||
if (existingSession && !isSessionExpired(existingSession)) {
|
||||
if (existingSession) {
|
||||
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
|
||||
return existingSession;
|
||||
}
|
||||
|
||||
if (existingSession) {
|
||||
logger.info("Stored Beszel session expired, re-authenticating", {
|
||||
integrationId: this.integration.id,
|
||||
expiresAt: existingSession.expiresAt,
|
||||
});
|
||||
await this.sessionStore.clearAsync();
|
||||
}
|
||||
|
||||
const authUrl = this.url("/api/collections/users/auth-with-password");
|
||||
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
|
||||
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
|
||||
@@ -164,30 +118,9 @@ export class BeszelIntegration extends Integration {
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BeszelAuthResponse;
|
||||
const expiresAt = parseTokenExpiration(data.token) ?? undefined;
|
||||
|
||||
// A token that has already lapsed by the time it reaches us means our clock and Beszel's
|
||||
// disagree by more than the token lifetime. Re-authenticating would only mint another dead
|
||||
// token, and using this one would silently produce the empty responses this check exists to
|
||||
// prevent — so fail loudly instead.
|
||||
if (expiresAt !== undefined && expiresAt <= Date.now()) {
|
||||
logger.warn("Beszel issued an already-expired token", { integrationId: this.integration.id, expiresAt });
|
||||
throw new ErrorWithMetadata("Beszel issued an already-expired authentication token, check for clock skew", {
|
||||
integrationId: this.integration.id,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
const session: BeszelSession = { token: data.token, userId: data.record.id, expiresAt };
|
||||
// Give the stored entry the token's own lifetime, so the cache cannot outlive the credential
|
||||
// even if the session is never read again before it lapses.
|
||||
const ttlSeconds = expiresAt === undefined ? undefined : Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
|
||||
await this.sessionStore.setAsync(session, ttlSeconds === undefined ? undefined : { ttlSeconds });
|
||||
logger.debug("Saved Beszel session", {
|
||||
integrationId: this.integration.id,
|
||||
userId: session.userId,
|
||||
expiresAt,
|
||||
});
|
||||
const session: BeszelSession = { token: data.token, userId: data.record.id };
|
||||
await this.sessionStore.setAsync(session);
|
||||
logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -424,41 +357,19 @@ export class BeszelIntegration extends Integration {
|
||||
let emittedSnapshotCount = 0;
|
||||
|
||||
while (!signal.aborted) {
|
||||
let streamResponse: UndiciResponse | undefined;
|
||||
let streamAgent: Awaited<ReturnType<typeof createCertificateAgentAsync>> | undefined;
|
||||
const closeStreamAsync = async () => {
|
||||
const response = streamResponse;
|
||||
streamResponse = undefined;
|
||||
const agent = streamAgent;
|
||||
streamAgent = undefined;
|
||||
|
||||
if (response?.body) await response.body.cancel().catch(() => {});
|
||||
if (agent) await agent.close().catch(() => {});
|
||||
};
|
||||
|
||||
try {
|
||||
let session = await this.authenticateAsync();
|
||||
const openStream = async () => {
|
||||
streamAgent = await createCertificateAgentAsync(undefined, { bodyTimeout: 0 });
|
||||
try {
|
||||
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Authorization: session.token,
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
signal,
|
||||
bodyTimeout: 0,
|
||||
dispatcher: streamAgent,
|
||||
});
|
||||
streamResponse = response;
|
||||
return response;
|
||||
} catch (error) {
|
||||
await closeStreamAsync();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const openStream = async () =>
|
||||
await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Authorization: session.token,
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
signal,
|
||||
bodyTimeout: 0,
|
||||
});
|
||||
|
||||
logger.debug("Opening Beszel rt_metrics SSE connection", {
|
||||
integrationId: this.integration.id,
|
||||
@@ -467,7 +378,6 @@ export class BeszelIntegration extends Integration {
|
||||
});
|
||||
let response = await openStream();
|
||||
if (response.status === 401) {
|
||||
await closeStreamAsync();
|
||||
await this.sessionStore.clearAsync();
|
||||
session = await this.authenticateAsync();
|
||||
response = await openStream();
|
||||
@@ -492,21 +402,14 @@ export class BeszelIntegration extends Integration {
|
||||
clientId = frame.id || fallbackClientId;
|
||||
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
|
||||
|
||||
if (!streamAgent) throw new Error("Beszel realtime stream agent is not available");
|
||||
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||
method: "POST",
|
||||
headers: { Authorization: session.token, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId, subscriptions: [topic] }),
|
||||
signal,
|
||||
dispatcher: streamAgent,
|
||||
});
|
||||
try {
|
||||
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
|
||||
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
|
||||
await subscribeResponse.arrayBuffer();
|
||||
} finally {
|
||||
if (subscribeResponse.body) await subscribeResponse.body.cancel().catch(() => {});
|
||||
}
|
||||
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
|
||||
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
|
||||
logger.debug("Subscribed to Beszel rt_metrics topic", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
@@ -558,12 +461,10 @@ export class BeszelIntegration extends Integration {
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
await closeStreamAsync();
|
||||
}
|
||||
|
||||
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
|
||||
} catch (error) {
|
||||
await closeStreamAsync();
|
||||
if (signal.aborted) break;
|
||||
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
|
||||
retryAttempt += 1;
|
||||
|
||||
@@ -10,7 +10,6 @@ vi.hoisted(() => {
|
||||
const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
|
||||
const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
|
||||
const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
|
||||
const closeAgent = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
createGetSetChannel: () => ({
|
||||
@@ -33,7 +32,7 @@ vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
|
||||
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
|
||||
createCertificateAgentAsync: vi.fn().mockResolvedValue({ close: closeAgent }),
|
||||
createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
@@ -154,7 +153,6 @@ describe("subscribeRealtimeMetrics", () => {
|
||||
|
||||
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
|
||||
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
|
||||
expect(closeAgent).toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
process.env.ENABLE_DNS_CACHING = "false";
|
||||
});
|
||||
|
||||
// A stateful stand-in for redis, so a session actually survives between calls and the
|
||||
// expiry logic can be exercised end to end.
|
||||
const store = vi.hoisted(() => new Map<string, { value: string; ttlSeconds?: number }>());
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
createGetSetChannel: (name: string) => ({
|
||||
getAsync: () => Promise.resolve(store.get(name)?.value ?? null),
|
||||
setAsync: (value: string, options?: { ttlSeconds?: number }) => {
|
||||
store.set(name, { value, ttlSeconds: options?.ttlSeconds });
|
||||
return Promise.resolve();
|
||||
},
|
||||
removeAsync: () => {
|
||||
store.delete(name);
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
||||
ErrorWithMetadata: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
|
||||
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
|
||||
createCertificateAgentAsync: vi.fn().mockResolvedValue({ close: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
||||
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
import { BeszelIntegration, isSessionExpired, parseTokenExpiration } from "../beszel-integration";
|
||||
|
||||
const base64Url = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const createToken = (expiresAtSeconds: number) => `header.${base64Url({ exp: expiresAtSeconds })}.signature`;
|
||||
|
||||
describe("parseTokenExpiration", () => {
|
||||
test("reads the exp claim as epoch milliseconds", () => {
|
||||
expect(parseTokenExpiration(createToken(1_800_000_000))).toBe(1_800_000_000_000);
|
||||
});
|
||||
|
||||
test("decodes base64url payloads containing - and _", () => {
|
||||
// A payload that base64-encodes with both + and /, which must be url-decoded first.
|
||||
const token = `header.${Buffer.from(JSON.stringify({ sub: "a>b?c>>", exp: 1_700_000_000 })).toString("base64url")}.sig`;
|
||||
expect(parseTokenExpiration(token)).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["not-a-jwt", "no segments"],
|
||||
["header.%%%not-base64%%%.signature", "undecodable payload"],
|
||||
[`header.${base64Url({ id: "user-1" })}.signature`, "no exp claim"],
|
||||
[`header.${base64Url({ exp: "soon" })}.signature`, "non-numeric exp"],
|
||||
])("returns null for %s (%s)", (token) => {
|
||||
expect(parseTokenExpiration(token)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const session = (expiresAt?: number) => ({ token: "t", userId: "u", expiresAt });
|
||||
|
||||
describe("isSessionExpired", () => {
|
||||
const now = Date.UTC(2026, 6, 26, 12, 0, 0);
|
||||
|
||||
test("treats a session with an unknown expiry as valid, falling back to the 401 retry", () => {
|
||||
expect(isSessionExpired(session(undefined), now)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps a session that is comfortably in the future", () => {
|
||||
expect(isSessionExpired(session(now + 60 * 60 * 1000), now)).toBe(false);
|
||||
});
|
||||
|
||||
test("expires a session that lapses within the leeway window", () => {
|
||||
expect(isSessionExpired(session(now + 30_000), now)).toBe(true);
|
||||
});
|
||||
|
||||
test("expires a session that has already lapsed", () => {
|
||||
expect(isSessionExpired(session(now - 1), now)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const createIntegration = () =>
|
||||
new BeszelIntegration({
|
||||
id: "test-beszel-session",
|
||||
name: "Test Beszel",
|
||||
url: "http://localhost:8090",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{ kind: "username", value: "user@example.com" },
|
||||
{ kind: "password", value: "password" },
|
||||
],
|
||||
});
|
||||
|
||||
describe("BeszelIntegration session lifecycle", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let authCount = 0;
|
||||
let issuedToken = "";
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
authCount = 0;
|
||||
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/collections/users/auth-with-password")) {
|
||||
authCount++;
|
||||
return Promise.resolve(Response.json({ token: issuedToken, record: { id: "user-1" } }));
|
||||
}
|
||||
if (url.includes("/api/collections/systems/records")) {
|
||||
return Promise.resolve(Response.json({ page: 1, perPage: 500, totalItems: 0, totalPages: 0, items: [] }));
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("reuses a stored session while its token is still valid", async () => {
|
||||
issuedToken = createToken(Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
|
||||
const integration = createIntegration();
|
||||
|
||||
await integration.getSystemsAsync();
|
||||
await integration.getSystemsAsync();
|
||||
|
||||
expect(authCount).toBe(1);
|
||||
});
|
||||
|
||||
test("persists the session with a ttl matching the token lifetime", async () => {
|
||||
const lifetimeSeconds = 7 * 24 * 60 * 60;
|
||||
issuedToken = createToken(Math.floor(Date.now() / 1000) + lifetimeSeconds);
|
||||
|
||||
await createIntegration().getSystemsAsync();
|
||||
|
||||
const stored = store.get("session-store:test-beszel-session");
|
||||
expect(stored?.ttlSeconds).toBeGreaterThan(lifetimeSeconds - 60);
|
||||
expect(stored?.ttlSeconds).toBeLessThanOrEqual(lifetimeSeconds);
|
||||
});
|
||||
|
||||
test("re-authenticates once the stored token has expired", async () => {
|
||||
// Beszel replies 200 + empty list to an expired token instead of 401, so without an expiry
|
||||
// check the stale session would be reused forever. See homarr-labs/homarr#6470.
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const lifetimeSeconds = 7 * 24 * 60 * 60;
|
||||
const issuedAt = new Date("2026-07-26T00:00:00.000Z");
|
||||
vi.setSystemTime(issuedAt);
|
||||
issuedToken = createToken(Math.floor(issuedAt.getTime() / 1000) + lifetimeSeconds);
|
||||
const integration = createIntegration();
|
||||
|
||||
await integration.getSystemsAsync();
|
||||
expect(authCount).toBe(1);
|
||||
|
||||
// Step past the token's lifetime, exactly as an idle Homarr instance would.
|
||||
vi.setSystemTime(new Date(issuedAt.getTime() + (lifetimeSeconds + 1) * 1000));
|
||||
issuedToken = createToken(Math.floor(Date.now() / 1000) + lifetimeSeconds);
|
||||
await integration.getSystemsAsync();
|
||||
|
||||
expect(authCount).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("uses a short-lived token that is valid now but lapses inside the leeway window", async () => {
|
||||
// The leeway exists to stop a token expiring mid-request; it must not reject a token that is
|
||||
// still valid, or an instance with a short authToken.duration could never authenticate.
|
||||
issuedToken = createToken(Math.floor(Date.now() / 1000) + 30);
|
||||
|
||||
await expect(createIntegration().getSystemsAsync()).resolves.toEqual([]);
|
||||
expect(authCount).toBe(1);
|
||||
expect(store.get("session-store:test-beszel-session")?.ttlSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("fails loudly when Beszel issues an already-expired token instead of returning empty data", async () => {
|
||||
issuedToken = createToken(Math.floor(Date.now() / 1000) - 600);
|
||||
|
||||
const caught = await createIntegration()
|
||||
.getSystemsAsync()
|
||||
.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
// The integration base class wraps thrown errors, so assert against the cause chain.
|
||||
const messages: string[] = [];
|
||||
for (let error: unknown = caught; error instanceof Error; error = error.cause) messages.push(error.message);
|
||||
expect(messages.join(" | ")).toMatch(/clock skew/i);
|
||||
|
||||
// The dead token must not be persisted, or it would be served to the next caller.
|
||||
expect(store.has("session-store:test-beszel-session")).toBe(false);
|
||||
expect(authCount).toBe(1);
|
||||
});
|
||||
|
||||
test("does not store a ttl when the token carries no readable expiry", async () => {
|
||||
issuedToken = "opaque-token-without-claims";
|
||||
|
||||
await createIntegration().getSystemsAsync();
|
||||
|
||||
expect(store.get("session-store:test-beszel-session")?.ttlSeconds).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "torrent";
|
||||
const client = await this.getClientAsync();
|
||||
const torrents = await client.listTorrents({ limit: input.limit });
|
||||
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
|
||||
const rates = torrents.reduce(
|
||||
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
||||
{ down: 0, up: 0 },
|
||||
|
||||
@@ -117,12 +117,6 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
state: null,
|
||||
positionMs: null,
|
||||
durationMs: null,
|
||||
},
|
||||
location: null,
|
||||
metadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import {
|
||||
AssetMediaSize,
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
getAlbumInfo,
|
||||
getAllAlbums,
|
||||
getMyUser,
|
||||
getServerStatistics,
|
||||
init,
|
||||
searchAssets,
|
||||
searchRandom,
|
||||
searchUsers,
|
||||
} from "@immich/sdk";
|
||||
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
|
||||
@@ -46,7 +42,6 @@ export interface ImmichAsset {
|
||||
}
|
||||
|
||||
const logger = createLogger({ module: "immich-integration" });
|
||||
const randomPhotoCount = 50;
|
||||
|
||||
export class ImmichIntegration extends Integration {
|
||||
constructor(integration: IntegrationInput) {
|
||||
@@ -67,36 +62,36 @@ export class ImmichIntegration extends Integration {
|
||||
};
|
||||
}
|
||||
|
||||
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> {
|
||||
public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
|
||||
this.initClient();
|
||||
const requestOptions = this.getRequestOptions();
|
||||
|
||||
if (!albumId) {
|
||||
const assets = await searchRandom(
|
||||
{
|
||||
randomSearchDto: {
|
||||
size: randomPhotoCount,
|
||||
type: AssetTypeEnum.Image,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
},
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
return {
|
||||
id: "",
|
||||
albumName: "",
|
||||
assets: await this.createAssetsAsync(assets),
|
||||
};
|
||||
}
|
||||
|
||||
const [album, albumAssets] = await Promise.all([
|
||||
getAlbumInfo({ id: albumId }, requestOptions),
|
||||
this.searchAlbumAssetsAsync(albumId),
|
||||
]);
|
||||
const imageProxy = new ImageProxy();
|
||||
return {
|
||||
albumName: album.albumName,
|
||||
assets: await this.createAssetsAsync(albumAssets),
|
||||
assets: await Promise.all(
|
||||
albumAssets.map(async (asset) => {
|
||||
const publicLink = await imageProxy.createImageAsync(
|
||||
this.url(`/api/assets/${asset.id}/original`).toString(),
|
||||
{
|
||||
"x-api-key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
);
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
thumbhash: asset.thumbhash,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
originalPath: asset.originalPath,
|
||||
updatedAt: asset.updatedAt,
|
||||
publicLink,
|
||||
};
|
||||
}),
|
||||
),
|
||||
id: album.id,
|
||||
};
|
||||
}
|
||||
@@ -138,32 +133,6 @@ export class ImmichIntegration extends Integration {
|
||||
return assets;
|
||||
}
|
||||
|
||||
private async createAssetsAsync(assets: AssetResponseDto[]): Promise<ImmichAsset[]> {
|
||||
const imageProxy = new ImageProxy();
|
||||
return await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
const publicLink = await imageProxy.createImageAsync(
|
||||
// This is the URL generated by the SDK's viewAsset call. Preview images are
|
||||
// browser-compatible while avoiding the large original asset download.
|
||||
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
|
||||
{
|
||||
"x-api-key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
);
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
thumbhash: asset.thumbhash,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
originalPath: asset.originalPath,
|
||||
updatedAt: asset.updatedAt,
|
||||
publicLink,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
|
||||
return {
|
||||
// Undici and node types are not the same
|
||||
|
||||
@@ -13,12 +13,6 @@ export interface StreamSession {
|
||||
episodeName?: string | null;
|
||||
albumName?: string | null;
|
||||
episodeCount?: number | null;
|
||||
playback: {
|
||||
state: "playing" | "paused" | "buffering" | null;
|
||||
positionMs: number | null;
|
||||
durationMs: number | null;
|
||||
};
|
||||
location: "lan" | "wan" | null;
|
||||
metadata: {
|
||||
video: {
|
||||
resolution: {
|
||||
|
||||
@@ -17,10 +17,6 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
|
||||
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
|
||||
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
|
||||
|
||||
function ticksToMs(ticks: number | null | undefined): number | null {
|
||||
return ticks ? Math.round(ticks / 10_000) : null;
|
||||
}
|
||||
|
||||
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
||||
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||
@@ -54,9 +50,6 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||
|
||||
if (sessionInfo.NowPlayingItem) {
|
||||
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
|
||||
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
|
||||
|
||||
currentlyPlaying = {
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
@@ -64,12 +57,6 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing",
|
||||
positionMs,
|
||||
durationMs,
|
||||
},
|
||||
location: null,
|
||||
metadata: {
|
||||
video: {
|
||||
resolution:
|
||||
|
||||
@@ -13,8 +13,6 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -29,8 +27,6 @@ const sessions: StreamSession[] = [
|
||||
episodeName: "Ozymandias",
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -45,8 +41,6 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -61,8 +55,6 @@ const sessions: StreamSession[] = [
|
||||
episodeName: "The We We Are",
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -77,8 +69,6 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: "Random Access Memories",
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
@@ -93,8 +83,6 @@ const sessions: StreamSession[] = [
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -153,9 +153,9 @@ export class OverseerrIntegration
|
||||
);
|
||||
} else if (pendingResults.length > 0) requests = pendingResults;
|
||||
else if (allResults.length > 0) requests = allResults;
|
||||
else return [];
|
||||
else return Promise.all([]);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
return await Promise.all(
|
||||
requests.map(async (request): Promise<MediaRequest> => {
|
||||
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
|
||||
|
||||
@@ -184,10 +184,6 @@ export class OverseerrIntegration
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return settled
|
||||
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
|
||||
.map((result) => result.value);
|
||||
}
|
||||
|
||||
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
|
||||
@@ -305,10 +301,6 @@ export class OverseerrIntegration
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (type === "tv") {
|
||||
const series = (await response.json()) as TvInformation;
|
||||
return {
|
||||
|
||||
@@ -3,54 +3,6 @@ interface MediaContainer {
|
||||
Track?: Session[];
|
||||
}
|
||||
|
||||
interface Stream {
|
||||
$: {
|
||||
streamType: string;
|
||||
codec?: string;
|
||||
language?: string;
|
||||
selected?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
frameRate?: string;
|
||||
channels?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Part {
|
||||
Stream?: Stream[];
|
||||
$: {
|
||||
container?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Media {
|
||||
Part?: Part[];
|
||||
$: {
|
||||
videoResolution?: string;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
bitrate?: string;
|
||||
container?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TranscodeSession {
|
||||
$: {
|
||||
throttled?: string;
|
||||
progress?: string;
|
||||
speed?: string;
|
||||
videoDecision?: string;
|
||||
audioDecision?: string;
|
||||
sourceVideoCodec?: string;
|
||||
sourceAudioCodec?: string;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
container?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Session {
|
||||
User?: {
|
||||
$: {
|
||||
@@ -63,20 +15,13 @@ interface Session {
|
||||
$: {
|
||||
product: string;
|
||||
title: string;
|
||||
address?: string;
|
||||
state?: string;
|
||||
local?: string;
|
||||
};
|
||||
}[];
|
||||
Session?: {
|
||||
$: {
|
||||
id: string;
|
||||
bandwidth?: string;
|
||||
location?: string;
|
||||
};
|
||||
}[];
|
||||
TranscodeSession?: TranscodeSession[];
|
||||
Media?: Media[];
|
||||
$: {
|
||||
grandparentTitle?: string;
|
||||
parentTitle?: string;
|
||||
@@ -84,8 +29,6 @@ interface Session {
|
||||
index?: number;
|
||||
type: string;
|
||||
live?: string;
|
||||
viewOffset?: string;
|
||||
duration?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,32 +17,6 @@ import type { PlexResponse } from "./interface";
|
||||
|
||||
const logger = createLogger({ module: "plexIntegration" });
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function parseLocation(value: string | undefined): "lan" | "wan" | null {
|
||||
return value === "lan" || value === "wan" ? value : null;
|
||||
}
|
||||
|
||||
function parsePlaybackState(value: string | undefined): "playing" | "paused" | "buffering" | null {
|
||||
return value === "playing" || value === "paused" || value === "buffering" ? value : null;
|
||||
}
|
||||
|
||||
function parseResolution(
|
||||
width: string | undefined,
|
||||
height: string | undefined,
|
||||
): { width: number; height: number } | null {
|
||||
const parsedWidth = parseOptionalNumber(width);
|
||||
const parsedHeight = parseOptionalNumber(height);
|
||||
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
|
||||
}
|
||||
|
||||
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
const token = super.getSecretValue("apiKey");
|
||||
@@ -69,24 +43,11 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
const userElement = mediaElement.User ? mediaElement.User[0] : undefined;
|
||||
const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined;
|
||||
const sessionElement = mediaElement.Session ? mediaElement.Session[0] : undefined;
|
||||
const transcodeElement = mediaElement.TranscodeSession ? mediaElement.TranscodeSession[0] : undefined;
|
||||
const mediaInfoElement = mediaElement.Media ? mediaElement.Media[0] : undefined;
|
||||
|
||||
if (!playerElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const positionMs = parseOptionalNumber(mediaElement.$.viewOffset);
|
||||
const durationMs = parseOptionalNumber(mediaElement.$.duration);
|
||||
|
||||
const playbackState = parsePlaybackState(playerElement.$.state);
|
||||
|
||||
const location = parseLocation(sessionElement?.$.location);
|
||||
|
||||
const streams = mediaInfoElement?.Part?.[0]?.Stream ?? [];
|
||||
const videoStream = streams.find((stream) => stream.$.streamType === "1");
|
||||
const audioStream = streams.find((stream) => stream.$.streamType === "2");
|
||||
|
||||
return {
|
||||
sessionId: sessionElement?.$.id ?? "unknown",
|
||||
sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
|
||||
@@ -102,30 +63,7 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
episodeName: mediaElement.$.title ?? null,
|
||||
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
||||
episodeCount: mediaElement.$.index ?? null,
|
||||
playback: {
|
||||
state: playbackState,
|
||||
positionMs,
|
||||
durationMs,
|
||||
},
|
||||
location,
|
||||
metadata: {
|
||||
video: {
|
||||
resolution: parseResolution(videoStream?.$.width, videoStream?.$.height),
|
||||
frameRate: parseOptionalNumber(videoStream?.$.frameRate),
|
||||
},
|
||||
audio: {
|
||||
channelCount: parseOptionalNumber(audioStream?.$.channels),
|
||||
codec: audioStream?.$.codec ?? mediaInfoElement?.$.audioCodec ?? null,
|
||||
},
|
||||
transcoding: {
|
||||
container: transcodeElement?.$.container ?? null,
|
||||
resolution: parseResolution(transcodeElement?.$.width, transcodeElement?.$.height),
|
||||
target: {
|
||||
audioCodec: transcodeElement?.$.audioCodec ?? null,
|
||||
videoCodec: transcodeElement?.$.videoCodec ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
metadata: null,
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
@@ -65,9 +65,6 @@ const optionMapping: OptionMapping = {
|
||||
useCustomTimezone: () => true,
|
||||
customTimeFormat: () => undefined,
|
||||
customDateFormat: () => undefined,
|
||||
showWeather: () => undefined,
|
||||
weatherLocation: () => undefined,
|
||||
isWeatherFormatFahrenheit: () => undefined,
|
||||
},
|
||||
downloads: {
|
||||
activeTorrentThreshold: (oldOptions) =>
|
||||
@@ -77,16 +74,15 @@ const optionMapping: OptionMapping = {
|
||||
categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined),
|
||||
filterIsWhitelist: (oldOptions) =>
|
||||
"labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined,
|
||||
enableRowSorting: (oldOptions) => ("rowSorting" in oldOptions ? oldOptions.rowSorting : undefined),
|
||||
showCompletedTorrent: (oldOptions) =>
|
||||
"displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined,
|
||||
columns: () => ["integration", "name", "progress", "time", "state"],
|
||||
defaultSort: () => "progress",
|
||||
columns: () => ["integration", "name", "progress", "time", "actions"],
|
||||
defaultSort: () => "type",
|
||||
descendingDefaultSort: () => false,
|
||||
showCompletedUsenet: () => true,
|
||||
showCompletedHttp: () => true,
|
||||
limitPerIntegration: () => undefined,
|
||||
columnOrder: () => undefined,
|
||||
columnWidths: () => undefined,
|
||||
},
|
||||
weather: {
|
||||
forecastDayCount: (oldOptions) => oldOptions.forecastDays,
|
||||
|
||||
@@ -300,7 +300,10 @@ async function getContainersWithStatsAsync() {
|
||||
|
||||
const stats = await instance
|
||||
.getContainer(container.Id)
|
||||
.stats({ stream: false, "one-shot": true })
|
||||
// Not in one-shot mode: the daemon then includes precpu_stats (the
|
||||
// previous reading), which is required to compute the current CPU usage
|
||||
// as a delta. One-shot omits it and would only expose lifetime totals.
|
||||
.stats({ stream: false })
|
||||
.catch(
|
||||
() =>
|
||||
({
|
||||
@@ -336,12 +339,22 @@ export function calculateCpuUsage(stats: ContainerStats): number {
|
||||
}
|
||||
|
||||
const numberOfCpus = stats.cpu_stats.online_cpus;
|
||||
const usage = stats.cpu_stats.system_cpu_usage;
|
||||
if (!usage || usage === 0) {
|
||||
|
||||
// Docker's own formula for the current (instantaneous) CPU usage: it compares
|
||||
// the latest reading against the previous one (precpu_stats). Dividing the
|
||||
// cumulative total_usage by the cumulative system_cpu_usage instead would
|
||||
// report the container's lifetime average, which stays high long after a load
|
||||
// spike has ended. precpu_stats is only present when the stats are NOT fetched
|
||||
// in one-shot mode; when it is absent (e.g. Podman) the deltas fall back to the
|
||||
// cumulative totals, matching the previous behaviour.
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
||||
const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - (stats.precpu_stats?.system_cpu_usage ?? 0);
|
||||
|
||||
if (systemDelta <= 0 || cpuDelta < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
|
||||
return (cpuDelta / systemDelta) * numberOfCpus * 100;
|
||||
}
|
||||
|
||||
export function calculateMemoryUsage(stats: ContainerStats): number {
|
||||
|
||||
@@ -33,7 +33,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
|
||||
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
|
||||
ImmichAlbum,
|
||||
IntegrationKindByCategory<"photoService">,
|
||||
{ albumId?: string }
|
||||
{ albumId: string }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
|
||||
@@ -13,7 +13,6 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
|
||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
}
|
||||
|
||||
export const createIntegrationRequestHandler = <
|
||||
@@ -33,7 +32,6 @@ export const createIntegrationRequestHandler = <
|
||||
return options.requestAsync(integration, input.options);
|
||||
},
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
}
|
||||
|
||||
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
|
||||
@@ -36,6 +35,7 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return { data: cached.data, timestamp: cached.timestamp };
|
||||
}
|
||||
if (cached) cache.delete(key);
|
||||
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
@@ -55,9 +55,6 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
})
|
||||
.catch((err) => {
|
||||
inflight.delete(key);
|
||||
if (options.fallbackToStaleOnError && cached) {
|
||||
return cached;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,4 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getMediaReleasesAsync();
|
||||
},
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
fallbackToStaleOnError: true,
|
||||
});
|
||||
@@ -39,6 +39,4 @@ export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
|
||||
return true;
|
||||
});
|
||||
},
|
||||
cacheTtlMs: 60_000,
|
||||
fallbackToStaleOnError: true,
|
||||
});
|
||||
@@ -16,6 +16,4 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
|
||||
users: await integrationInstance.getUsersAsync(),
|
||||
};
|
||||
},
|
||||
cacheTtlMs: 60_000,
|
||||
fallbackToStaleOnError: true,
|
||||
});
|
||||
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user