Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54a40dcda |
No files matched your search
@@ -1,691 +0,0 @@
|
|||||||
---
|
|
||||||
name: datatable-migration
|
|
||||||
description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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 v = entry[key as keyof typeof entry];
|
|
||||||
if (typeof v === "number") widthMap[key] = v;
|
|
||||||
else if (typeof v === "string" && v.endsWith("px"))
|
|
||||||
widthMap[key] = parseInt(v, 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 translation keys (including hidden option labels)
|
|
||||||
8. Add context menu if row actions exist
|
|
||||||
9. Add row expansion if detail view exists
|
|
||||||
10. Add error handling on all mutations
|
|
||||||
11. Add accessibility attributes
|
|
||||||
12. Test at multiple widget widths
|
|
||||||
13. Update docs in `apps/docs/docs/widgets/` if applicable
|
|
||||||
14. Run `pnpm turbo typecheck`
|
|
||||||
15. Run translation spec — catches missing keys
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
---
|
|
||||||
name: documentation-sync
|
|
||||||
description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Documentation Sync
|
|
||||||
|
|
||||||
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
|
|
||||||
|
|
||||||
## What triggers a doc update
|
|
||||||
|
|
||||||
| Code change | Doc location |
|
|
||||||
| ---------------------- | ----------------------------------------------------------- |
|
|
||||||
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
|
|
||||||
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
|
|
||||||
| Changed API surface | `apps/docs/docs/management/api.mdx` |
|
|
||||||
| New/changed env vars | `apps/docs/docs/advanced/` |
|
|
||||||
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
|
|
||||||
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
|
|
||||||
| UI/UX changes | Relevant getting-started or management pages |
|
|
||||||
| New cron job | `apps/docs/docs/management/tasks.mdx` |
|
|
||||||
| Changed permissions | `apps/docs/docs/management/users.mdx` |
|
|
||||||
|
|
||||||
## Integration doc pattern
|
|
||||||
|
|
||||||
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
|
|
||||||
|
|
||||||
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
|
|
||||||
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
|
|
||||||
- Import metadata from `@homarr/definitions` where possible
|
|
||||||
|
|
||||||
## Widget doc pattern
|
|
||||||
|
|
||||||
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
|
|
||||||
|
|
||||||
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
|
|
||||||
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
- Run docs locally: `pnpm dev:docs` from root
|
|
||||||
- Build docs: `pnpm turbo build --filter=@homarr/docs`
|
|
||||||
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
|
|
||||||
- Docusaurus strict mode throws on broken links — fix any broken references before committing
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../.agents/skills
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: codebase-context
|
description: Comprehensive Homarr codebase architecture and conventions reference
|
||||||
description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code.
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Homarr Codebase Context
|
# Homarr Codebase Context
|
||||||
@@ -27,7 +27,7 @@ Homarr is an open-source, self-hosted dashboard for managing homelab services. I
|
|||||||
|
|
||||||
## Repository Structure
|
## Repository Structure
|
||||||
|
|
||||||
```text
|
```
|
||||||
homarr/
|
homarr/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── nextjs/ # Main Next.js application (port 3000)
|
│ ├── nextjs/ # Main Next.js application (port 3000)
|
||||||
@@ -66,6 +66,8 @@ homarr/
|
|||||||
│ ├── analytics/ # Server-side analytics (Umami)
|
│ ├── analytics/ # Server-side analytics (Umami)
|
||||||
│ ├── server-settings/ # Server setting keys/types
|
│ ├── server-settings/ # Server setting keys/types
|
||||||
│ ├── settings/ # User-facing settings UI context
|
│ ├── settings/ # User-facing settings UI context
|
||||||
|
│ ├── old-import/ # Legacy Homarr import
|
||||||
|
│ ├── old-schema/ # Legacy Homarr zod schemas
|
||||||
│ └── cli/ # Node CLI for ops (brocli)
|
│ └── cli/ # Node CLI for ops (brocli)
|
||||||
├── tooling/
|
├── tooling/
|
||||||
│ ├── typescript/ # Base tsconfig
|
│ ├── typescript/ # Base tsconfig
|
||||||
@@ -77,11 +79,11 @@ homarr/
|
|||||||
|
|
||||||
## Three Runtime Services
|
## Three Runtime Services
|
||||||
|
|
||||||
| Service | Port | Role |
|
| Service | Port | Role |
|
||||||
| --------- | ---- | -------------------------------------------- |
|
|---------|------|------|
|
||||||
| Next.js | 3000 | Main web app (SSR + API routes) |
|
| Next.js | 3000 | Main web app (SSR + API routes) |
|
||||||
| WebSocket | 3001 | tRPC subscriptions via `ws` |
|
| WebSocket | 3001 | tRPC subscriptions via `ws` |
|
||||||
| Tasks | 3002 | Cron job runner + management API via Fastify |
|
| Tasks | 3002 | Cron job runner + management API via Fastify |
|
||||||
|
|
||||||
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
|
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
|
||||||
|
|
||||||
@@ -110,7 +112,6 @@ In Docker, nginx listens on **7575** and proxies to all three. The tasks service
|
|||||||
### Multi-Database Support
|
### Multi-Database Support
|
||||||
|
|
||||||
Drizzle schemas exist in three parallel implementations:
|
Drizzle schemas exist in three parallel implementations:
|
||||||
|
|
||||||
- `packages/db/schema/sqlite.ts`
|
- `packages/db/schema/sqlite.ts`
|
||||||
- `packages/db/schema/mysql.ts`
|
- `packages/db/schema/mysql.ts`
|
||||||
- `packages/db/schema/postgresql.ts`
|
- `packages/db/schema/postgresql.ts`
|
||||||
@@ -120,7 +121,6 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
|
|||||||
### Widget System
|
### Widget System
|
||||||
|
|
||||||
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
|
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
|
||||||
|
|
||||||
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
|
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
|
||||||
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
|
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
|
||||||
- Loads dynamically via `next/dynamic`
|
- Loads dynamically via `next/dynamic`
|
||||||
@@ -133,7 +133,6 @@ Integrations are defined in `packages/definitions/src/integration.ts` (`integrat
|
|||||||
### Cron Job System
|
### Cron Job System
|
||||||
|
|
||||||
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
|
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
|
||||||
|
|
||||||
- Uses `createCronJob` with a cron expression
|
- Uses `createCronJob` with a cron expression
|
||||||
- Publishes results to Redis channels
|
- Publishes results to Redis channels
|
||||||
- Widget subscriptions consume those channels via tRPC subscriptions
|
- Widget subscriptions consume those channels via tRPC subscriptions
|
||||||
@@ -161,7 +160,6 @@ Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `o
|
|||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
Key env vars (from `.env.example`):
|
Key env vars (from `.env.example`):
|
||||||
|
|
||||||
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
|
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
|
||||||
- `DB_URL`: Database connection string
|
- `DB_URL`: Database connection string
|
||||||
- `AUTH_SECRET`: NextAuth secret
|
- `AUTH_SECRET`: NextAuth secret
|
||||||
@@ -193,3 +191,16 @@ Key env vars (from `.env.example`):
|
|||||||
- Drag-and-drop via `@dnd-kit/*`
|
- Drag-and-drop via `@dnd-kit/*`
|
||||||
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
|
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
|
||||||
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
|
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
|
||||||
|
|
||||||
|
## Documentation (`apps/docs`)
|
||||||
|
|
||||||
|
Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`.
|
||||||
|
|
||||||
|
- Run with `pnpm dev:docs` from root
|
||||||
|
- Build with `pnpm turbo build --filter=@homarr/docs`
|
||||||
|
- Uses Tailwind CSS 3 for custom pages (not Mantine)
|
||||||
|
- Imports `@homarr/definitions` for type-safe integration/widget metadata
|
||||||
|
- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/`
|
||||||
|
- Integration docs: `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
|
||||||
|
- Widget docs: `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
|
||||||
|
- Strict link checking: broken links/anchors cause build failure
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
description: Keep documentation in sync with code changes
|
||||||
|
globs:
|
||||||
|
- packages/definitions/src/**
|
||||||
|
- packages/widgets/src/**
|
||||||
|
- packages/integrations/src/**
|
||||||
|
- packages/api/src/**
|
||||||
|
- apps/nextjs/src/**
|
||||||
|
- apps/docs/**
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documentation Sync Rule
|
||||||
|
|
||||||
|
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
|
||||||
|
|
||||||
|
## What triggers a doc update
|
||||||
|
|
||||||
|
| Code change | Doc location |
|
||||||
|
|---|---|
|
||||||
|
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
|
||||||
|
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
|
||||||
|
| Changed API surface | `apps/docs/docs/management/api.mdx` |
|
||||||
|
| New/changed env vars | `apps/docs/docs/advanced/` |
|
||||||
|
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
|
||||||
|
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
|
||||||
|
| UI/UX changes | Relevant getting-started or management pages |
|
||||||
|
| New cron job | `apps/docs/docs/management/tasks.mdx` |
|
||||||
|
| Changed permissions | `apps/docs/docs/management/users.mdx` |
|
||||||
|
|
||||||
|
## Integration doc pattern
|
||||||
|
|
||||||
|
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
|
||||||
|
|
||||||
|
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
|
||||||
|
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
|
||||||
|
- Import metadata from `@homarr/definitions` where possible
|
||||||
|
|
||||||
|
## Widget doc pattern
|
||||||
|
|
||||||
|
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
|
||||||
|
|
||||||
|
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
|
||||||
|
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- Run docs locally: `pnpm dev:docs` from root
|
||||||
|
- Build docs: `pnpm turbo build --filter=@homarr/docs`
|
||||||
|
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
|
||||||
|
- Docusaurus strict mode throws on broken links — fix any broken references before committing
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: mcp-integration
|
description: How to expose tRPC procedures as MCP tools for AI clients
|
||||||
description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip.
|
globs: packages/api/src/router/**/*.ts
|
||||||
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# MCP Integration
|
# MCP Integration Guide
|
||||||
|
|
||||||
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
|
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
|
||||||
|
|
||||||
Exposing a procedure makes it callable by any AI client with an API key. Before adding `.meta({ mcp })`, review what the tool exposes: authorization requirements, sensitive data, tenant isolation, auditability, and whether the action is destructive. Prefer exposing read-only queries over mutations unless the mutation is safe and permission-scoped. When in doubt, ask for a security review before shipping a new MCP tool.
|
|
||||||
|
|
||||||
## Adding MCP to a procedure
|
## Adding MCP to a procedure
|
||||||
|
|
||||||
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
|
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
|
||||||
@@ -17,7 +16,7 @@ Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure cha
|
|||||||
// Read-only tool (query)
|
// Read-only tool (query)
|
||||||
myProcedure: protectedProcedure
|
myProcedure: protectedProcedure
|
||||||
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
|
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
|
||||||
.input(z.object({ limit: z.number().default(10) }))
|
.input(z.object({ limit: z.number().default(50) }))
|
||||||
.query(async ({ ctx, input }) => { ... })
|
.query(async ({ ctx, input }) => { ... })
|
||||||
|
|
||||||
// Write tool (mutation)
|
// Write tool (mutation)
|
||||||
@@ -39,27 +38,23 @@ The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()`
|
|||||||
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
|
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
|
||||||
|
|
||||||
**Must include:**
|
**Must include:**
|
||||||
|
|
||||||
- What the tool returns or does
|
- What the tool returns or does
|
||||||
- Which integrations/services it works with (if applicable)
|
- Which integrations/services it works with (if applicable)
|
||||||
- How to get required IDs (e.g., "Use integration_all to get the integrationId")
|
- How to get required IDs (e.g., "Use integration_all to get the integrationId")
|
||||||
- Permission requirements (if non-obvious)
|
- Permission requirements (if non-obvious)
|
||||||
|
|
||||||
**Good:**
|
**Good:**
|
||||||
|
```
|
||||||
```text
|
|
||||||
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
|
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bad:**
|
**Bad:**
|
||||||
|
```
|
||||||
```text
|
|
||||||
"Get calendar events"
|
"Get calendar events"
|
||||||
```
|
```
|
||||||
|
|
||||||
**For tools that return permission fields**, explain what they mean:
|
**For tools that return permission fields**, explain what they mean:
|
||||||
|
```
|
||||||
```text
|
|
||||||
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
|
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -82,13 +77,11 @@ export const mcpRouter = createTRPCRouter({
|
|||||||
## What to expose vs. what to skip
|
## What to expose vs. what to skip
|
||||||
|
|
||||||
**Expose:**
|
**Expose:**
|
||||||
|
|
||||||
- Data queries (list, search, get by ID)
|
- Data queries (list, search, get by ID)
|
||||||
- Actions users would ask an AI to do (toggle, create, delete, approve)
|
- Actions users would ask an AI to do (toggle, create, delete, approve)
|
||||||
- Status checks (health, stats, summaries)
|
- Status checks (health, stats, summaries)
|
||||||
|
|
||||||
**Skip:**
|
**Skip:**
|
||||||
|
|
||||||
- Subscriptions (WebSocket-only, not supported by MCP)
|
- Subscriptions (WebSocket-only, not supported by MCP)
|
||||||
- Internal procedures (session management, onboarding steps)
|
- Internal procedures (session management, onboarding steps)
|
||||||
- File upload/download procedures
|
- File upload/download procedures
|
||||||
@@ -17,8 +17,7 @@ coverage
|
|||||||
dev
|
dev
|
||||||
.build
|
.build
|
||||||
e2e
|
e2e
|
||||||
apps/docs/*
|
apps/docs
|
||||||
!apps/docs/package.json
|
|
||||||
apps/nextjs/.next
|
apps/nextjs/.next
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -33,11 +33,6 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
- 1.75.0
|
|
||||||
- 1.74.0
|
|
||||||
- 1.73.0
|
|
||||||
- 1.72.0
|
|
||||||
- 1.71.0
|
|
||||||
- 1.70.0
|
- 1.70.0
|
||||||
- 1.69.2
|
- 1.69.2
|
||||||
- 1.69.0
|
- 1.69.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
extends: ["config:recommended", ":dependencyDashboardApproval"],
|
extends: ["config:recommended"],
|
||||||
packageRules: [
|
packageRules: [
|
||||||
{
|
{
|
||||||
matchPackageNames: ["^@homarr/"],
|
matchPackageNames: ["^@homarr/"],
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
rangeStrategy: "bump",
|
rangeStrategy: "bump",
|
||||||
automerge: false,
|
automerge: false,
|
||||||
baseBranchPatterns: ["dev"],
|
baseBranchPatterns: ["dev"],
|
||||||
dependencyDashboard: true,
|
dependencyDashboard: false,
|
||||||
// prevent special characters in branch names (otherwise git errors occur on windows)
|
// prevent special characters in branch names (otherwise git errors occur on windows)
|
||||||
branchNameStrict: true,
|
branchNameStrict: true,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: "[Dependency Updates] Auto Approve"
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
approve-automatic-prs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 2
|
||||||
|
# 158783068: homarr-renovate[bot]
|
||||||
|
# 190541745: homarr-crowdin[bot]
|
||||||
|
# 210161987: homarr-update-contributors[bot]
|
||||||
|
# 175486441: homarr-releases[bot]
|
||||||
|
# Extracted from https://api.github.com/users/<username>
|
||||||
|
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
- name: Obtain token
|
||||||
|
id: obtainToken
|
||||||
|
uses: actions/create-github-app-token@v3
|
||||||
|
with:
|
||||||
|
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||||
|
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||||
|
permission-pull-requests: write # required to approve pull request
|
||||||
|
|
||||||
|
- name: Install GitHub CLI
|
||||||
|
run: sudo apt-get install -y gh
|
||||||
|
- name: Approve automatic PRs
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
run: |
|
||||||
|
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
name: "[Bots] Reconcile Auto-merge"
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "*/30 * * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
reconcile:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Obtain token
|
|
||||||
id: obtainToken
|
|
||||||
uses: actions/create-github-app-token@v3
|
|
||||||
with:
|
|
||||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
|
||||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
|
||||||
permission-pull-requests: write # required to enable auto-merge
|
|
||||||
|
|
||||||
- name: Install GitHub CLI
|
|
||||||
run: sudo apt-get install -y gh
|
|
||||||
|
|
||||||
- name: Re-enable auto-merge for prepared bot PRs
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
|
||||||
run: |
|
|
||||||
# 158783068: homarr-renovate[bot]
|
|
||||||
# 190541745: homarr-crowdin[bot]
|
|
||||||
# 210161987: homarr-update-contributors[bot]
|
|
||||||
# 175441: homarr-releases[bot]
|
|
||||||
gh pr list --repo "${{ github.repository }}" --base dev --state open --limit 100 \
|
|
||||||
--json number,author,reviewDecision,mergeStateStatus,autoMergeRequest \
|
|
||||||
--jq '.[] | select(.author.login as $login | ["app/homarr-renovate", "app/homarr-crowdin", "app/homarr-update-contributors", "app/homarr-releases"] | index($login)) | select(.reviewDecision == "APPROVED") | select(.mergeStateStatus == "CLEAN") | select(.autoMergeRequest == null) | .number' \
|
|
||||||
| while read -r pr; do
|
|
||||||
echo "Re-enabling auto-merge on PR #$pr"
|
|
||||||
gh pr merge "$pr" --auto --squash
|
|
||||||
done
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
name: "[Bots] Approve and Auto-merge"
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
approve-and-automerge:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
# 158783068: homarr-renovate[bot]
|
|
||||||
# 190541745: homarr-crowdin[bot]
|
|
||||||
# 210161987: homarr-update-contributors[bot]
|
|
||||||
# 175441: homarr-releases[bot]
|
|
||||||
# Extracted from https://api.github.com/users/<username>
|
|
||||||
if: |
|
|
||||||
(github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175441) &&
|
|
||||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
|
||||||
github.event.pull_request.base.ref == 'dev'
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Obtain token
|
|
||||||
id: obtainToken
|
|
||||||
uses: actions/create-github-app-token@v3
|
|
||||||
with:
|
|
||||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
|
||||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
|
||||||
permission-pull-requests: write # required to approve a pull request and enable auto-merge
|
|
||||||
|
|
||||||
- name: Install GitHub CLI
|
|
||||||
run: sudo apt-get install -y gh
|
|
||||||
|
|
||||||
- name: Approve and enable auto-merge
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
|
||||||
run: |
|
|
||||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
|
||||||
if [ "$(gh pr view "$PR_NUMBER" --json reviewDecision --jq .reviewDecision)" != "APPROVED" ]; then
|
|
||||||
gh pr review "$PR_NUMBER" --approve --body "Automatically approved by Homarr bot policy"
|
|
||||||
fi
|
|
||||||
if [ "$(gh pr view "$PR_NUMBER" --json autoMergeRequest --jq '.autoMergeRequest != null')" != "true" ]; then
|
|
||||||
gh pr merge "$PR_NUMBER" --auto --squash
|
|
||||||
fi
|
|
||||||
@@ -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,8 +81,11 @@ 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 --concurrency=2
|
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
|
||||||
|
|
||||||
- name: Validate workspace configuration
|
- name: Validate workspace configuration
|
||||||
run: pnpm lint:ws
|
run: pnpm lint:ws
|
||||||
@@ -90,8 +93,12 @@ 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 and E2E
|
name: Container, E2E, and memory
|
||||||
needs: fast-gate
|
needs: fast-gate
|
||||||
if: |
|
if: |
|
||||||
github.event_name == 'merge_group' ||
|
github.event_name == 'merge_group' ||
|
||||||
@@ -104,7 +111,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 || '' }}
|
||||||
@@ -113,11 +120,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: .
|
||||||
@@ -158,7 +165,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:
|
||||||
@@ -184,6 +191,82 @@ 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: |
|
||||||
@@ -233,7 +316,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: |
|
||||||
|
|||||||
@@ -71,12 +71,10 @@ apps/nextjs/public/images/background.png
|
|||||||
|
|
||||||
# personal agent config
|
# personal agent config
|
||||||
.opencode
|
.opencode
|
||||||
.agents/*
|
.agents/
|
||||||
!.agents/skills/
|
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
|
||||||
# docusaurus
|
# docusaurus
|
||||||
apps/docs/.docusaurus
|
apps/docs/.docusaurus
|
||||||
apps/docs/build
|
apps/docs/build
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
benchmark-results
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"mantine": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "@mantine/mcp-server"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 47 KiB |