Compare commits

..
Author SHA1 Message Date
ajnart 9d3de49875 feat(media-requests): filter requests list by status and recent days
Adds two new options to the mediaRequests-requestList widget so users
can narrow the Overseerr/Jellyseerr requests list to specific request
statuses and a recent time window (in days):

- statusFilter (multi-select, defaults to all five statuses)
- recentDays  (number 0-365, 0 disables the time filter)

Filtering is applied server-side in the request handler and tRPC
getLatestRequests procedure, with defaults preserved for existing
widgets.

Closes #4561
2026-07-13 18:47:28 +02:00
Thomas 03f68a0421 fix(qbittorrent): sort torrents by progress so active items appear before limit cutoff (#6297) 2026-07-12 14:56:24 +02:00
Bergasha 807e5eb2a9 feat(docker-widget): replace footer timestamp text with compact refresh button (#6307) 2026-07-12 14:51:56 +02:00
Thomas 8e8c29979f fix(system-disks): format absolute storage values with shared byte helper (#6299) 2026-07-12 14:50:58 +02:00
Manuel 7d859c1af0 docs: update pr template (#6147) 2026-07-12 14:49:56 +02:00
homarr-renovate[bot] e9543dc30d chore(deps): update tiptap monorepo to v3.27.3 (#6308)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 09:12:55 +00:00
Nerdy Technician 9f313440ac fix(integrations): update PatchMon API key settings link (#6302) 2026-07-12 08:59:52 +02:00
homarr-renovate[bot] c2508dffea chore(deps): update dependency posthog-js to ^1.396.7 (#6285)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 06:18:17 +00:00
Artur d44bc06a93 feat(notebook): support text direction controls (#6293) 2026-07-12 07:58:02 +02:00
homarr-renovate[bot] c56b91570b chore(deps): update tiptap monorepo to v3.27.2 (#6292)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 03:01:23 +00:00
homarr-renovate[bot] d53c4e6513 chore(deps): update dependency hono@<4.6.5 to >=4.12.28 (#6291)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 02:58:51 +00:00
homarr-renovate[bot] 0426d600cc chore(deps): update dependency oxfmt to ^0.58.0 (#6305)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 00:06:59 +00:00
36 changed files with 992 additions and 582 deletions

No files matched your search

+8
View File
@@ -11,3 +11,11 @@
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
**When contributing a new integration or widget:**
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
tests (e.g. what happens if it is offline)
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
provide support and maintain the integration (e.g. in case of breaking or failure)
@@ -1,41 +1,44 @@
---
title: 'Media Request List'
description: 'See a list of all media requests from your integration'
title: "Media Request List"
description: "See a list of all media requests from your integration"
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import { mediaRequestListWidget } from '.';
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
import { overseerrIntegration } from '@site/docs/integrations/overseerr';
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader
widget={mediaRequestListWidget}
categories={['Media requests']}
/>
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
### Screenshots
import mediaRequestList from './img/list.png';
import mediaRequestList from "./img/list.png";
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
### Supported Integrations
<WidgetIntegrations items={[{
integration: jellyseerrIntegration,
}, {
integration: overseerrIntegration,
}]} />
<WidgetIntegrations
items={[
{
integration: jellyseerrIntegration,
},
{
integration: overseerrIntegration,
},
]}
/>
### Interactions
@@ -47,4 +50,5 @@ import mediaRequestList from './img/list.png';
<AddingWidget />
### Configuration
<WidgetConfig configuration={mediaRequestListWidget.configuration} />
@@ -14,6 +14,18 @@ export const mediaRequestListWidget: WidgetDefinition = {
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Statuses to show",
description: "Only requests with these statuses will be shown. Defaults to all statuses.",
values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'",
defaultValue: "All statuses",
},
{
name: "Show only recent requests",
description: "Limit to requests created within this many days. Set to 0 to disable the time filter.",
values: "0-365",
defaultValue: "0",
},
],
},
};
+40 -20
View File
@@ -1,29 +1,25 @@
---
title: 'Notebook'
description: 'A simple notebook widget that supports markdown.'
title: "Notebook"
description: "A simple notebook widget that supports markdown."
hide_title: true
---
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { notebookWidget } from '.';
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { notebookWidget } from ".";
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
<WidgetHeader
widget={notebookWidget}
categories={['Notes']}
/>
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
It is based on Tiptap.dev and supports most of its features.
### Screenshots
import editOff from './img/normal.png';
import editOn from './img/edit.png';
import editOff from "./img/normal.png";
import editOn from "./img/edit.png";
<div className='flex gap-1'>
<div className="flex gap-1">
<img src={editOff} alt="notebook edit-off" width={128 * 3} />
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div>
@@ -33,24 +29,27 @@ import editOn from './img/edit.png';
<AddingWidget />
### Configuration
<WidgetConfig configuration={notebookWidget.configuration} />
### Editing
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
![](./img/notebook-enter-editing.webp)
You can then decide to save your changes or cancel them to exit editing, using the buttons placed in the same location.
![](./img/notebook-exit-editing.webp)
### Notebook toolbar functions
The notebook offers a variaty of functions to edit the text and more.
The notebook offers a variety of functions to edit the text and more.
![](./img/notebook-toolbar.webp)
#### Common text formatting
| Function | Description |
| ---------------------- | ------------------------------------------------------------------- |
| Bold | Makes the selected text bold |
@@ -62,35 +61,51 @@ The notebook offers a variaty of functions to edit the text and more.
| Clear formatting | Erases any of the preceding function's effects on the selected text |
#### Heading
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
#### Text Alignement
Select the alignement on the line. Left, center or right.
#### Text Alignment
Select the alignment on the line. Left, center or right.
#### Text direction
Set the current paragraph or heading to left-to-right or right-to-left. This is useful when writing right-to-left text while Homarr is using a left-to-right language, or vice versa.
#### Misc functions
##### Blockquote
Puts the current line in a quoting block.
##### Horizontal line
Adds a line that goes from the far right to the far left as a separator.
#### Lists
##### Bullet lists
Simple list with a dot preceding each element.
##### Ordered lists
List with numbers preceding each element.
##### Check lists
List with a checkbox on each element.
##### Indentation changes
![](./img/notebook-toolbar-lists.webp)
When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
#### Insert functions
##### Link controls
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
Pasting a link directly into the text will automatically put the link anchor on it too.
@@ -102,16 +117,20 @@ The second button will remove the link while leaving the text intact.
This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab.
##### Image controls
![](./img/notebook-image-modal.webp)
##### Source
You can use a normal link to an image or you can also use locally stored images.
##### Width
The aspect ratio will always be respected, so only the width field is necessary.
You can provide a value in a plain number or you can also use percents to have your image size dynamically change on different screens.
#### Table
This last control allows you to add a table, for which you can determine the number of columns and row before inserting it.
![](./img/notebook-table-modal.webp)
@@ -119,6 +138,7 @@ This last control allows you to add a table, for which you can determine the num
Clicking that button while in a table will delete it.
##### Table controls
![](./img/notebook-toolbar-table.webp)
While in a table, further controls will appear to help edit the table.
@@ -134,4 +154,4 @@ All the following ones are for editing the wize of the table:
- Delete Column
- Add row on top
- Add row below
- Delete Row
- Delete Row
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{humanFileSize(file.size)}
{formatBytes(file.size)}
</Text>
</Group>
<Button
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server";
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
/>
</TableTd>
<TableTd>{media.name}</TableTd>
<TableTd>{humanFileSize(media.size)}</TableTd>
<TableTd>{formatBytes(media.size)}</TableTd>
<TableTd>
{media.creator ? (
<Group gap="sm">
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
{formatBytes(bytesUsage)}
</Text>
);
},
@@ -3,7 +3,10 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
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 { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -16,13 +19,14 @@ export const mediaRequestsRouter = createTRPCRouter({
mcp: {
enabled: true,
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")))
.query(async ({ ctx }) => {
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
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 results
+104 -5
View File
@@ -21,11 +21,17 @@ export const randomInt = (min: number, max: number) => {
};
/**
* Number of bytes to si format. (Division by 1024)
* Does not accept floats, size in bytes should be an integer.
* Will return "NaI" and logs a warning if a float is passed.
* Concat as parameters so it is not added if the returned value is "NaI" or "∞".
* Returns "∞" if the size is too large to be represented in the current format.
* Number of bytes to a human-readable string using binary (KiB) suffixes.
* Does not accept floats; size in bytes should be an integer.
* Will return "NaI" and log a warning if a float is passed.
* `concat` is appended after the unit so it is omitted when the returned
* value is "NaI" or "∞". Returns "∞" if the size is too large to be
* represented in the current format.
*
* @deprecated Use `formatBytes` for single values, `formatBytesPair` for
* paired "used / total" displays, and `formatByteRate` for byte-per-second
* rates. This function is kept for backwards compatibility but should not
* be used in new code.
*/
export const humanFileSize = (size: number, concat = ""): string => {
//64bit limit for Number stops at EiB
@@ -48,6 +54,99 @@ export const humanFileSize = (size: number, concat = ""): string => {
return "∞";
};
const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] as const;
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
export type ByteUnitSystem = "binary" | "decimal";
export interface FormatBytesOptions {
/**
* Unit system to use. "binary" uses 1024 as the base and KiB/MiB/GiB/TiB suffixes,
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "binary".
*/
unit?: ByteUnitSystem;
}
const pickUnitIndex = (bytes: number, base: number, lastIndex: number): number => {
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
let index = 0;
let value = bytes;
while (value >= base && index < lastIndex) {
value /= base;
index++;
}
return index;
};
const sanitizeBytes = (bytes: number): number => (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
const resolveUnitConfig = (options: FormatBytesOptions) => {
const { unit = "binary" } = options;
return {
units: unit === "binary" ? BINARY_UNITS : DECIMAL_UNITS,
base: unit === "binary" ? 1024 : 1000,
};
};
/**
* Format a byte value as a human-readable string with a unit suffix.
*
* Picks the largest unit that keeps the scaled value below the base. Use
* `formatBytesPair` when two related values (e.g. used and available) should
* share a common unit.
*
* @example
* formatBytes(0); // "0.0 B"
* formatBytes(1024); // "1.0 KiB"
* formatBytes(985828802560); // "918.1 GiB"
* formatBytes(985828802560, { unit: "decimal" }); // "985.8 GB"
*/
export const formatBytes = (bytes: number, options: FormatBytesOptions = {}): string => {
const { units, base } = resolveUnitConfig(options);
const safe = sanitizeBytes(bytes);
const index = pickUnitIndex(safe, base, units.length - 1);
const scaled = safe / base ** index;
return `${scaled.toFixed(1)} ${units[index]}`;
};
/**
* Format two related byte values (typically `used` and `available`) with a
* shared unit. The unit is picked from the sum of the two values so the
* combined display stays consistent.
*
* @example
* formatBytesPair(985828802560, 2858736793190);
* // { used: "0.9 TiB", available: "2.6 TiB" }
*/
export const formatBytesPair = (
used: number,
available: number,
options: FormatBytesOptions = {},
): { used: string; available: string } => {
const { units, base } = resolveUnitConfig(options);
const safeUsed = sanitizeBytes(used);
const safeAvailable = sanitizeBytes(available);
const index = pickUnitIndex(safeUsed + safeAvailable, base, units.length - 1);
const suffix = units[index];
return {
used: `${(safeUsed / base ** index).toFixed(1)} ${suffix}`,
available: `${(safeAvailable / base ** index).toFixed(1)} ${suffix}`,
};
};
/**
* Format a byte-per-second rate (e.g. network throughput) as a human-readable
* string with a `/s` suffix. The value is formatted with the same rules as
* `formatBytes` and a trailing `/s` is appended.
*
* @example
* formatByteRate(0); // "0.0 B/s"
* formatByteRate(1024); // "1.0 KiB/s"
* formatByteRate(985828802560, { unit: "decimal" }); // "985.8 GB/s"
*/
export const formatByteRate = (bytes: number, options: FormatBytesOptions = {}): string =>
`${formatBytes(bytes, options)}/s`;
const IMPERIAL_MULTIPLIER = 1.609344;
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { formatByteRate, formatBytes, formatBytesPair } from "../number";
describe("formatBytes", () => {
it("returns bytes for small values", () => {
expect(formatBytes(0)).toBe("0.0 B");
expect(formatBytes(1)).toBe("1.0 B");
expect(formatBytes(1023)).toBe("1023.0 B");
});
it("scales binary units correctly", () => {
expect(formatBytes(1024)).toBe("1.0 KiB");
expect(formatBytes(1024 ** 2)).toBe("1.0 MiB");
expect(formatBytes(1024 ** 3)).toBe("1.0 GiB");
expect(formatBytes(1024 ** 4)).toBe("1.0 TiB");
});
it("scales decimal units when requested", () => {
expect(formatBytes(1000, { unit: "decimal" })).toBe("1.0 KB");
expect(formatBytes(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB");
expect(formatBytes(1000 ** 3, { unit: "decimal" })).toBe("1.0 GB");
expect(formatBytes(1000 ** 4, { unit: "decimal" })).toBe("1.0 TB");
});
it("formats a typical storage value with one decimal", () => {
expect(formatBytes(985828802560)).toBe("918.1 GiB");
expect(formatBytes(985828802560, { unit: "decimal" })).toBe("985.8 GB");
});
it("caps at the largest unit to avoid overflow", () => {
expect(formatBytes(Number.MAX_SAFE_INTEGER)).toMatch(/PiB$/);
});
it("returns the zero value for negative or non-finite input", () => {
expect(formatBytes(-1)).toBe("0.0 B");
expect(formatBytes(Number.NaN)).toBe("0.0 B");
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0.0 B");
});
});
describe("formatBytesPair", () => {
it("picks the unit of the larger total and applies it to both values", () => {
const { used, available } = formatBytesPair(985828802560, 2858736793190);
expect(used).toBe("0.9 TiB");
expect(available).toBe("2.6 TiB");
});
it("uses a smaller unit when both values fit in it", () => {
const { used, available } = formatBytesPair(1024 * 1024 * 100, 1024 * 1024 * 200);
expect(used).toBe("100.0 MiB");
expect(available).toBe("200.0 MiB");
});
it("falls back to zero when both inputs are invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, -1);
expect(used).toBe("0.0 B");
expect(available).toBe("0.0 B");
});
it("uses the same unit across both values when one is invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, 1024 ** 4);
expect(used).toBe("0.0 TiB");
expect(available).toBe("1.0 TiB");
});
it("honours the decimal unit option", () => {
const { used, available } = formatBytesPair(500_000_000_000, 1_500_000_000_000, { unit: "decimal" });
expect(used).toBe("0.5 TB");
expect(available).toBe("1.5 TB");
});
});
describe("formatByteRate", () => {
it("appends a /s suffix to the formatted value", () => {
expect(formatByteRate(0)).toBe("0.0 B/s");
expect(formatByteRate(1024)).toBe("1.0 KiB/s");
expect(formatByteRate(1024 ** 3)).toBe("1.0 GiB/s");
});
it("honours the decimal unit option", () => {
expect(formatByteRate(1000, { unit: "decimal" })).toBe("1.0 KB/s");
expect(formatByteRate(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB/s");
});
it("returns zero for invalid input", () => {
expect(formatByteRate(-1)).toBe("0.0 B/s");
expect(formatByteRate(Number.NaN)).toBe("0.0 B/s");
});
});
@@ -1,5 +1,3 @@
import { humanFileSize } from "@homarr/common";
import "@homarr/redis";
import dayjs from "dayjs";
@@ -48,7 +46,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
.map((storage, index) => ({
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
used: humanFileSize(storageLoad[index]!),
used: `${storageLoad[index]!}`,
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
})),
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = await this.getClientAsync();
const torrents = await client.listTorrents({ limit: input.limit });
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 },
@@ -1,7 +1,6 @@
import dayjs from "dayjs";
import type { fetch as undiciFetch } from "undici/types/fetch";
import { humanFileSize } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -59,7 +58,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
fileSystem: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name,
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
used: `${disk.fsUsed * 1024}`, // API is in KiB (kibibytes), convert to bytes
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
})),
@@ -23,6 +23,8 @@ type OptionMapping = {
const optionMapping: OptionMapping = {
"mediaRequests-requestList": {
linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab,
statusFilter: () => undefined,
recentDays: () => undefined,
},
"mediaRequests-requestStats": {},
bookmarks: {
@@ -1,16 +1,42 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
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";
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<
MediaRequest[],
IntegrationKindByCategory<"mediaRequest">,
Record<string, never>
MediaRequestListInput
>({
async requestAsync(integration, _input) {
async requestAsync(integration, input) {
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;
});
},
});
+11
View File
@@ -1956,6 +1956,8 @@
"clear": "Clear formatting",
"heading": "Heading {level}",
"align": "Align text: {position}",
"directionLtr": "Set text direction left to right",
"directionRtl": "Set text direction right to left",
"blockquote": "Blockquote",
"horizontalLine": "Horizontal line",
"bulletList": "Bullet list",
@@ -3193,6 +3195,12 @@
"option": {
"linksTargetNewTab": {
"label": "Open links in new tab"
},
"statusFilter": {
"label": "Statuses to show"
},
"recentDays": {
"label": "Show only recent requests"
}
},
"pending": {
@@ -5203,6 +5211,9 @@
"title": "Containers",
"table": {
"updated": "Updated {when}",
"refresh": {
"lastUpdated": "Last refresh {when}"
},
"search": "Seach {count} containers",
"selected": "{selectCount} of {totalCount} containers selected",
"footer": "Total {count} containers",
@@ -3,7 +3,7 @@
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { WidgetComponentProps } from "../definition";
@@ -84,4 +84,4 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
</Stack>
);
const formatBandwidth = (value?: number) => humanFileSize(Math.round(value ?? 0), "/s");
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
+23 -8
View File
@@ -3,13 +3,13 @@
import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise } from "@tabler/icons-react";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -110,7 +110,7 @@ const createColumns = (
return (
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
{formatBytes(bytesUsage)}
</Text>
);
},
@@ -184,7 +184,11 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const t = useScopedI18n("docker");
const isTiny = width <= 256;
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
const {
data,
refetch,
isFetching,
} = clientApi.docker.getContainers.useQuery(undefined, {
refetchInterval: 30_000,
});
const containers = data?.containers ?? [];
@@ -297,12 +301,23 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: humanFileSize(totals.memory) })}
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.updated", { when: relativeTime })}
</Text>
<Tooltip
label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<ActionIcon
size="sm"
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching}
onClick={() => void refetch()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<IconRefresh style={actionIconIconStyle} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
)}
+1
View File
@@ -25,6 +25,7 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
queryKey: [["docker", "getContainers"]],
createOptions() {
return optionsBuilder.from((factory) => ({
columns: factory.multiSelect({
+16 -19
View File
@@ -43,7 +43,7 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { humanFileSize, useIntegrationConnected } from "@homarr/common";
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
@@ -386,7 +386,7 @@ export default function DownloadClientsWidget({
sortUndefined: "last",
Cell: ({ cell }) => {
const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>();
return downSpeed ? <Text size="xs">{humanFileSize(downSpeed, "/s")}</Text> : null;
return downSpeed ? <Text size="xs">{formatByteRate(downSpeed)}</Text> : null;
},
},
{
@@ -466,7 +466,7 @@ export default function DownloadClientsWidget({
...columnsDefBase({ key: "received", showHeader: true }),
Cell: ({ cell }) => {
const received = cell.getValue<ExtendedDownloadClientItem["received"]>();
return <Text size="xs">{humanFileSize(received)}</Text>;
return <Text size="xs">{formatBytes(received)}</Text>;
},
},
{
@@ -474,14 +474,14 @@ export default function DownloadClientsWidget({
sortUndefined: "last",
Cell: ({ cell }) => {
const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>();
return sent && <Text size="xs">{humanFileSize(sent)}</Text>;
return sent && <Text size="xs">{formatBytes(sent)}</Text>;
},
},
{
...columnsDefBase({ key: "size", showHeader: true }),
Cell: ({ cell }) => {
const size = cell.getValue<ExtendedDownloadClientItem["size"]>();
return <Text size="xs">{humanFileSize(size)}</Text>;
return <Text size="xs">{formatBytes(size)}</Text>;
},
},
{
@@ -521,7 +521,7 @@ export default function DownloadClientsWidget({
sortUndefined: "last",
Cell: ({ cell }) => {
const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>();
return upSpeed && <Text size="xs">{humanFileSize(upSpeed, "/s")}</Text>;
return upSpeed && <Text size="xs">{formatByteRate(upSpeed)}</Text>;
},
},
],
@@ -669,21 +669,21 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr
{item.type !== "miscellaneous" && (
<NormalizedLine
itemKey="upSpeed"
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
values={item.upSpeed === undefined ? undefined : formatByteRate(item.upSpeed)}
/>
)}
<NormalizedLine
itemKey="downSpeed"
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
values={item.downSpeed === undefined ? undefined : formatByteRate(item.downSpeed)}
/>
{item.type !== "miscellaneous" && (
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : formatBytes(item.sent)} />
)}
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
<NormalizedLine itemKey="received" values={formatBytes(item.received)} />
<NormalizedLine itemKey="size" values={formatBytes(item.size)} />
<NormalizedLine
itemKey="progress"
values={new Intl.NumberFormat("en", {
@@ -744,10 +744,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
{ paused: [] as string[], active: [] as string[] },
);
const someInteract = clients.some(({ interact }) => interact);
const totalSpeed = humanFileSize(
clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0),
"/s",
);
const totalSpeed = formatByteRate(clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0));
const utils = clientApi.useUtils();
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
@@ -849,21 +846,21 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
{client.status.rates.up !== undefined ? (
<Group display="flex" justify="center" c="green" w="100%" gap={5}>
<Text flex={1} ta="right">
{`↑ ${humanFileSize(client.status.rates.up, "/s")}`}
{`↑ ${formatByteRate(client.status.rates.up)}`}
</Text>
<Text>{"-"}</Text>
<Text flex={1} ta="left">
{humanFileSize(client.status.totalUp ?? 0)}
{formatBytes(client.status.totalUp ?? 0)}
</Text>
</Group>
) : undefined}
<Group display="flex" justify="center" c="blue" w="100%" gap={5}>
<Text flex={1} ta="right">
{`↓ ${humanFileSize(client.status.rates.down, "/s")}`}
{`↓ ${formatByteRate(client.status.rates.down)}`}
</Text>
<Text>{"-"}</Text>
<Text flex={1} ta="left">
{humanFileSize(Math.floor(client.status.totalDown ?? 0))}
{formatBytes(Math.floor(client.status.totalDown ?? 0))}
</Text>
</Group>
</Stack>
@@ -16,7 +16,7 @@ import {
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { capitalize, humanFileSize } from "@homarr/common";
import { capitalize, formatBytes, formatBytesPair } from "@homarr/common";
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
@@ -93,16 +93,18 @@ const RightSection = ({ label, value }: RightSectionProps) => {
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const memory = formatBytesPair(item.memory.used, item.memory.total);
const storage = formatBytesPair(item.storage.used, item.storage.total);
return (
<List>
<List.Item icon={<IconCpu size={16} />}>
{t("cpu")} - {item.cpu.cores}
</List.Item>
<List.Item icon={<IconBrain size={16} />}>
{t("memory")} - {humanFileSize(item.memory.used)} / {humanFileSize(item.memory.total)}
{t("memory")} - {memory.used} / {memory.available}
</List.Item>
<List.Item icon={<IconDatabase size={16} />}>
{t("storage")} - {humanFileSize(item.storage.used)} / {humanFileSize(item.storage.total)}
{t("storage")} - {storage.used} / {storage.available}
</List.Item>
<List.Item icon={<IconClockHour3 size={16} />}>
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
@@ -121,6 +123,7 @@ const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
const storage = formatBytesPair(item.used, item.total);
return (
<Stack gap={0}>
<Center>
@@ -133,7 +136,7 @@ const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
/>
<Group align="center" gap={0}>
<Text>
{t("storage")} - {humanFileSize(item.used)} / {humanFileSize(item.total)}
{t("storage")} - {storage.used} / {storage.available}
</Text>
</Group>
</Center>
@@ -152,11 +155,11 @@ const DiskStats = ({ item }: { item: ComputeResource }) => {
<List.Item icon={<IconDatabase size={16} />}>
<Group gap="sm">
<Group gap={0}>
<Text>{humanFileSize(item.storage.write)}</Text>
<Text>{formatBytes(item.storage.write)}</Text>
<IconArrowNarrowDown size={14} />
</Group>
<Group gap={0}>
<Text>{humanFileSize(item.storage.read)}</Text>
<Text>{formatBytes(item.storage.read)}</Text>
<IconArrowNarrowUp size={14} />
</Group>
</Group>
@@ -172,11 +175,11 @@ const NetStats = ({ item }: { item: ComputeResource }) => {
<List.Item icon={<IconNetwork size={16} />}>
<Group gap="sm">
<Group gap={0}>
<Text>{humanFileSize(item.network.in)}</Text>
<Text>{formatBytes(item.network.in)}</Text>
<IconArrowNarrowDown size={14} />
</Group>
<Group gap={0}>
<Text>{humanFileSize(item.network.out)}</Text>
<Text>{formatBytes(item.network.out)}</Text>
<IconArrowNarrowUp size={14} />
</Group>
</Group>
@@ -32,7 +32,7 @@ import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
@@ -277,7 +277,7 @@ export const progressColor = (percentage: number) => {
// them (e.g. Unraid, dashdot). Format the former and pass the latter through untouched.
const formatFileSize = (value: string) => {
const bytes = Number(value);
return Number.isFinite(bytes) ? humanFileSize(Math.round(bytes)) : value;
return Number.isFinite(bytes) ? formatBytes(Math.round(bytes)) : value;
};
interface FileSystem {
@@ -5,7 +5,7 @@ import { Group, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
@@ -46,7 +46,7 @@ export default function ImmichServerStatsWidget({
<StatItem
icon={<IconDatabase size={20} />}
label={t("widget.immich-serverStats.storage")}
value={humanFileSize(stats.totalLibraryUsageInBytes)}
value={formatBytes(stats.totalLibraryUsageInBytes)}
/>
)}
</Stack>
@@ -38,6 +38,11 @@ export default function MediaServerWidget({
}: WidgetComponentProps<"mediaRequests-requestList">) {
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
? options.statusFilter
: ["pending", "approved", "declined", "failed", "completed"],
recentDays: options.recentDays,
});
if (!mediaRequests) return <WidgetEmptyState />;
@@ -1,4 +1,5 @@
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
@@ -6,6 +7,8 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion,
createOptions() {
@@ -13,6 +16,17 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
linksTargetNewTab: factory.switch({
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 }) => [
@@ -14,7 +14,7 @@ import {
} from "@mantine/core";
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { TdarrQueue } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
@@ -72,7 +72,7 @@ export function QueuePanel(props: QueuePanelProps) {
</Group>
</TableTd>
<TableTd py={2}>
<Text size="xs">{humanFileSize(item.fileSize)}</Text>
<Text size="xs">{formatBytes(item.fileSize)}</Text>
</TableTd>
</TableTr>
))}
@@ -3,7 +3,7 @@ import { Card, Center, Group, RingProgress, ScrollArea, Stack, Text, Title, Tool
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
@@ -41,7 +41,7 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
<StatisticItem
icon={IconDatabaseHeart}
label={t("savedSpace")}
value={humanFileSize(Math.floor(allLibs.totalSavedSpace))}
value={formatBytes(Math.floor(allLibs.totalSavedSpace))}
/>
</Group>
<Group justify="center" wrap="wrap" grow>
@@ -39,6 +39,8 @@ import {
IconRowRemove,
IconTableOff,
IconTablePlus,
IconTextDirectionLtr,
IconTextDirectionRtl,
IconX,
} from "@tabler/icons-react";
import { Color } from "@tiptap/extension-color";
@@ -66,6 +68,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
import "@mantine/tiptap/styles.css";
import "./notebook.css";
@@ -171,6 +174,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
}),
TaskList.configure({ itemTypeName: "taskItem" }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
NotebookTextDirection,
TextStyle,
],
shouldRerenderOnTransaction: true,
@@ -347,6 +351,22 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
position: t("widget.notebook.align.right"),
})}
/>
<RichTextEditor.Control
title={tControls("directionLtr")}
aria-label={tControls("directionLtr")}
active={editor?.isActive({ dir: "ltr" })}
onClick={() => editor && setTextDirection(editor, "ltr").run()}
>
<IconTextDirectionLtr {...controlIconProps} />
</RichTextEditor.Control>
<RichTextEditor.Control
title={tControls("directionRtl")}
aria-label={tControls("directionRtl")}
active={editor?.isActive({ dir: "rtl" })}
onClick={() => editor && setTextDirection(editor, "rtl").run()}
>
<IconTextDirectionRtl {...controlIconProps} />
</RichTextEditor.Control>
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
@@ -0,0 +1,34 @@
import { Editor } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
describe("notebook text direction", () => {
it("persists right-to-left direction in the notebook HTML", () => {
const editor = new Editor({
content: "<p>יש לי 16GB</p>",
extensions: [StarterKit, NotebookTextDirection],
});
setTextDirection(editor, "rtl").run();
expect(editor.getHTML()).toBe('<p dir="rtl">יש לי 16GB</p>');
editor.destroy();
});
it("reads and changes an existing direction attribute", () => {
const editor = new Editor({
content: '<h2 dir="rtl">Heading</h2>',
extensions: [StarterKit, NotebookTextDirection],
});
expect(editor.getHTML()).toBe('<h2 dir="rtl">Heading</h2>');
editor.commands.setTextSelection(1);
setTextDirection(editor, "ltr").run();
expect(editor.getHTML()).toContain('<h2 dir="ltr">Heading</h2>');
editor.destroy();
});
});
@@ -0,0 +1,28 @@
import { Extension } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
export type TextDirection = "ltr" | "rtl";
export const NotebookTextDirection = Extension.create({
name: "notebookTextDirection",
addGlobalAttributes() {
return [
{
types: ["heading", "paragraph"],
attributes: {
dir: {
default: null,
parseHTML: (element) => element.getAttribute("dir"),
renderHTML: ({ dir }: { dir?: TextDirection }) => (dir ? { dir } : {}),
},
},
},
];
},
});
export function setTextDirection(editor: Editor, direction: TextDirection) {
const nodeType = editor.isActive("heading") ? "heading" : "paragraph";
return editor.chain().focus().updateAttributes(nodeType, { dir: direction });
}
@@ -5,7 +5,7 @@ import { Box, Card, Group, Stack, Tooltip, useMantineColorScheme } from "@mantin
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import { formatBytesPair } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
@@ -20,11 +20,13 @@ const getDisplayText = (item: { used: string; available: string; percentage: num
case "percentage":
return `${Math.round(item.percentage)}%`;
case "absolute": {
const usedInBytes = Number(item.used);
const availableInBytes = Number(item.available);
const availableText = Number.isFinite(availableInBytes)
? humanFileSize(Math.round(availableInBytes))
: item.available;
return `${item.used} / ${availableText}`;
if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) {
const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
return `${used} / ${available}`;
}
return `${item.used} / ${item.available}`;
}
case "free":
return `${Math.round(100 - item.percentage)}% free`;
@@ -130,16 +132,8 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
throw new NoIntegrationDataError();
}
const fileSystem = filterStorageVolumes(
rawFileSystem,
options.visibleStorageVolumes,
lastItem.integrationId,
);
const smart = filterStorageVolumes(
lastItem.healthInfo.smart,
options.visibleStorageVolumes,
lastItem.integrationId,
);
const fileSystem = filterStorageVolumes(rawFileSystem, options.visibleStorageVolumes, lastItem.integrationId);
const smart = filterStorageVolumes(lastItem.healthInfo.smart, options.visibleStorageVolumes, lastItem.integrationId);
if (fileSystem.length === 0) return <WidgetEmptyState />;
@@ -1,7 +1,7 @@
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatByteRate } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
@@ -47,7 +47,7 @@ export const CombinedNetworkTrafficChart = ({
{payloadData.value === undefined ? (
<>N/A</>
) : (
<>{humanFileSize(Math.round(Number(payloadData.value)))}/s</>
formatByteRate(Math.round(Number(payloadData.value)))
)}
</Text>
</Group>
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core";
import { IconBrain } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatBytesPair } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
@@ -41,11 +41,11 @@ export const SystemResourceMemoryChart = ({
tooltipProps={{
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
const memory = formatBytesPair(Math.round(value), totalCapacityInBytes);
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{humanFileSize(Math.round(value))} / {humanFileSize(totalCapacityInBytes)} (
{Math.round((value / totalCapacityInBytes) * 100)}%)
{memory.used} / {memory.available} ({Math.round((value / totalCapacityInBytes) * 100)}%)
</Text>
</Paper>
);
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core";
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatByteRate } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
@@ -32,7 +32,7 @@ export const NetworkTrafficChart = ({
title={isUp ? t("up") : t("down")}
icon={isUp ? IconArrowUp : IconArrowDown}
yAxisProps={{ domain: [0, upperBound] }}
lastValue={`${humanFileSize(Math.round(max))}/s`}
lastValue={formatByteRate(Math.round(max))}
chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode}
tooltipProps={{
@@ -41,7 +41,7 @@ export const NetworkTrafficChart = ({
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{humanFileSize(Math.round(value))}/s
{formatByteRate(Math.round(value))}
</Text>
</Paper>
);
+454 -428
View File
File diff suppressed because it is too large. Load diff
+19 -19
View File
@@ -53,22 +53,22 @@ catalog:
"@testcontainers/mysql": ^12.0.4
"@testcontainers/postgresql": ^12.0.4
"@testcontainers/redis": ^12.0.4
"@tiptap/extension-color": 3.27.1
"@tiptap/extension-highlight": 3.27.1
"@tiptap/extension-image": 3.27.1
"@tiptap/extension-link": ^3.27.1
"@tiptap/extension-placeholder": ^3.27.1
"@tiptap/extension-table": 3.27.1
"@tiptap/extension-table-cell": 3.27.1
"@tiptap/extension-table-header": 3.27.1
"@tiptap/extension-table-row": 3.27.1
"@tiptap/extension-task-item": 3.27.1
"@tiptap/extension-task-list": 3.27.1
"@tiptap/extension-text-align": 3.27.1
"@tiptap/extension-text-style": 3.27.1
"@tiptap/extension-underline": 3.27.1
"@tiptap/react": ^3.27.1
"@tiptap/starter-kit": ^3.27.1
"@tiptap/extension-color": 3.27.3
"@tiptap/extension-highlight": 3.27.3
"@tiptap/extension-image": 3.27.3
"@tiptap/extension-link": ^3.27.3
"@tiptap/extension-placeholder": ^3.27.3
"@tiptap/extension-table": 3.27.3
"@tiptap/extension-table-cell": 3.27.3
"@tiptap/extension-table-header": 3.27.3
"@tiptap/extension-table-row": 3.27.3
"@tiptap/extension-task-item": 3.27.3
"@tiptap/extension-task-list": 3.27.3
"@tiptap/extension-text-align": 3.27.3
"@tiptap/extension-text-style": 3.27.3
"@tiptap/extension-underline": 3.27.3
"@tiptap/react": ^3.27.3
"@tiptap/starter-kit": ^3.27.3
"@trpc/client": ^11.18.0
"@trpc/next": ^11.18.0
"@trpc/react-query": ^11.18.0
@@ -93,7 +93,7 @@ catalog:
"@types/video.js": ^7.3.58
"@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14
posthog-js: ^1.396.4
posthog-js: ^1.398.0
posthog-node: ^5.39.2
"@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.9
@@ -151,7 +151,7 @@ catalog:
next-intl: 4.13.1
node-loader: ^2.1.0
octokit: ^5.0.5
oxfmt: ^0.57.0
oxfmt: ^0.58.0
oxlint: ^1.72.0
pg: ^8.22.0
postcss-preset-mantine: ^1.18.0
@@ -218,7 +218,7 @@ overrides:
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7"
"esbuild@<=0.24.2": ">=0.28.1"
"form-data@>=4.0.0 <4.0.4": ">=4.0.6"
"hono@<4.6.5": ">=4.12.27"
"hono@<4.6.5": ">=4.12.28"
"linkifyjs@<4.3.2": ">=4.3.3"
"nanoid@>=4.0.0 <5.0.9": ">=5.1.16"
"prismjs@<1.30.0": ">=1.30.0"