Compare commits

...
Author SHA1 Message Date
Thomas Camlong 6ec705db4a docs: add mantine-datatable migration guide as cursor rule 2026-07-21 17:37:37 +02:00
ajnart f6ca2a40c5 style: auto-fix lint and format 2026-07-21 14:49:07 +00:00
Thomas Camlong 85c6eef016 fix(downloads): clamp name tooltip to cell bounds 2026-07-21 16:47:59 +02:00
Thomas Camlong f0cb7ab3c8 fix(downloads): replace dot badge with plain circle indicator 2026-07-21 16:47:06 +02:00
Thomas Camlong 6bf37756b8 docs(downloads): tight crop screenshots to widget bounds 2026-07-21 16:44:49 +02:00
Thomas Camlong 32f315b37c docs(downloads): crop screenshots to focus on widget 2026-07-21 16:30:35 +02:00
Thomas Camlong 37365daccb fix(translations): add missing option keys for hidden columnOrder/columnWidths 2026-07-21 16:26:26 +02:00
Thomas Camlong 7c3aee944d chore: remove out-of-scope files (weather, scratchpad) + fix docs sortColumns
- Revert weather widget changes (belongs to #6429)
- Remove .cursor/goal/archive scratchpad artifact
- Exclude 'integration' from docs sortColumns to match widget definition

Addresses CodeRabbit review comments.
2026-07-21 16:07:17 +02:00
Thomas Camlong c3a5e92947 fix(qbittorrent): remove progress sort to include seeding torrents in limited fetches 2026-07-21 16:06:00 +02:00
ajnart 3d4bb25f49 style: auto-fix lint and format 2026-07-21 16:06:00 +02:00
Thomas Camlong df48246001 chore(downloads): remove footer item counter badge 2026-07-21 16:06:00 +02:00
Thomas Camlong a2a22a89bc fix(downloads): address PR review findings
- Fix column persistence race (defer hydrated flag via rAF)
- Add onError to saveItemOptions mutation
- Wrap clipboard writes in .catch()
- Fix hardcoded i18n strings (ETA, ID, disconnected)
- Add aria-label to all ActionIcons and aria-pressed on filter toggles
- Add missing 'milliseconds' unit to dayjs().add()
- Sync sortStatus when widget options change
2026-07-21 16:06:00 +02:00
Thomas Camlong ea049066e1 fix(downloads): filter badge overflow, tooltip dark mode, dedup stats, ring text sizing
- Replace fixed w={50} on filter labels with flexShrink:0 + wrap
- Add color='dark' to progress tooltip (matches name hover)
- Hide speeds/ratio from footer when stats bar is open
- Shrink RingProgress label font to prevent 100% touching edges
2026-07-21 16:06:00 +02:00
Thomas Camlong 70b868544f fix(downloads): fix column sync bugs + simplify component
- Fix localStorage key format (use hook's setColumnsOrder/setMultipleColumnWidths)
- Fix columnsWidth read shape (object-keyed entries, not {accessor,width})
- Safe JSON.parse with try/catch fallback
- Per-widget storeKey (include itemId, sort columns)
- Reset initialSyncDone on storeKey change
- Remove forced table-layout:fixed (fights resize lifecycle)
- Skip persist until initial server restore completes
- Extract helpers and lookup tables (code-simplifier)
2026-07-21 16:05:59 +02:00
Thomas Camlong 88a6f44d0d feat: bring back draggable + resizable columns with server-side persistence via hidden options 2026-07-21 16:05:59 +02:00
Thomas Camlong 5226138186 fix: stable column layout via table-layout:fixed, no content-driven shifts 2026-07-21 16:05:59 +02:00
Thomas Camlong 5c71c1aede fix: mutation error notifications, query error state, ratio guards, memoization, composite ids, docs accuracy 2026-07-21 16:05:59 +02:00
Thomas Camlong 5acf6cef32 fix: remove useDataTableColumns (crash on options change), fix delete confirmation, simplify 2026-07-21 16:05:59 +02:00
Thomas Camlong 32aa26db65 fix: column overlap, tooltip dark mode, simplify column visibility, round badge 2026-07-21 16:05:59 +02:00
Thomas Camlong 47cfc5be6c fix: resolve lint errors - non-null assertions and function scoping 2026-07-21 16:05:59 +02:00
Thomas Camlong 27686e00f3 refactor: remove scroll indicator clutter, withColumnBorders, simplify footer 2026-07-21 16:05:59 +02:00
Thomas Camlong d21d67fc59 chore: update goal criteria - disk usage not available from integration API 2026-07-21 16:05:59 +02:00
Thomas Camlong 30e2e084a4 feat: add fetching loader, progress tooltip with speed/ETA on hover 2026-07-21 16:05:59 +02:00
Thomas Camlong 7f7af15fc5 feat: add useDataTableColumns with toggleable, draggable, resizable, pinnable columns 2026-07-21 16:05:59 +02:00
Thomas Camlong 6dffa4d2df feat: add hover tooltips, global stats bar, enhanced row expansion, striped rows, and DataTable features 2026-07-21 16:05:59 +02:00
Thomas Camlong 0963885764 docs: update screenshots with new downloads widget design 2026-07-21 16:05:59 +02:00
Thomas Camlong d53de7c641 fix: let mantine-datatable manage row expansion state internally 2026-07-21 16:05:59 +02:00
Thomas Camlong 9edf6ca99b fix: remove onRowClick conflict with rowExpansion, clean up imports 2026-07-21 16:05:59 +02:00
Thomas Camlong fc0da8dedf feat: rework downloads widget with mantine-datatable
- Replace mantine-react-table with mantine-datatable for a cleaner, lighter table
- Add right-click context menu with pause/resume/delete/copy actions
- Add row expansion for inline detail view (replaces modal)
- Implement responsive scaling based on widget width
- Add footer filter panel with client and status filtering
- Show status badges with color-coded indicators
- Display speed indicators with icons in column cells
- Add global ratio, total speeds, and item count in footer
- Sort always enabled (removed confusing enableRowSorting toggle)
- Reorder default columns to prioritize useful information
- Update docs and translations
2026-07-21 16:05:59 +02:00
Thomas Camlong b004186183 feat(weather): add humidity display from Open-Meteo API
Adds relative_humidity_2m to the daily forecast API request and
displays it in both the single-day weather view and the forecast
hover cards.

Closes #6428
2026-07-21 16:05:59 +02:00
Thomas 1706289124 fix(media-server): guard against undefined playback from stale cached data (#6433) 2026-07-21 16:01:37 +02:00
homarr-renovate[bot] dd23de7aaa chore(deps): update pnpm to v11.13.1 (#6422)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-21 05:54:52 +00:00
Thomas f1602f67d4 fix: stop auto-seeding ArchiveTeam Warrior integration and widget on default dashboards (#6413) 2026-07-20 17:01:07 +02:00
Thomas c03cd97d3a fix(test): use Home Assistant long-lived token JWT
* fix(test): use Home Assistant long-lived token

* fix(test): revert to correct Home Assistant long-lived token JWT
2026-07-20 15:27:50 +02:00
Thomas a8e9a074d3 Merge pull request #6411 from homarr-labs/mysql-migration-test-timeout
fix(db): increase mysql migration test timeout to 120s
2026-07-20 15:19:12 +02:00
Thomas Camlong 1e9d3e0656 fix(db): increase mysql migration test timeout to 120s 2026-07-20 15:18:50 +02:00
Thomas 01e0f3612d docs(immich): list Asset View permission (#6408) 2026-07-20 14:57:07 +02:00
Thomas 0015a6d88d fix(immich): use thumbnail endpoint instead of original for album carousel (#6396)
* fix(immich): use thumbnail endpoint instead of original for album carousel

Switches from /api/assets/:id/original to /api/assets/:id/thumbnail to fix
HEIC image incompatibility (thumbnails are always JPEG), reduce bandwidth,
and improve loading speed.

Closes #6388

* working on immich

* Improve image proxy error handling

* fix(image-proxy): use trusted fetch response type

* docs(immich): require Asset View permission
2026-07-20 14:53:27 +02:00
Thomas 77dc317da2 chore(ci): remove memory regression tests (#6395) 2026-07-18 22:55:38 +02:00
homarr-update-contributors[bot] 6aeda3faaf chore: update contributors (#6378) 2026-07-18 22:43:01 +02:00
Releases Homarr ca358d9f8b chore(release): 1.71.0 [skip ci] 2026-07-17 17:58:17 +00:00
homarr-releases[bot] 03e0683892 chore(release): automatic release v1.71.0 2026-07-17 17:57:04 +00:00
Junker der Provinz 6ac4c6a7b9 fix(search): let public dashboards use generic custom search engines (#6375) 2026-07-17 14:53:52 +02:00
Junker der Provinz a0e0bf65ff fix(releases): let LinuxServer.io version filters find older releases via GitHub (#6376) 2026-07-17 14:53:01 +02:00
Junker der Provinz bc018e1fe0 fix(docker): disable the broken full-screen toggle on the container table (#6374) 2026-07-17 14:43:37 +02:00
Thomas 5e39c8813a feat(preferences): add enableRightClickOnWidgets user preference (#6298) 2026-07-17 14:42:19 +02:00
Thomas 06719cdef0 fix(ci): remove coverage report, scope format to changed files, fix oxlint config (#6359) 2026-07-16 10:21:07 +02:00
Bergasha 5144e14da4 fix(media-server): populate playback progress, transcode status, and network location for streams (#6315) 2026-07-15 16:27:58 +02:00
Thomas 8528d5068b feat(media-requests): filter requests list by status and time window (#6320) 2026-07-15 16:27:24 +02:00
Thomas c6c4428f7d Revert "fix(docker): report current CPU usage instead of the lifetime average" (#6357) 2026-07-15 14:55:18 +02:00
Thomas 7e4cb0201b fix(widgets): add refetchInterval to WidgetDefinition for per-widget polling (#6352) 2026-07-15 11:52:35 +02:00
Thomas 7887ce12c0 fix(integrations): resolve intermittent Seerr/Overseerr crashes and add query status to context menu (#6347) 2026-07-15 11:41:18 +02:00
Thomas 2d450239ac fix(releases): add required octokit throttling rate limit handlers (#6338) 2026-07-15 11:27:32 +02:00
Thomas bf4ccd8cb3 fix(ci): remove persist-credentials: false to fix git-auto-commit-action push (#6346) 2026-07-15 10:33:32 +02:00
96 changed files with 9904 additions and 1176 deletions

No files matched your search

@@ -0,0 +1,622 @@
# 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
-1
View File
@@ -23,7 +23,6 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.head_ref }} ref: ${{ github.head_ref }}
persist-credentials: false
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
+7 -90
View File
@@ -60,7 +60,7 @@ jobs:
TURBO_PLATFORM: linux/amd64 TURBO_PLATFORM: linux/amd64
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
if: '!env.ACT' if: "!env.ACT"
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@@ -81,9 +81,6 @@ jobs:
- name: Copy environment defaults - name: Copy environment defaults
run: cp .env.example .env run: cp .env.example .env
- name: Check formatting in affected workspaces
run: pnpm turbo run format --affected --continue -- --check
- name: Lint, typecheck, and build affected workspaces - name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
@@ -93,12 +90,8 @@ jobs:
- name: Run unit tests - name: Run unit tests
run: pnpm test run: pnpm test
- name: Report coverage
if: always() && !env.ACT
uses: davelosert/vitest-coverage-report-action@v2
container: container:
name: Container, E2E, and memory name: Container and E2E
needs: fast-gate needs: fast-gate
if: | if: |
github.event_name == 'merge_group' || github.event_name == 'merge_group' ||
@@ -111,7 +104,7 @@ jobs:
packages: write packages: write
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
if: '!env.ACT' if: "!env.ACT"
with: with:
persist-credentials: false persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
@@ -120,11 +113,11 @@ jobs:
uses: ./tooling/github/setup uses: ./tooling/github/setup
- name: Set up Docker Buildx - name: Set up Docker Buildx
if: '!env.ACT' if: "!env.ACT"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: Build container once - name: Build container once
if: '!env.ACT' if: "!env.ACT"
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
context: . context: .
@@ -165,7 +158,7 @@ jobs:
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" . --tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers - name: Cache Playwright browsers
if: '!env.ACT' if: "!env.ACT"
id: playwright-cache id: playwright-cache
uses: actions/cache@v6 uses: actions/cache@v6
with: with:
@@ -191,82 +184,6 @@ jobs:
path: e2e/test-results/ path: e2e/test-results/
retention-days: 7 retention-days: 7
- name: Run memory regression test
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
run: |
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
-e NO_EXTERNAL_CONNECTION=true \
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
homarr-ci
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
URL="http://127.0.0.1:${PORT}"
healthy=false
for i in $(seq 1 30); do
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
warmup_ok=0
for _ in $(seq 1 5); do
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
sleep 2
done
if [ "$warmup_ok" -eq 0 ]; then
echo "ERROR: All warmup requests failed"
docker logs "$CONTAINER_NAME" --tail 50
exit 1
fi
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
echo "ERROR: Container died before memory measurement"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
/MiB/ { gsub(/MiB/, ""); print $1; next }
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
{ print $1 / 1048576 }
')
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
- name: Clean up memory test container
if: always()
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
- name: Determine preview tag - name: Determine preview tag
id: preview id: preview
if: | if: |
@@ -316,7 +233,7 @@ jobs:
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; } run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
- name: Delete GHCR package version - name: Delete GHCR package version
if: '!env.ACT' if: "!env.ACT"
uses: actions/github-script@v8 uses: actions/github-script@v8
with: with:
script: | script: |
+2
View File
@@ -1,3 +1,5 @@
## [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.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) ## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
-3
View File
@@ -23,9 +23,6 @@
"react-hooks/exhaustive-deps": "warn", "react-hooks/exhaustive-deps": "warn",
"eslint/no-unused-expressions": "warn" "eslint/no-unused-expressions": "warn"
}, },
"options": {
"reportUnusedDisableDirectives": "off"
},
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"], "ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
"settings": { "settings": {
"react": { "version": "19" } "react": { "version": "19" }
+1 -2
View File
@@ -50,12 +50,11 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
<code>user.read</code>,{" "} <code>user.read</code>,{" "}
<code>user.update</code>,{" "} <code>user.update</code>,{" "}
<code>asset.read</code>,{" "} <code>asset.read</code>,{" "}
<code>asset.view</code>,{" "}
<code>asset.download</code>,{" "} <code>asset.download</code>,{" "}
<code>asset.statistics</code>,{" "} <code>asset.statistics</code>,{" "}
<code>server.statistics</code>,{" "} <code>server.statistics</code>,{" "}
<code>server.about</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> </span>
), ),
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 122 KiB

+35 -9
View File
@@ -4,6 +4,7 @@ description: 'Allows you to view and manage your Downloads from both Torrent and
hide_title: true hide_title: true
--- ---
import { aria2Integration } from '@site/docs/integrations/aria2';
import { delugeIntegration } from '@site/docs/integrations/deluge'; import { delugeIntegration } from '@site/docs/integrations/deluge';
import { nzbgetIntegration } from '@site/docs/integrations/nzbget'; import { nzbgetIntegration } from '@site/docs/integrations/nzbget';
import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent'; import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent';
@@ -14,9 +15,6 @@ import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from '@site/src/components/widgets/header';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; 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 '.'; import { downloadsWidget } from '.';
@@ -25,16 +23,37 @@ import { downloadsWidget } from '.';
categories={['Torrent client', 'Usenet client', 'Downloads']} 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. 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.
### Screenshots ### Screenshots
import downloadsCard from './img/screenshot.png'; import downloadsCard from './img/screenshot.png';
import contextMenuImg from './img/context-menu.png';
<div> <div>
<img src={downloadsCard} alt="downloads" width={128 * 4} /> <img src={downloadsCard} alt="Downloads widget showing torrent and usenet items" width={128 * 4} />
</div> </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 ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations items={[{
@@ -52,16 +71,23 @@ import downloadsCard from './img/screenshot.png';
}, { }, {
integration: qBittorentIntegration, integration: qBittorentIntegration,
note: "Torrent client" note: "Torrent client"
}, {
integration: aria2Integration,
note: "Multi-protocol download client"
}, { }, {
integration: slskdIntegration, integration: slskdIntegration,
note: "Soulseek client" note: "Soulseek client (read-only)"
}]} /> }]} />
### Interactions ### Interactions
- Pause / Resume one download by clicking on the according icon. - **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
- Delete one download by clicking on the trash icon. (Can also delete the files) - **Click** any row to expand it and see full download details inline
- Pause / Resume all downloads by using the bottom button. - **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)
### Adding the widget ### Adding the widget
+24 -32
View File
@@ -2,26 +2,23 @@ import { WidgetDefinition } from "@site/src/types";
import { IconDownload } from "@tabler/icons-react"; import { IconDownload } from "@tabler/icons-react";
const columnsList = [ const columnsList = [
"id",
"actions",
"added",
"category",
"downSpeed",
"index",
"integration",
"name", "name",
"progress", "progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio", "ratio",
"received", "received",
"sent", "sent",
"size", "category",
"state", "integration",
"time", "index",
"type", "type",
"upSpeed",
]; ];
const sortingExclusion = ["actions", "id", "state"]; const sortColumns = columnsList.filter((c) => !["state", "category", "integration"].includes(c));
const columnsSort = columnsList.filter((column) => !sortingExclusion.some((exclusion) => exclusion === column));
export const downloadsWidget: WidgetDefinition = { export const downloadsWidget: WidgetDefinition = {
icon: IconDownload, icon: IconDownload,
@@ -32,25 +29,20 @@ export const downloadsWidget: WidgetDefinition = {
items: [ items: [
{ {
name: "Columns to show", name: "Columns to show",
description: "Select the columns you want to display in the widget.", description:
"Select the columns you want to display in the widget. Columns automatically hide/show based on widget width.",
values: `List of columns: ${columnsList.join(", ")}`, values: `List of columns: ${columnsList.join(", ")}`,
defaultValue: ["integration", "name", "progress", "time", "actions"].join(", "), defaultValue: ["name", "progress", "downSpeed", "time", "state"].join(", "),
}, },
{ {
name: "Enable items sorting", name: "Default sort column",
description: "Enables the sorting in the table.", description: "The column used for initial sorting when the widget loads.",
values: { type: "boolean" }, defaultValue: "progress",
defaultValue: "no", values: `List of columns: ${sortColumns.join(", ")}`,
},
{
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", name: "Invert sorting",
description: "This will invert the sorting order of the table.", description: "This will invert the default sorting order.",
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "no", defaultValue: "no",
}, },
@@ -76,13 +68,13 @@ export const downloadsWidget: WidgetDefinition = {
name: "Hide completed torrent under this threshold (in kiB/s)", name: "Hide completed torrent under this threshold (in kiB/s)",
description: description:
"This will hide completed torrent entries that have a download speed below the specified threshold.", "This will hide completed torrent entries that have a download speed below the specified threshold.",
values: "Any number above 1, 0 to disable", values: "Any number above 0, 0 to disable",
defaultValue: "0", defaultValue: "0",
}, },
{ {
name: "Categories/names to filter", name: "Categories/labels to filter",
description: "You can filter the items by categories or names. Use a comma to separate multiple values.", description: "You can filter the items by categories or labels. Use a comma to separate multiple values.",
values: "Comma-separated list of categories or names", values: "Comma-separated list of categories or labels",
defaultValue: "-", defaultValue: "-",
}, },
{ {
@@ -94,13 +86,13 @@ export const downloadsWidget: WidgetDefinition = {
}, },
{ {
name: "Use filter to calculate Ratio", name: "Use filter to calculate Ratio",
description: "This will use the filter to calculate the ratio of items shown in the widget.", description: "This will use the category filter when calculating the global torrent ratio.",
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", defaultValue: "yes",
}, },
{ {
name: "Limit items per integration", 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", values: "Any number above 1",
defaultValue: "50", defaultValue: "50",
}, },
@@ -1,41 +1,44 @@
--- ---
title: 'Media Request List' title: "Media Request List"
description: 'See a list of all media requests from your integration' description: "See a list of all media requests from your integration"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from '@site/src/components/widgets/adding'; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react"; import { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from '.'; import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr'; import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from '@site/docs/integrations/overseerr'; import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
<WidgetHeader
widget={mediaRequestListWidget}
categories={['Media requests']}
/>
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
### Screenshots ### Screenshots
import mediaRequestList from './img/list.png'; import mediaRequestList from "./img/list.png";
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} /> <img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: jellyseerrIntegration, items={[
}, { {
integration: overseerrIntegration, integration: jellyseerrIntegration,
}]} /> },
{
integration: overseerrIntegration,
},
]}
/>
### Interactions ### Interactions
@@ -47,4 +50,5 @@ import mediaRequestList from './img/list.png';
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={mediaRequestListWidget.configuration} /> <WidgetConfig configuration={mediaRequestListWidget.configuration} />
@@ -14,6 +14,18 @@ export const mediaRequestListWidget: WidgetDefinition = {
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", defaultValue: "yes",
}, },
{
name: "Statuses to show",
description: "Only requests with these statuses will be shown. Defaults to all statuses.",
values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'",
defaultValue: "All statuses",
},
{
name: "Show only recent requests",
description: "Limit to requests created within this many days. Set to 0 to disable the time filter.",
values: "0-365",
defaultValue: "0",
},
], ],
}, },
}; };
@@ -2,6 +2,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useState } from "react"; import { useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
@@ -33,6 +34,7 @@ import {
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications"; import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets";
import { createWidgetQueryPersister } from "./query-cache-persister"; import { createWidgetQueryPersister } from "./query-cache-persister";
@@ -90,6 +92,13 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}, },
}); });
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); 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; return client;
}); });
@@ -193,6 +193,10 @@ export function DockerTable({ initialData }: DockerTableProps) {
positionToolbarAlertBanner: "top", positionToolbarAlertBanner: "top",
enableTableFooter: false, enableTableFooter: false,
enableBottomToolbar: 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", positionGlobalFilter: "right",
mantineSearchTextInputProps: { mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(containers.length) }), placeholder: tDocker("table.search", { count: String(containers.length) }),
@@ -31,6 +31,7 @@ import {
userChangeHomeBoardsSchema, userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema, userChangeSearchPreferencesSchema,
userEditProfileSchema, userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema, userFirstDayOfWeekSchema,
userPingIconsEnabledSchema, userPingIconsEnabledSchema,
} from "@homarr/validation/user"; } from "@homarr/validation/user";
@@ -51,6 +52,7 @@ const userGeneralSettingsSchema = z.object({
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled, ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek, firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled, pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
}); });
type FormValues = z.infer<typeof userGeneralSettingsSchema>; type FormValues = z.infer<typeof userGeneralSettingsSchema>;
@@ -78,6 +80,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
ddgBangsEnabled: user.ddgBangs, ddgBangsEnabled: user.ddgBangs,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek, firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled, pingIconsEnabled: user.pingIconsEnabled,
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
}); });
export const UserGeneralSettingsForm = ({ export const UserGeneralSettingsForm = ({
@@ -95,6 +98,7 @@ export const UserGeneralSettingsForm = ({
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation(); const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation(); const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation(); const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
const initialValues = buildInitialValues(user); const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues); const initialValuesRef = useRef(initialValues);
@@ -114,6 +118,7 @@ export const UserGeneralSettingsForm = ({
changeSearchPreferencesMutation, changeSearchPreferencesMutation,
changeFirstDayOfWeekMutation, changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation, changePingIconsEnabledMutation,
changeEnableRightClickOnWidgetsMutation,
]; ];
const isPending = mutations.some((m) => m.isPending); const isPending = mutations.some((m) => m.isPending);
@@ -169,6 +174,14 @@ export const UserGeneralSettingsForm = ({
action: () => action: () =>
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }), 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()); const promises = saveActions.filter((s) => s.when).map((s) => s.action());
@@ -305,6 +318,11 @@ export const UserGeneralSettingsForm = ({
label={t("user.field.pingIconsEnabled.label")} label={t("user.field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })} {...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/> />
<Switch
label={t("user.field.enableRightClickOnWidgets.label")}
description={t("user.field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack> </Stack>
</Card> </Card>
</SimpleGrid> </SimpleGrid>
@@ -1,19 +1,25 @@
import { notFound } from "next/navigation";
import { ImageProxy } from "@homarr/image-proxy"; import { ImageProxy } from "@homarr/image-proxy";
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => { export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
const { id } = await props.params; const { id } = await props.params;
const imageProxy = new ImageProxy(); const imageProxy = new ImageProxy();
const image = await imageProxy.forwardImageAsync(id); const result = await imageProxy.forwardImageAsync(id);
if (!image) {
notFound(); 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,
},
});
} }
return new Response(image, { return new Response(result.image, {
headers: { headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour "Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
...(result.contentType ? { "Content-Type": result.contentType } : {}),
}, },
}); });
}; };
@@ -1,9 +1,17 @@
"use client"; "use client";
import type { MutableRefObject, ReactNode } from "react"; import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useMemo } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core"; import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react"; import {
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query"; import { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -11,10 +19,12 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets"; import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals"; import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -38,6 +48,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const tMenu = useScopedI18n("item.menu.label"); const tMenu = useScopedI18n("item.menu.label");
const t = useI18n(); const t = useI18n();
const settings = useSettings(); const settings = useSettings();
const isRightClickEnabled = settings.enableRightClickOnWidgets;
const { openModal } = useModalAction(WidgetEditModal); const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal); const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
@@ -158,6 +169,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
); );
if (!session) return <>{children}</>; if (!session) return <>{children}</>;
if (!isRightClickEnabled) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
@@ -232,11 +244,14 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
onClick={handleRefetch} onClick={handleRefetch}
disabled={isWidgetFetching} disabled={isWidgetFetching}
> >
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap" gap="sm">
{tMenu("refresh")} {tMenu("refresh")}
<Text size="xs" c="dimmed"> <WidgetQueryStatus
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} /> queryClient={queryClient}
</Text> queryKey={widgetQueryKey}
isFetching={isWidgetFetching}
t={t}
/>
</Group> </Group>
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
@@ -273,34 +288,65 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
); );
}; };
const WidgetCacheAge = ({ interface WidgetQueryStatusProps {
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient; queryClient: QueryClient;
queryKey: QueryKey; queryKey: QueryKey;
isFetching: boolean; 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);
}, []);
if (isFetching) return <Loader size={12} />; 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));
const timestamps = queryClient if (isFetching) {
.getQueryCache() return (
.findAll({ queryKey }) <Group gap={4} wrap="nowrap">
.map((q) => q.state.dataUpdatedAt) <Loader size={12} />
.filter(Boolean); <Text size="xs" c="dimmed">
if (timestamps.length === 0) return null; {t("item.menu.status.loading")}
</Text>
</Group>
);
}
const latest = Math.max(...timestamps); if (queries.length === 0) {
const seconds = Math.floor((Date.now() - latest) / 1000); return (
if (seconds < 5) return "just now"; <Text size="xs" c="dimmed">
if (seconds < 60) return `${seconds}s ago`; {t("item.menu.status.idle")}
return `${Math.floor(seconds / 60)}m ago`; </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>
);
}; };
+2 -2
View File
@@ -6,7 +6,7 @@
}, },
"engines": { "engines": {
"node": ">=24.18.0", "node": ">=24.18.0",
"pnpm": ">=11.11.0" "pnpm": ">=11.13.1"
}, },
"packageManager": "pnpm@11.11.0" "packageManager": "pnpm@11.13.1"
} }
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "1.70.0", "version": "1.71.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
@@ -69,7 +69,7 @@
}, },
"engines": { "engines": {
"node": ">=24.18.0", "node": ">=24.18.0",
"pnpm": ">=11.11.0" "pnpm": ">=11.13.1"
}, },
"packageManager": "pnpm@11.11.0" "packageManager": "pnpm@11.13.1"
} }
@@ -3,7 +3,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db"; import { and, asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema"; import { searchEngines, users } from "@homarr/db/schema";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
@@ -130,9 +130,15 @@ export const searchEngineRouter = createTRPCRouter({
return null; return null;
}), }),
search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => { search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({ return await ctx.db.query.searchEngines.findMany({
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`), // 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"),
),
with: { with: {
integration: { integration: {
columns: { columns: {
+77
View File
@@ -357,6 +357,83 @@ 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) => { const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
await db.insert(onboarding).values({ await db.insert(onboarding).values({
id: createId(), id: createId(),
+29
View File
@@ -23,6 +23,7 @@ import {
userCreateSchema, userCreateSchema,
userDdgBangsSchema, userDdgBangsSchema,
userEditProfileSchema, userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema, userFirstDayOfWeekSchema,
userInitSchema, userInitSchema,
userPingIconsEnabledSchema, userPingIconsEnabledSchema,
@@ -345,6 +346,7 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true, mobileHomeBoardId: true,
firstDayOfWeek: true, firstDayOfWeek: true,
pingIconsEnabled: true, pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true, defaultSearchEngineId: true,
openSearchInNewTab: true, openSearchInNewTab: true,
ddgBangs: true, ddgBangs: true,
@@ -381,6 +383,7 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true, mobileHomeBoardId: true,
firstDayOfWeek: true, firstDayOfWeek: true,
pingIconsEnabled: true, pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true, defaultSearchEngineId: true,
openSearchInNewTab: true, openSearchInNewTab: true,
ddgBangs: true, ddgBangs: true,
@@ -651,6 +654,32 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, ctx.session.user.id)); .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 changePingIconsEnabled: protectedProcedure
.input(userPingIconsEnabledSchema.and(byIdSchema)) .input(userPingIconsEnabledSchema.and(byIdSchema))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@@ -3,7 +3,10 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list"; import {
mediaRequestListInputSchema,
mediaRequestListRequestHandler,
} from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -16,13 +19,14 @@ export const mediaRequestsRouter = createTRPCRouter({
mcp: { mcp: {
enabled: true, enabled: true,
description: description:
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)", "Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => { .input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync(); const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
}); });
return results return results
+7 -4
View File
@@ -3,6 +3,7 @@ import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server"; import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db"; import { eq } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema"; import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions"; import { releaseProviderKinds } from "@homarr/definitions";
@@ -11,6 +12,8 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access"; import { throwIfActionForbiddenAsync } from "../board/board-access";
const logger = createLogger({ module: "releasesRouter" });
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => { const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined; if (!versionFilter) return undefined;
@@ -71,13 +74,13 @@ export const releasesRouter = createTRPCRouter({
for (const secret of secrets) { for (const secret of secrets) {
try { try {
tokensByProvider.set(secret.kind, decryptSecret(secret.value)); tokensByProvider.set(secret.kind, decryptSecret(secret.value));
} catch { } catch (error) {
// Skip corrupt secrets logger.warn("Failed to decrypt widget secret", { itemId: input.itemId, kind: secret.kind, error });
} }
} }
} }
} catch { } catch (error) {
// Access denied or table not yet migrated -- proceed without tokens logger.warn("Failed to load release widget tokens", { itemId: input.itemId, error });
} }
} }
@@ -0,0 +1 @@
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,6 +302,13 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0042_add_widget_secret", "tag": "0042_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1783852800000,
"tag": "0043_add_right_click_preference",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1 @@
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,6 +64,13 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0008_add_widget_secret", "tag": "0008_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1783786677702,
"tag": "0009_add_right_click_preference",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1 @@
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,6 +288,13 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0040_add_widget_secret", "tag": "0040_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1783786658943,
"tag": "0041_add_right_click_preference",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -86,6 +86,7 @@ export const users = mysqlTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(), colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(), pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(), completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(), completedBoardTour: boolean().default(false).notNull(),
}); });
+1
View File
@@ -85,6 +85,7 @@ export const users = pgTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(), colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(), pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(), completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(), completedBoardTour: boolean().default(false).notNull(),
}); });
+1
View File
@@ -68,6 +68,7 @@ export const users = sqliteTable("user", {
colorScheme: text().$type<ColorScheme>().default("dark").notNull(), colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(), pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
completedManageTour: int({ mode: "boolean" }).default(false).notNull(), completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(), completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
}); });
+1 -1
View File
@@ -37,5 +37,5 @@ describe("Mysql Migration", () => {
connection.end(); connection.end();
await mysqlContainer.stop(); await mysqlContainer.stop();
}, 40_000); }, 120_000);
}); });
-1
View File
@@ -467,7 +467,6 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png", iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"], category: ["archiving"],
documentationUrl: null, 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) // 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: { mock: {
+31 -8
View File
@@ -19,6 +19,12 @@ const createUrlByIdChannel = (id: string) =>
headers: `${string}.${string}`; headers: `${string}.${string}`;
}>(`image-proxy:url:${id}`); }>(`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 { export class ImageProxy {
private static hmacKey: Buffer<ArrayBuffer> | null = null; private static hmacKey: Buffer<ArrayBuffer> | null = null;
@@ -47,17 +53,34 @@ export class ImageProxy {
return this.createImageUrl(id); return this.createImageUrl(id);
} }
public async forwardImageAsync(id: string): Promise<ArrayBuffer | null> { public async forwardImageAsync(id: string): Promise<ForwardImageResult> {
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id); const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
if (!urlAndHeaders) { if (!urlAndHeaders) {
return null; return { error: "not-found" };
} }
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
const proxyUrl = this.createImageUrl(id); 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) { if (!response.ok) {
logger.error( logger.error(
new ErrorWithMetadata("Failed to fetch image", { new ErrorWithMetadata("Failed to fetch image", {
@@ -68,7 +91,7 @@ export class ImageProxy {
statusCode: response.status, statusCode: response.status,
}), }),
); );
return null; return { error: "upstream-error", statusCode: response.status };
} }
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
@@ -80,7 +103,7 @@ export class ImageProxy {
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`, size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
}); });
return arrayBuffer; return { image: arrayBuffer, contentType: response.headers.get("content-type") };
} }
private createImageUrl(id: string): string { private createImageUrl(id: string): string {
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent"; const type = "torrent";
const client = await this.getClientAsync(); const client = await this.getClientAsync();
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false }); const torrents = await client.listTorrents({ limit: input.limit });
const rates = torrents.reduce( const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }), ({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 }, { down: 0, up: 0 },
@@ -117,6 +117,12 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "", albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null, metadata: null,
}; };
} }
@@ -1,4 +1,5 @@
import { import {
AssetMediaSize,
getAlbumInfo, getAlbumInfo,
getAllAlbums, getAllAlbums,
getMyUser, getMyUser,
@@ -75,7 +76,9 @@ export class ImmichIntegration extends Integration {
assets: await Promise.all( assets: await Promise.all(
albumAssets.map(async (asset) => { albumAssets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync( const publicLink = await imageProxy.createImageAsync(
this.url(`/api/assets/${asset.id}/original`).toString(), // 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"), "x-api-key": this.getSecretValue("apiKey"),
}, },
@@ -13,6 +13,12 @@ export interface StreamSession {
episodeName?: string | null; episodeName?: string | null;
albumName?: string | null; albumName?: string | null;
episodeCount?: number | null; episodeCount?: number | null;
playback: {
state: "playing" | "paused" | "buffering" | null;
positionMs: number | null;
durationMs: number | null;
};
location: "lan" | "wan" | null;
metadata: { metadata: {
video: { video: {
resolution: { resolution: {
@@ -17,6 +17,10 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types"; import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../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]) @HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({ private readonly jellyfin: Jellyfin = new Jellyfin({
@@ -50,6 +54,9 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) { if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
currentlyPlaying = { currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
@@ -57,6 +64,12 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "", albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing",
positionMs,
durationMs,
},
location: null,
metadata: { metadata: {
video: { video: {
resolution: resolution:
@@ -13,6 +13,8 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -27,6 +29,8 @@ const sessions: StreamSession[] = [
episodeName: "Ozymandias", episodeName: "Ozymandias",
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -41,6 +45,8 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -55,6 +61,8 @@ const sessions: StreamSession[] = [
episodeName: "The We We Are", episodeName: "The We We Are",
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -69,6 +77,8 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: "Random Access Memories", albumName: "Random Access Memories",
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -83,6 +93,8 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -153,9 +153,9 @@ export class OverseerrIntegration
); );
} else if (pendingResults.length > 0) requests = pendingResults; } else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults; else if (allResults.length > 0) requests = allResults;
else return Promise.all([]); else return [];
return await Promise.all( const settled = await Promise.allSettled(
requests.map(async (request): Promise<MediaRequest> => { requests.map(async (request): Promise<MediaRequest> => {
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type); const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
@@ -184,6 +184,10 @@ export class OverseerrIntegration
}; };
}), }),
); );
return settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value);
} }
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus { protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -301,6 +305,10 @@ export class OverseerrIntegration
}, },
}); });
if (!response.ok) {
throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`);
}
if (type === "tv") { if (type === "tv") {
const series = (await response.json()) as TvInformation; const series = (await response.json()) as TvInformation;
return { return {
@@ -3,6 +3,54 @@ interface MediaContainer {
Track?: Session[]; 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 { interface Session {
User?: { User?: {
$: { $: {
@@ -15,13 +63,20 @@ interface Session {
$: { $: {
product: string; product: string;
title: string; title: string;
address?: string;
state?: string;
local?: string;
}; };
}[]; }[];
Session?: { Session?: {
$: { $: {
id: string; id: string;
bandwidth?: string;
location?: string;
}; };
}[]; }[];
TranscodeSession?: TranscodeSession[];
Media?: Media[];
$: { $: {
grandparentTitle?: string; grandparentTitle?: string;
parentTitle?: string; parentTitle?: string;
@@ -29,6 +84,8 @@ interface Session {
index?: number; index?: number;
type: string; type: string;
live?: string; live?: string;
viewOffset?: string;
duration?: string;
}; };
} }
@@ -17,6 +17,32 @@ import type { PlexResponse } from "./interface";
const logger = createLogger({ module: "plexIntegration" }); 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 { export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> { public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey"); const token = super.getSecretValue("apiKey");
@@ -43,11 +69,24 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const userElement = mediaElement.User ? mediaElement.User[0] : undefined; const userElement = mediaElement.User ? mediaElement.User[0] : undefined;
const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined; const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined;
const sessionElement = mediaElement.Session ? mediaElement.Session[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) { if (!playerElement) {
return undefined; 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 { return {
sessionId: sessionElement?.$.id ?? "unknown", sessionId: sessionElement?.$.id ?? "unknown",
sessionName: `${playerElement.$.product} (${playerElement.$.title})`, sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
@@ -63,7 +102,30 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
episodeName: mediaElement.$.title ?? null, episodeName: mediaElement.$.title ?? null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null, albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: mediaElement.$.index ?? null, episodeCount: mediaElement.$.index ?? null,
metadata: 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,
},
},
},
}, },
}; };
}) })
+6 -3
View File
@@ -23,6 +23,8 @@ type OptionMapping = {
const optionMapping: OptionMapping = { const optionMapping: OptionMapping = {
"mediaRequests-requestList": { "mediaRequests-requestList": {
linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab, linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab,
statusFilter: () => undefined,
recentDays: () => undefined,
}, },
"mediaRequests-requestStats": {}, "mediaRequests-requestStats": {},
bookmarks: { bookmarks: {
@@ -72,15 +74,16 @@ const optionMapping: OptionMapping = {
categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined), categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined),
filterIsWhitelist: (oldOptions) => filterIsWhitelist: (oldOptions) =>
"labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined, "labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined,
enableRowSorting: (oldOptions) => ("rowSorting" in oldOptions ? oldOptions.rowSorting : undefined),
showCompletedTorrent: (oldOptions) => showCompletedTorrent: (oldOptions) =>
"displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined, "displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined,
columns: () => ["integration", "name", "progress", "time", "actions"], columns: () => ["integration", "name", "progress", "time", "state"],
defaultSort: () => "type", defaultSort: () => "progress",
descendingDefaultSort: () => false, descendingDefaultSort: () => false,
showCompletedUsenet: () => true, showCompletedUsenet: () => true,
showCompletedHttp: () => true, showCompletedHttp: () => true,
limitPerIntegration: () => undefined, limitPerIntegration: () => undefined,
columnOrder: () => undefined,
columnWidths: () => undefined,
}, },
weather: { weather: {
forecastDayCount: (oldOptions) => oldOptions.forecastDays, forecastDayCount: (oldOptions) => oldOptions.forecastDays,
+4 -17
View File
@@ -300,10 +300,7 @@ async function getContainersWithStatsAsync() {
const stats = await instance const stats = await instance
.getContainer(container.Id) .getContainer(container.Id)
// Not in one-shot mode: the daemon then includes precpu_stats (the .stats({ stream: false, "one-shot": true })
// 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( .catch(
() => () =>
({ ({
@@ -339,22 +336,12 @@ export function calculateCpuUsage(stats: ContainerStats): number {
} }
const numberOfCpus = stats.cpu_stats.online_cpus; const numberOfCpus = stats.cpu_stats.online_cpus;
const usage = stats.cpu_stats.system_cpu_usage;
// Docker's own formula for the current (instantaneous) CPU usage: it compares if (!usage || usage === 0) {
// 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 0;
} }
return (cpuDelta / systemDelta) * numberOfCpus * 100; return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
} }
export function calculateMemoryUsage(stats: ContainerStats): number { export function calculateMemoryUsage(stats: ContainerStats): number {
@@ -13,6 +13,7 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> { interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>; requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number; cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
} }
export const createIntegrationRequestHandler = < export const createIntegrationRequestHandler = <
@@ -32,6 +33,7 @@ export const createIntegrationRequestHandler = <
return options.requestAsync(integration, input.options); return options.requestAsync(integration, input.options);
}, },
cacheTtlMs: options.cacheTtlMs, cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
}); });
return { return {
@@ -1,6 +1,7 @@
interface Options<TData, TInput extends Record<string, unknown>> { interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>; requestAsync: (input: TInput) => Promise<TData>;
cacheTtlMs?: number; cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
} }
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number }; type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
@@ -35,7 +36,6 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
if (cached && Date.now() < cached.expiresAt) { if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp }; return { data: cached.data, timestamp: cached.timestamp };
} }
if (cached) cache.delete(key);
const existing = inflight.get(key); const existing = inflight.get(key);
if (existing) return existing; if (existing) return existing;
@@ -55,6 +55,9 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
}) })
.catch((err) => { .catch((err) => {
inflight.delete(key); inflight.delete(key);
if (options.fallbackToStaleOnError && cached) {
return cached;
}
throw err; throw err;
}); });
@@ -1,16 +1,44 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest } from "@homarr/integrations/types"; import type { MediaRequest, MediaRequestStatus } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const mediaRequestStatusValues = [
"pending",
"approved",
"declined",
"failed",
"completed",
] as const satisfies readonly MediaRequestStatus[];
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export const mediaRequestListInputSchema = z.object({
statuses: z.array(z.enum(mediaRequestStatusValues)).nonempty(),
recentDays: z.number().min(0).max(365),
});
export type MediaRequestListInput = z.infer<typeof mediaRequestListInputSchema>;
export const mediaRequestListRequestHandler = createIntegrationRequestHandler< export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
MediaRequest[], MediaRequest[],
IntegrationKindByCategory<"mediaRequest">, IntegrationKindByCategory<"mediaRequest">,
Record<string, never> MediaRequestListInput
>({ >({
async requestAsync(integration, _input) { async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getRequestsAsync(); const requests = await integrationInstance.getRequestsAsync();
const cutoff = input.recentDays > 0 ? Date.now() - input.recentDays * MS_PER_DAY : null;
const statusSet = new Set<MediaRequestStatus>(input.statuses);
return requests.filter((request) => {
if (!statusSet.has(request.status)) return false;
if (cutoff !== null && request.createdAt.getTime() < cutoff) return false;
return true;
});
}, },
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
}); });
@@ -134,7 +134,33 @@ const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
baseUrl, baseUrl,
auth: token, auth: token,
request: { fetch: fetchWithTrustedCertificatesAsync }, request: { fetch: fetchWithTrustedCertificatesAsync },
throttle: { enabled: true }, throttle: {
enabled: true,
onRateLimit: (
retryAfter: number,
options: { url?: string; method?: string },
_octokit: unknown,
retryCount: number,
) => {
logger.warn(`GitHub rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
url: options.url,
method: options.method,
});
return retryCount < 1;
},
onSecondaryRateLimit: (
retryAfter: number,
options: { url?: string; method?: string },
_octokit: unknown,
retryCount: number,
) => {
logger.warn(`GitHub secondary rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
url: options.url,
method: options.method,
});
return retryCount < 1;
},
},
userAgent, userAgent,
}); });
@@ -719,6 +745,19 @@ const linuxServerIOReleasesSchema = z.object({
}), }),
}); });
// Parse a github.com project URL (as returned by the LSIO registry) into an
// "owner/repo" identifier, or null if it is not a github.com repository URL.
const parseGithubRepo = (url: string): string | null => {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") return null;
const [owner, repo] = parsed.pathname.replace(/^\/+/, "").split("/");
return owner && repo ? `${owner}/${repo}` : null;
} catch {
return null;
}
};
const getLinuxServerIOReleaseAsync = async ( const getLinuxServerIOReleaseAsync = async (
baseUrl: string, baseUrl: string,
identifier: string, identifier: string,
@@ -748,6 +787,29 @@ const getLinuxServerIOReleaseAsync = async (
error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` }, error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` },
}; };
// LSIO's /api/v1/images only exposes the single latest version, so a version
// filter can never surface an older release (#4351). When the image has a GitHub
// repository, delegate to the GitHub release provider to fetch the real release
// history and apply the filter, while keeping LSIO's own image metadata. Any
// GitHub failure (no repo, no releases, rate limit) falls back to the LSIO latest.
const githubRepo = parseGithubRepo(release.github_url);
if (githubRepo) {
const githubResult = await getGithubReleaseAsync(getReleaseProviderDefaultUrl("github"), githubRepo, versionRegex);
if (githubResult.success) {
return {
success: true,
data: {
...githubResult.data,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
starsCount: release.stars,
createdAt: release.initial_date ?? githubResult.data.createdAt,
},
};
}
}
const regex = compileVersionRegex(versionRegex); const regex = compileVersionRegex(versionRegex);
if (versionRegex && !regex) { if (versionRegex && !regex) {
return { return {
@@ -75,26 +75,6 @@ describe("calculateCpuUsage", () => {
// (500 / 100000) * 2 * 100 = 1 // (500 / 100000) * 2 * 100 = 1
expect(calculateCpuUsage(stats)).toBe(1); expect(calculateCpuUsage(stats)).toBe(1);
}); });
test("should use the delta against precpu_stats for the current usage", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_002_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// Despite a large lifetime total_usage, only the recent delta counts:
// (2000 / 10000) * 4 * 100 = 80
expect(calculateCpuUsage(stats)).toBe(80);
});
test("should report 0 for a container that was busy over its lifetime but is idle now", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// No CPU delta since the last reading -> current usage is 0, even though the
// lifetime average would be high. This is the regression the fix addresses.
expect(calculateCpuUsage(stats)).toBe(0);
});
}); });
describe("calculateMemoryUsage", () => { describe("calculateMemoryUsage", () => {
+3
View File
@@ -10,6 +10,7 @@ export type SettingsContextProps = Pick<
| "openSearchInNewTab" | "openSearchInNewTab"
| "ddgBangs" | "ddgBangs"
| "pingIconsEnabled" | "pingIconsEnabled"
| "enableRightClickOnWidgets"
> & > &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> & Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["user"], "enableGravatar">; Pick<ServerSettings["user"], "enableGravatar">;
@@ -32,6 +33,7 @@ export type UserSettings = Pick<
| "openSearchInNewTab" | "openSearchInNewTab"
| "ddgBangs" | "ddgBangs"
| "pingIconsEnabled" | "pingIconsEnabled"
| "enableRightClickOnWidgets"
>; >;
export const createSettings = ({ export const createSettings = ({
@@ -48,6 +50,7 @@ export const createSettings = ({
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId, homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId, mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false, pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
enableStatusByDefault: serverSettings.board.enableStatusByDefault, enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus, forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar, enableGravatar: serverSettings.user.enableGravatar,
@@ -56,6 +56,13 @@ export const userPreferenceDefinitions = [
mutationGroup: "pingIconsEnabled", mutationGroup: "pingIconsEnabled",
aliases: ["ping", "icon", "status"], aliases: ["ping", "icon", "status"],
}, },
{
key: "enableRightClickOnWidgets",
kind: "boolean",
guest: false,
mutationGroup: "enableRightClickOnWidgets",
aliases: ["right click", "context menu", "tile", "widget menu"],
},
{ {
key: "fullPreferencesPage", key: "fullPreferencesPage",
kind: "link", kind: "link",
@@ -3,6 +3,7 @@
import { import {
IconActivity, IconActivity,
IconCalendarWeek, IconCalendarWeek,
IconClick,
IconDeviceDesktop, IconDeviceDesktop,
IconExternalLink, IconExternalLink,
IconHome, IconHome,
@@ -46,6 +47,7 @@ export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
homeBoardId: IconHome, homeBoardId: IconHome,
mobileHomeBoardId: IconLayoutBoard, mobileHomeBoardId: IconLayoutBoard,
pingIconsEnabled: IconActivity, pingIconsEnabled: IconActivity,
enableRightClickOnWidgets: IconClick,
fullPreferencesPage: IconSettings, fullPreferencesPage: IconSettings,
}; };
@@ -93,6 +93,7 @@ export const useUserPreferences = () => {
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh }); const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh }); const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh }); const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
const rightClickMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation({ onSuccess: refresh });
const getEffective = (key: UserPreferenceKey): unknown => const getEffective = (key: UserPreferenceKey): unknown =>
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps]; key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
@@ -116,6 +117,8 @@ export const useUserPreferences = () => {
}), }),
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }), firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }), pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
enableRightClickOnWidgets: (uid, _key, value) =>
rightClickMutation.mutateAsync({ id: uid, enableRightClickOnWidgets: value as boolean }),
}; };
const groupPending: Record<string, boolean> = { const groupPending: Record<string, boolean> = {
@@ -123,6 +126,7 @@ export const useUserPreferences = () => {
homeBoards: homeBoardsMutation.isPending, homeBoards: homeBoardsMutation.isPending,
firstDayOfWeek: firstDayMutation.isPending, firstDayOfWeek: firstDayMutation.isPending,
pingIconsEnabled: pingMutation.isPending, pingIconsEnabled: pingMutation.isPending,
enableRightClickOnWidgets: rightClickMutation.isPending,
}; };
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => { const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
+7 -1
View File
@@ -1598,10 +1598,16 @@
}, },
"menu": { "menu": {
"label": { "label": {
"settings": "Settings", "settings": "",
"refresh": "", "refresh": "",
"options": "", "options": "",
"actions": "" "actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
} }
}, },
"create": { "create": {
+60 -4
View File
@@ -198,6 +198,10 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Use icons for pings" "label": "Use icons for pings"
}, },
"enableRightClickOnWidgets": {
"label": "Enable right-click on widgets",
"description": "Show Homarr's context menu when right-clicking a widget. Disable to use the browser's default menu (e.g. \"Open link in new tab\")."
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "Default search engine" "label": "Default search engine"
}, },
@@ -1622,6 +1626,12 @@
"refresh": "Refresh", "refresh": "Refresh",
"options": "Options", "options": "Options",
"actions": "Actions" "actions": "Actions"
},
"status": {
"success": "OK",
"error": "Error",
"loading": "Loading",
"idle": "Idle"
} }
}, },
"create": { "create": {
@@ -2711,6 +2721,11 @@
"user": "User", "user": "User",
"name": "Name", "name": "Name",
"id": "Id", "id": "Id",
"status": "Status",
"directPlay": "Direct play",
"transcoding": "Transcoding",
"paused": "Paused",
"remaining": "{minutes} min left",
"metadata": { "metadata": {
"title": "Stats for nerds", "title": "Stats for nerds",
"video": { "video": {
@@ -3053,11 +3068,20 @@
"limitPerIntegration": { "limitPerIntegration": {
"label": "Limit items per integration", "label": "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"
},
"columnOrder": {
"label": "Column order"
},
"columnWidths": {
"label": "Column widths"
} }
}, },
"errors": { "errors": {
"noColumns": "Select Columns in Items", "noColumns": "Select Columns in Items",
"noCommunications": "Can't load data from integration" "noCommunications": "Can't load data from integration",
"noItems": "No downloads found",
"actionFailed": "Action failed",
"actionFailedMessage": "The action could not be completed. Please try again."
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3080,7 +3104,8 @@
"detailsTitle": "Current index within client" "detailsTitle": "Current index within client"
}, },
"id": { "id": {
"columnTitle": "Id" "columnTitle": "Id",
"detailsTitle": "Item ID"
}, },
"integration": { "integration": {
"columnTitle": "Integration" "columnTitle": "Integration"
@@ -3114,7 +3139,8 @@
}, },
"time": { "time": {
"columnTitle": "Finish time", "columnTitle": "Finish time",
"detailsTitle": "Time since/to completion" "detailsTitle": "Time since/to completion",
"eta": "ETA"
}, },
"type": { "type": {
"columnTitle": "Type", "columnTitle": "Type",
@@ -3158,7 +3184,23 @@
} }
} }
}, },
"globalRatio": "Global Ratio" "globalRatio": "Global Ratio",
"contextMenu": {
"copyName": "Copy name",
"copyId": "Copy ID",
"clearFilters": "Clear filters"
},
"footer": {
"filter": "Filter downloads"
},
"stats": {
"show": "Show global stats",
"hide": "Hide global stats",
"overallProgress": "Overall queue progress",
"totalSize": "Total queue size",
"clients": "{count, plural, one {# client} other {# clients}}"
},
"disconnected": "(disconnected)"
}, },
"mediaReleases": { "mediaReleases": {
"name": "Media releases", "name": "Media releases",
@@ -3195,6 +3237,12 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "Open links in new tab" "label": "Open links in new tab"
},
"statusFilter": {
"label": "Statuses to show"
},
"recentDays": {
"label": "Show only recent requests"
} }
}, },
"pending": { "pending": {
@@ -5936,6 +5984,14 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "Enable right-click on widgets",
"children": {
"detail": {
"title": "Enable right-click on widgets"
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "Open full preferences page" "label": "Open full preferences page"
} }
+4
View File
@@ -130,6 +130,10 @@ export const userPingIconsEnabledSchema = z.object({
pingIconsEnabled: z.boolean(), pingIconsEnabled: z.boolean(),
}); });
export const userEnableRightClickOnWidgetsSchema = z.object({
enableRightClickOnWidgets: z.boolean(),
});
export const userDdgBangsSchema = z.object({ export const userDdgBangsSchema = z.object({
ddgBangs: z.boolean(), ddgBangs: z.boolean(),
}); });
+1
View File
@@ -70,6 +70,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition { export interface WidgetDefinition {
icon: TablerIcon; icon: TablerIcon;
queryKey?: QueryKey; queryKey?: QueryKey;
refetchInterval?: number | null;
supportedIntegrations?: IntegrationKind[]; supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean; integrationsRequired?: boolean;
maxIntegrations?: number; maxIntegrations?: number;
@@ -9,6 +9,8 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad, icon: IconDeviceGamepad,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({ showToggleAllButtons: factory.switch({
@@ -9,6 +9,8 @@ export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd, icon: IconAd,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({ usePiHoleColors: factory.switch({
+2 -10
View File
@@ -184,13 +184,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const t = useScopedI18n("docker"); const t = useScopedI18n("docker");
const isTiny = width <= 256; const isTiny = width <= 256;
const { const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
data,
refetch,
isFetching,
} = clientApi.docker.getContainers.useQuery(undefined, {
refetchInterval: 30_000,
});
const containers = data?.containers ?? []; const containers = data?.containers ?? [];
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]); const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp); const relativeTime = useTimeAgo(timestamp);
@@ -304,9 +298,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
{t("table.totalMemory", { memory: formatBytes(totals.memory) })} {t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text> </Text>
<Tooltip <Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<ActionIcon <ActionIcon
size="sm" size="sm"
variant="transparent" variant="transparent"
File diff suppressed because it is too large. Load diff
+32 -28
View File
@@ -8,48 +8,55 @@ import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
const columnsList = [ const columnsList = [
"id",
"actions",
"added",
"category",
"downSpeed",
"index",
"integration",
"name", "name",
"progress", "progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio", "ratio",
"received", "received",
"sent", "sent",
"size", "category",
"state", "integration",
"time", "index",
"type", "type",
"upSpeed",
] as const satisfies (keyof ExtendedDownloadClientItem)[]; ] as const satisfies (keyof ExtendedDownloadClientItem)[];
const sortingExclusion = ["actions", "id", "state"] as const satisfies readonly (typeof columnsList)[number][];
const columnsSort = columnsList.filter((column) => const sortColumns = [
sortingExclusion.every((exclusion) => exclusion !== column), "name",
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>; "progress",
"size",
"downSpeed",
"upSpeed",
"time",
"added",
"ratio",
"received",
"sent",
"index",
"type",
] as const satisfies readonly (typeof columnsList)[number][];
export const { definition, componentLoader } = createWidgetDefinition("downloads", { export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload, icon: IconDownload,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
(factory) => ({ (factory) => ({
columns: factory.multiSelect({ columns: factory.multiSelect({
defaultValue: ["integration", "name", "progress", "time", "actions"], defaultValue: ["name", "progress", "downSpeed", "time", "state"],
options: columnsList.map((value) => ({ options: columnsList.map((value) => ({
value, value,
label: (t) => t(`widget.downloads.items.${value}.columnTitle`), label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
})), })),
searchable: true, searchable: true,
}), }),
enableRowSorting: factory.switch({
defaultValue: false,
}),
defaultSort: factory.select({ defaultSort: factory.select({
defaultValue: "type", defaultValue: "progress",
options: columnsSort.map((value) => ({ options: sortColumns.map((value) => ({
value, value,
label: (t) => t(`widget.downloads.items.${value}.columnTitle`), label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
})), })),
@@ -67,7 +74,6 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
defaultValue: true, defaultValue: true,
}), }),
activeTorrentThreshold: factory.number({ activeTorrentThreshold: factory.number({
//in KiB/s
validate: z.number().min(0), validate: z.number().min(0),
defaultValue: 0, defaultValue: 0,
step: 1, step: 1,
@@ -87,14 +93,12 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
validate: z.number().min(1), validate: z.number().min(1),
withDescription: true, withDescription: true,
}), }),
columnOrder: factory.text({ defaultValue: "" }),
columnWidths: factory.text({ defaultValue: "" }),
}), }),
{ {
defaultSort: { columnOrder: { shouldHide: () => true },
shouldHide: (options) => !options.enableRowSorting, columnWidths: { shouldHide: () => true },
},
descendingDefaultSort: {
shouldHide: (options) => !options.enableRowSorting,
},
showCompletedUsenet: { showCompletedUsenet: {
shouldHide: (_, integrationKinds) => shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)), !getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)),
+50 -10
View File
@@ -1,12 +1,52 @@
/* Make the MantineReactTable transparent so the board card's opacity-controlled /* Make the DataTable transparent so the board card's opacity-controlled
background shows through. MRT defaults to var(--mantine-color-body) which is opaque. */ background shows through. The mantine-datatable defaults to
.downloads-widget-table, var(--mantine-color-body) which is opaque. */
.downloads-widget-table .mantine-Table-table, .downloads-table,
.downloads-widget-table .mantine-Table-thead, .downloads-table .mantine-datatable-table,
.downloads-widget-table .mantine-Table-tbody, .downloads-table .mantine-datatable-table thead,
.downloads-widget-table .mantine-Table-tfoot, .downloads-table .mantine-datatable-table tbody,
.downloads-widget-table .mantine-Table-tr, .downloads-table .mantine-datatable-table tfoot,
.downloads-widget-table th, .downloads-table th,
.downloads-widget-table td { .downloads-table td {
background-color: transparent !important; background-color: transparent !important;
} }
.downloads-table .mantine-datatable-table tr {
background-color: transparent;
}
.downloads-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.downloads-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.downloads-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.downloads-table td .mantine-Text-root,
.downloads-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.downloads-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.downloads-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
/* Row expansion styling */
.downloads-table .mantine-datatable-row-expansion-cell {
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
}
+1
View File
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", { export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall, icon: IconWall,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from(() => ({})); return optionsBuilder.from(() => ({}));
}, },
@@ -8,6 +8,7 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", { export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor, icon: IconHeartRateMonitor,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
(factory) => ({ (factory) => ({
@@ -8,6 +8,8 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", { export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto, icon: IconPhoto,
queryKey: [["widget", "immich"]],
refetchInterval: null,
supportedIntegrations: ["immich"], supportedIntegrations: ["immich"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", { export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", {
icon: IconGraphFilled, icon: IconGraphFilled,
queryKey: [["widget", "immich"]],
refetchInterval: null,
supportedIntegrations: ["immich"], supportedIntegrations: ["immich"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", { export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch, icon: IconReportSearch,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({ openIndexerSiteInNewTab: factory.switch({
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", { export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", {
icon: IconTicket, icon: IconTicket,
queryKey: [["widget", "mediaRelease"]],
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
layout: factory.select({ layout: factory.select({
@@ -38,6 +38,11 @@ export default function MediaServerWidget({
}: WidgetComponentProps<"mediaRequests-requestList">) { }: WidgetComponentProps<"mediaRequests-requestList">) {
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds, integrationIds,
statuses:
options.statusFilter.length > 0
? options.statusFilter
: ["pending", "approved", "declined", "failed", "completed"],
recentDays: options.recentDays,
}); });
if (!mediaRequests) return <WidgetEmptyState />; if (!mediaRequests) return <WidgetEmptyState />;
@@ -1,4 +1,5 @@
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react"; import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight"; import { openMediaRequestSearch } from "@homarr/spotlight";
@@ -6,6 +7,8 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition"; import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options"; import { optionsBuilder } from "../../options";
const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", { export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion, icon: IconZoomQuestion,
createOptions() { createOptions() {
@@ -13,6 +16,17 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
linksTargetNewTab: factory.switch({ linksTargetNewTab: factory.switch({
defaultValue: true, defaultValue: true,
}), }),
statusFilter: factory.multiSelect({
defaultValue: [...mediaRequestStatusValues],
options: mediaRequestStatusValues.map((value) => ({
value,
label: (t) => t(`widget.mediaRequests-requestList.status.${value}`),
})),
}),
recentDays: factory.number({
validate: z.number().min(0).max(365),
defaultValue: 0,
}),
})); }));
}, },
contextActions: ({ integrationIds }) => [ contextActions: ({ integrationIds }) => [
+106 -24
View File
@@ -2,8 +2,16 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Fragment, useMemo } from "react"; import { Fragment, useMemo } from "react";
import { Avatar, Divider, Flex, Group, Stack, Text, Title } from "@mantine/core"; import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core";
import { IconDeviceTv, IconHeadphones, IconMovie, IconVideo } from "@tabler/icons-react"; import {
IconDeviceTv,
IconHeadphones,
IconMovie,
IconPlayerPause,
IconVideo,
IconWifi,
IconWorld,
} from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table"; import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table";
@@ -27,24 +35,26 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const t = useScopedI18n("widget.mediaServer"); const t = useScopedI18n("widget.mediaServer");
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>( const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
() => [ () => [
{
accessorKey: "sessionName",
header: t("items.name"),
Cell: ({ row }) => (
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{row.original.sessionName}
</Text>
),
},
{ {
accessorKey: "user.username", accessorKey: "user.username",
header: t("items.user"), header: t("items.user"),
size: 160,
Cell: ({ row }) => ( Cell: ({ row }) => (
<Group gap="xs"> <Group gap="xs" wrap="nowrap">
<Avatar size={20} src={row.original.user.profilePictureUrl} /> <Avatar size={28} src={row.original.user.profilePictureUrl} />
<Text size="xs">{row.original.user.username}</Text> <Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.original.user.username}
</Text>
<Text
size="10px"
c="dimmed"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{row.original.sessionName}
</Text>
</Stack>
</Group> </Group>
), ),
}, },
@@ -53,17 +63,85 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
header: t("items.currentlyPlaying"), header: t("items.currentlyPlaying"),
Cell: ({ row }) => { Cell: ({ row }) => {
if (!row.original.currentlyPlaying) return null; const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const Icon = mediaTypeIconMap[row.original.currentlyPlaying.type]; const playback = currentlyPlaying.playback;
const isPaused = playback?.state === "paused";
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
const positionMs = playback?.positionMs ?? null;
const durationMs = playback?.durationMs ?? null;
const progressPercent =
positionMs !== null && durationMs !== null && durationMs > 0
? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null;
const remainingMinutes =
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
return ( return (
<Group gap="xs" align="center"> <Stack gap={4} style={{ minWidth: 0 }}>
<Icon size={16} /> <Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" lineClamp={1}> <Icon
{row.original.currentlyPlaying.name} size={16}
</Text> color={isPaused ? "var(--mantine-color-yellow-6)" : undefined}
</Group> style={{ flexShrink: 0 }}
/>
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
{currentlyPlaying.name}
</Text>
{isPaused && (
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
{t("items.paused")}
</Text>
)}
{!isPaused && remainingMinutes !== null && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{t("items.remaining", { minutes: remainingMinutes.toString() })}
</Text>
)}
</Group>
{progressPercent !== null && (
<Progress
value={progressPercent}
size={4}
color={isPaused ? "yellow" : "green"}
style={{ backgroundColor: "rgba(255, 255, 255, 0.15)" }}
/>
)}
</Stack>
);
},
},
{
id: "status",
header: t("items.status"),
size: 110,
Cell: ({ row }) => {
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const isTranscoding = Boolean(
currentlyPlaying.metadata?.transcoding.target.videoCodec ??
currentlyPlaying.metadata?.transcoding.target.audioCodec ??
currentlyPlaying.metadata?.transcoding.container,
);
return (
<Stack gap={4} align="flex-start">
<Badge size="xs" variant="light" color={isTranscoding ? "orange" : "green"}>
{isTranscoding ? t("items.transcoding") : t("items.directPlay")}
</Badge>
{currentlyPlaying.location && (
<Group gap={4} align="center">
{currentlyPlaying.location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />}
<Text size="10px" c="dimmed" tt="uppercase">
{currentlyPlaying.location}
</Text>
</Group>
)}
</Stack>
); );
}, },
}, },
@@ -107,7 +185,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
initialState: { initialState: {
density: "xs", density: "xs",
columnPinning: { columnPinning: {
right: ["currentlyPlaying"], right: ["currentlyPlaying", "status"],
}, },
}, },
mantineTableHeadProps: { mantineTableHeadProps: {
@@ -144,6 +222,10 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
); );
}, },
py: 4, py: 4,
style: {
overflowX: "hidden",
overflowY: "visible",
},
}), }),
}); });
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", { export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo, icon: IconVideo,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }), showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
@@ -11,6 +11,7 @@ export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", { export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform, icon: IconTransform,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
defaultView: factory.select({ defaultView: factory.select({
@@ -6,6 +6,8 @@ import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", { export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
icon: IconBrandMinecraft, icon: IconBrandMinecraft,
queryKey: [["widget", "minecraft"]],
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
title: factory.text({ defaultValue: "" }), title: factory.text({ defaultValue: "" }),
@@ -7,6 +7,8 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("networkControllerStatus", { export const { definition, componentLoader } = createWidgetDefinition("networkControllerStatus", {
icon: IconTopologyFull, icon: IconTopologyFull,
queryKey: [["widget", "networkController"]],
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
content: factory.select({ content: factory.select({
@@ -7,6 +7,8 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", { export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", {
icon: IconTopologyFull, icon: IconTopologyFull,
queryKey: [["widget", "networkController"]],
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from(() => ({})); return optionsBuilder.from(() => ({}));
}, },
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("notifications", { export const { componentLoader, definition } = createWidgetDefinition("notifications", {
icon: IconMessage, icon: IconMessage,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
hideLogos: factory.switch({ defaultValue: false }), hideLogos: factory.switch({ defaultValue: false }),
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", { export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
icon: IconFileText, icon: IconFileText,
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"], supportedIntegrations: ["paperlessNgx"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
+1
View File
@@ -14,6 +14,7 @@ const relativeDateSchema = z
export const { definition, componentLoader } = createWidgetDefinition("releases", { export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket, icon: IconRocket,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
newReleaseWithin: factory.text({ newReleaseWithin: factory.text({
+1
View File
@@ -12,6 +12,7 @@ import { optionsBuilder } from "../options";
*/ */
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", { export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss, icon: IconRss,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
feedUrls: factory.multiText({ feedUrls: factory.multiText({
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", { export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat, icon: IconSpeedboat,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
(factory) => ({ (factory) => ({
+1
View File
@@ -13,6 +13,7 @@ const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", { export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
icon: IconBuildingBank, icon: IconBuildingBank,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
stock: factory.text({ stock: factory.text({
@@ -6,6 +6,8 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", { export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
icon: IconServer2, icon: IconServer2,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"], supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
@@ -14,6 +14,8 @@ const labelDisplayModeOptions = {
export const { definition, componentLoader } = createWidgetDefinition("systemResources", { export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
icon: IconGraphFilled, icon: IconGraphFilled,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"], supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
+1
View File
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("tracearr", { export const { definition, componentLoader } = createWidgetDefinition("tracearr", {
icon: IconActivityHeartbeat, icon: IconActivityHeartbeat,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
showStreams: factory.switch({ showStreams: factory.switch({
+1
View File
@@ -8,6 +8,7 @@ export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth
export const { definition, componentLoader } = createWidgetDefinition("umami", { export const { definition, componentLoader } = createWidgetDefinition("umami", {
icon: IconChartBar, icon: IconChartBar,
refetchInterval: null,
supportedIntegrations: ["umami"], supportedIntegrations: ["umami"],
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
+1
View File
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("vpn", { export const { componentLoader, definition } = createWidgetDefinition("vpn", {
icon: IconShieldLock, icon: IconShieldLock,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from(() => ({})); return optionsBuilder.from(() => ({}));
}, },
+1 -1
View File
@@ -239,7 +239,7 @@ allowBuilds:
"@tree-sitter-grammars/tree-sitter-yaml": true "@tree-sitter-grammars/tree-sitter-yaml": true
bcrypt: true bcrypt: true
better-sqlite3: true better-sqlite3: true
cpu-features: true cpu-features: false
esbuild: true esbuild: true
sharp: true sharp: true
ssh2: true ssh2: true
+1 -1
View File
@@ -1 +1 @@
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"beanbag420","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontentLine truncated [{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"beanbag420","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontentLine truncated