Merge branch 'release/v2' into feat/widget-integration-dx
This commit is contained in:
31 files changed
+336
-259
No files matched your search
@@ -133,20 +133,21 @@ On larger canvases, the complete board grows together like browser zoom.
|
||||
The complete canvas always fits the available width and never adds horizontal board scrolling.
|
||||
|
||||
- **Mobile** starts at `0px` and uses three columns on new boards.
|
||||
- **Base** is the widest layout and starts at `768px` on new boards.
|
||||
- **Base** is the main layout and starts at `768px` on new boards.
|
||||
|
||||
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed
|
||||
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between
|
||||
their breakpoints.
|
||||
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any
|
||||
unused breakpoint above Mobile.
|
||||
|
||||
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in
|
||||
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a
|
||||
free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails.
|
||||
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an
|
||||
available breakpoint, and you can then change the breakpoint, columns, and rails.
|
||||
|
||||
Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three
|
||||
columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
|
||||
layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain
|
||||
custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions.
|
||||
layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest
|
||||
becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them.
|
||||
Duplicate breakpoints are normalized without discarding saved positions.
|
||||
|
||||
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
|
||||
|
||||
@@ -182,7 +183,8 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
|
||||
#### Breakpoint
|
||||
|
||||
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
|
||||
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
|
||||
`0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the
|
||||
eligible layout with the highest breakpoint, regardless of its role.
|
||||
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
|
||||
fallback to select a layout.
|
||||
|
||||
@@ -271,6 +273,8 @@ Expanding it smoothly restores those bounds and moves following items out of the
|
||||
|
||||
### Background
|
||||
|
||||
When a background image is set, it always covers at least the full browser window height, even if the board's content is shorter (for example when a Container is collapsed).
|
||||
|
||||
#### Background image URL
|
||||
|
||||
Homarr can display a background image behind apps and widgets.
|
||||
|
||||
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
|
||||
import { mediaRequestListWidget } from ".";
|
||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
import { seerrIntegration } from "@site/docs/integrations/seerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
||||
|
||||
@@ -39,6 +40,9 @@ import mediaRequestList from "./img/list.png";
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: seerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -46,6 +50,7 @@ import mediaRequestList from "./img/list.png";
|
||||
|
||||
- Approve / decline a request by clicking on the thumbs up or down icon.
|
||||
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
|
||||
- When no requests exist yet, the widget offers the same media search action instead of reporting an integration error.
|
||||
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
|
||||
import { mediaRequestStatsWidget } from ".";
|
||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
import { seerrIntegration } from "@site/docs/integrations/seerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
|
||||
|
||||
@@ -31,6 +32,9 @@ Provides an overview of media requests, including totals, availability, approval
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: seerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -43,6 +47,7 @@ import mediaRequestStats from "./img/stats.png";
|
||||
### Interactions
|
||||
|
||||
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
|
||||
- When no requests exist yet, the widget offers media search and explains when statistics will appear.
|
||||
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
|
||||
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
|
||||
including the latest result, historical averages, and a live chart of the last 12 hours.
|
||||
|
||||
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
|
||||
The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
|
||||
|
||||
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ interface Props {
|
||||
saveSettingsAsync: () => Promise<FormValues | null>;
|
||||
}
|
||||
|
||||
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
|
||||
|
||||
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tLayout = useI18n("layout");
|
||||
@@ -101,6 +103,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
|
||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
||||
const displayedLayouts = form.values.layouts
|
||||
.map((layout, index) => ({ layout, index }))
|
||||
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||
@@ -134,13 +139,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => {
|
||||
{displayedLayouts.map(({ layout, index }) => {
|
||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||
const customBreakpoints = form.values.layouts
|
||||
.filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id)
|
||||
.map((candidate) => candidate.breakpoint);
|
||||
const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768;
|
||||
|
||||
return (
|
||||
<Fieldset
|
||||
@@ -190,12 +191,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
}
|
||||
disabled={layout.role === "mobile"}
|
||||
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
||||
min={
|
||||
layout.role === "base"
|
||||
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
|
||||
: 1
|
||||
}
|
||||
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
|
||||
min={layout.role === "mobile" ? 0 : 1}
|
||||
max={32767}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("item actions create-item", () => {
|
||||
.build();
|
||||
const baseLayout = board.layouts.find((layout) => layout.role === "base");
|
||||
|
||||
const result = createItemCallback({ kind: "mediaMissing" })(board);
|
||||
const result = createItemCallback({ kind: "downloads" })(board);
|
||||
|
||||
const createdItem = result.items.at(0);
|
||||
expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3);
|
||||
@@ -165,7 +165,7 @@ describe("item actions create-item", () => {
|
||||
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
|
||||
|
||||
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
|
||||
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }),
|
||||
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -262,7 +262,7 @@ describe("item actions create-item", () => {
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
width: 1,
|
||||
height: 3,
|
||||
height: 2,
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.settingsButton {
|
||||
position: absolute;
|
||||
top: -34px;
|
||||
right: 14px;
|
||||
top: calc(var(--mantine-spacing-sm) / 2);
|
||||
right: calc(var(--mantine-spacing-sm) / 2);
|
||||
z-index: 201;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -52,7 +52,6 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settingsButton {
|
||||
right: 4px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -49,12 +49,14 @@
|
||||
}
|
||||
|
||||
.widgetIcon {
|
||||
width: var(--mantine-font-size-sm);
|
||||
height: var(--mantine-font-size-sm);
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 11px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
@@ -94,7 +96,7 @@
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--chip-background);
|
||||
font-size: 10px;
|
||||
font-size: var(--mantine-font-size-xxs);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: clip;
|
||||
overflow-x: clip;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
.board-grid-entry:has([data-board-widget-header]):hover,
|
||||
.board-grid-entry:has([data-board-widget-header]):focus-within {
|
||||
z-index: 10;
|
||||
z-index: calc(var(--homarr-z-index-board-header) - 1);
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-preview="true"] {
|
||||
|
||||
@@ -27,6 +27,12 @@ export const ClientShell = ({
|
||||
return (
|
||||
<AppShell
|
||||
{...backgroundProps}
|
||||
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
|
||||
// <main> doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
|
||||
// background image only covers as much height as the content needs, so collapsing a
|
||||
// container short enough leaves flat page background showing below it instead of the
|
||||
// background continuing to the bottom of the viewport.
|
||||
mih={backgroundProps.bg ? "100dvh" : undefined}
|
||||
header={hasHeader ? { height: headerHeight } : undefined}
|
||||
navbar={
|
||||
hasNavigation
|
||||
|
||||
@@ -2814,14 +2814,11 @@ export const boardLayoutsNeedRepair = (
|
||||
) => {
|
||||
const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
|
||||
const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
|
||||
const baseLayout = baseLayouts.at(0);
|
||||
|
||||
return (
|
||||
mobileLayouts.length !== 1 ||
|
||||
baseLayouts.length !== 1 ||
|
||||
mobileLayouts.at(0)?.breakpoint !== 0 ||
|
||||
!baseLayout ||
|
||||
boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) ||
|
||||
new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
|
||||
);
|
||||
};
|
||||
|
||||
@@ -280,7 +280,7 @@ describe("onboard.completeSetup", () => {
|
||||
expect(mediaMissing).toBeDefined();
|
||||
expect(
|
||||
await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }),
|
||||
).toMatchObject({ width: 4, height: 3 });
|
||||
).toMatchObject({ width: 2, height: 2 });
|
||||
});
|
||||
|
||||
it("requires an exact board id when more than one board exists", async () => {
|
||||
|
||||
@@ -143,8 +143,8 @@ export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: str
|
||||
}
|
||||
}
|
||||
|
||||
const mobileLayout = normalizedLayouts[0];
|
||||
const baseLayout = normalizedLayouts.at(-1);
|
||||
const mobileLayout = normalizedLayouts.find((layout) => layout.role === "mobile");
|
||||
const baseLayout = normalizedLayouts.find((layout) => layout.role === "base");
|
||||
if (mobileLayout && baseLayout) {
|
||||
await insertMissingProjectedPositionsAsync(
|
||||
db,
|
||||
|
||||
@@ -33,14 +33,14 @@ export const normalizeBoardLayoutRoles = <
|
||||
const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
|
||||
const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
|
||||
const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
|
||||
const orderedLayouts =
|
||||
mobileLayout && baseLayout
|
||||
? [
|
||||
mobileLayout,
|
||||
...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts),
|
||||
baseLayout,
|
||||
]
|
||||
: sourceLayouts.toSorted(compareBoardLayouts);
|
||||
const hasProtectedLayouts = mobileLayout !== undefined && baseLayout !== undefined;
|
||||
let orderedLayouts = sourceLayouts.toSorted(compareBoardLayouts);
|
||||
if (hasProtectedLayouts) {
|
||||
orderedLayouts = [
|
||||
mobileLayout,
|
||||
...sourceLayouts.filter((layout) => layout.id !== mobileLayout.id).toSorted(compareBoardLayouts),
|
||||
];
|
||||
}
|
||||
|
||||
let previousBreakpoint = -1;
|
||||
return orderedLayouts.map((layout, index) => {
|
||||
@@ -49,7 +49,12 @@ export const normalizeBoardLayoutRoles = <
|
||||
index === 0
|
||||
? 0
|
||||
: Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
|
||||
const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom";
|
||||
let role: LayoutRole = layout.role ?? "custom";
|
||||
if (!hasProtectedLayouts) {
|
||||
if (index === 0) role = "mobile";
|
||||
else if (index === orderedLayouts.length - 1) role = "base";
|
||||
else role = "custom";
|
||||
}
|
||||
previousBreakpoint = breakpoint;
|
||||
return { ...layout, breakpoint, role };
|
||||
});
|
||||
|
||||
@@ -66,14 +66,20 @@ export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; hei
|
||||
clock: { width: 2, height: 1 },
|
||||
weather: { width: 2, height: 1 },
|
||||
airQuality: { width: 2, height: 1 },
|
||||
calendar: { width: 2, height: 2 },
|
||||
countdown: { width: 2, height: 1 },
|
||||
downloads: { width: 4, height: 2 },
|
||||
indexerManager: { width: 2, height: 3 },
|
||||
timer: { width: 2, height: 1 },
|
||||
bookmarks: { width: 2, height: 2 },
|
||||
uptimeKuma: { width: 2, height: 3 },
|
||||
audioStats: { width: 2, height: 2 },
|
||||
paperlessNgx: { width: 2, height: 2 },
|
||||
patchmon: { width: 2, height: 2 },
|
||||
mediaMissing: { width: 4, height: 3 },
|
||||
mediaServer: { width: 3, height: 2 },
|
||||
"mediaRequests-requestList": { width: 3, height: 2 },
|
||||
"mediaRequests-requestStats": { width: 2, height: 2 },
|
||||
mediaMissing: { width: 2, height: 2 },
|
||||
bazarr: { width: 2, height: 2 },
|
||||
assistant: { width: 10, height: 4 },
|
||||
};
|
||||
@@ -4227,7 +4227,11 @@
|
||||
},
|
||||
"mediaRequests-requestList": {
|
||||
"name": "Media Requests List",
|
||||
"description": "See a list of all media requests from your Overseerr or Jellyseerr instance",
|
||||
"description": "See a list of all media requests from your Seerr, Overseerr, or Jellyseerr instance",
|
||||
"empty": {
|
||||
"title": "No media requests yet",
|
||||
"description": "Search for a movie or show to start a request."
|
||||
},
|
||||
"option": {
|
||||
"linksTargetNewTab": {
|
||||
"label": "Open links in new tab"
|
||||
@@ -4267,6 +4271,10 @@
|
||||
"mediaRequests-requestStats": {
|
||||
"name": "Media Requests Stats",
|
||||
"description": "Statistics about your media requests",
|
||||
"empty": {
|
||||
"title": "No request activity yet",
|
||||
"description": "Stats will appear after the first media request."
|
||||
},
|
||||
"titles": {
|
||||
"stats": {
|
||||
"main": "Media Stats",
|
||||
@@ -4672,12 +4680,6 @@
|
||||
"noSectionsEnabled": "Enable at least one section in widget options.",
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch speedtest data"
|
||||
},
|
||||
"range": {
|
||||
"twelveHours": "12h",
|
||||
"oneDay": "24h",
|
||||
"all": "All",
|
||||
"lastThirty": "Last 30"
|
||||
}
|
||||
},
|
||||
"uptimeKuma": {
|
||||
@@ -5424,7 +5426,7 @@
|
||||
"title": "Layout",
|
||||
"responsive": {
|
||||
"title": "Responsive layouts",
|
||||
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.",
|
||||
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts at any additional breakpoint.",
|
||||
"action": {
|
||||
"add": "Add layout"
|
||||
}
|
||||
|
||||
@@ -116,10 +116,6 @@ export const responsiveBoardLayoutsSchema = z
|
||||
ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" });
|
||||
}
|
||||
|
||||
if (layouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) {
|
||||
ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" });
|
||||
}
|
||||
|
||||
if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) {
|
||||
ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
.beszelStatsControls {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: var(--mantine-spacing-sm);
|
||||
right: var(--mantine-spacing-sm);
|
||||
left: var(--mantine-spacing-sm);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.beszelStatsContainer:hover .beszelStatsControls,
|
||||
.beszelStatsContainer:focus-within .beszelStatsControls {
|
||||
.beszelStatsRoot:hover .beszelStatsControls,
|
||||
.beszelStatsRoot:focus-within .beszelStatsControls {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -23,11 +23,16 @@ import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||
|
||||
const STACK_PADDING = 24;
|
||||
const SCROLL_FIT_BUFFER = 2;
|
||||
|
||||
export default function BeszelSystemStatsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayScale = 1,
|
||||
boardId,
|
||||
itemId,
|
||||
setOptions,
|
||||
@@ -66,6 +71,12 @@ export default function BeszelSystemStatsWidget({
|
||||
{ value: "1w", label: t("period.oneWeek") },
|
||||
{ value: "30d", label: t("period.thirtyDays") },
|
||||
];
|
||||
let responsiveWidth = width;
|
||||
let responsiveHeight = height;
|
||||
if (Number.isFinite(displayScale) && displayScale > 0) {
|
||||
responsiveWidth *= displayScale;
|
||||
responsiveHeight *= displayScale;
|
||||
}
|
||||
useWidgetRuntimeQueries(
|
||||
widgetRuntimeRef,
|
||||
selectedSystem && options.timePeriod !== "1m"
|
||||
@@ -180,82 +191,83 @@ export default function BeszelSystemStatsWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box h="100%" pos="relative" className={classes.beszelStatsRoot}>
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
</Group>
|
||||
</Box>
|
||||
{!isEditMode && (
|
||||
<Group gap="xs" wrap="nowrap" className={classes.beszelStatsControls}>
|
||||
{systems.length > 1 && (
|
||||
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-xs"
|
||||
leftSection={<IconServer style={iconSizes.sm} />}
|
||||
className={classes.beszelStatsSystemToggle}
|
||||
disabled={isSelectionSavePending}
|
||||
>
|
||||
{selectedLabel}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{systems.map((system) => (
|
||||
<Menu.Item
|
||||
key={system.value}
|
||||
fz="xs"
|
||||
fw={system.value === selectedValue ? 600 : 400}
|
||||
c={system.value !== selectedValue ? "dimmed" : undefined}
|
||||
disabled={isSelectionSavePending}
|
||||
onClick={() => handleSelectSystem(system.value)}
|
||||
>
|
||||
{system.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
{responsiveWidth >= 560 ? (
|
||||
<Button.Group className={classes.beszelStatsPeriodToggle}>
|
||||
{periodOptions.map((period) => (
|
||||
<Button
|
||||
key={period.value}
|
||||
size="compact-xs"
|
||||
variant={period.value === options.timePeriod ? "filled" : "default"}
|
||||
className={classes.beszelStatsPeriodButton}
|
||||
aria-pressed={period.value === options.timePeriod}
|
||||
disabled={isSelectionSavePending}
|
||||
onClick={() => handleTimePeriod(period.value)}
|
||||
>
|
||||
{period.label}
|
||||
</Button>
|
||||
))}
|
||||
</Button.Group>
|
||||
) : (
|
||||
<Select
|
||||
size="xs"
|
||||
value={options.timePeriod}
|
||||
onChange={(value) => value && handleTimePeriod(value)}
|
||||
disabled={isSelectionSavePending}
|
||||
data={periodOptions}
|
||||
className={classes.beszelStatsPeriodToggle}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
className={classes.beszelStatsContainer}
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap="md" p="sm">
|
||||
{!isEditMode && (
|
||||
<Group gap="xs" wrap="nowrap" className={classes.beszelStatsControls}>
|
||||
{systems.length > 1 && (
|
||||
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-xs"
|
||||
leftSection={<IconServer style={iconSizes.sm} />}
|
||||
className={classes.beszelStatsSystemToggle}
|
||||
disabled={isSelectionSavePending}
|
||||
>
|
||||
{selectedLabel}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{systems.map((system) => (
|
||||
<Menu.Item
|
||||
key={system.value}
|
||||
fz="xs"
|
||||
fw={system.value === selectedValue ? 600 : 400}
|
||||
c={system.value !== selectedValue ? "dimmed" : undefined}
|
||||
disabled={isSelectionSavePending}
|
||||
onClick={() => handleSelectSystem(system.value)}
|
||||
>
|
||||
{system.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
{width >= 560 ? (
|
||||
<Button.Group className={classes.beszelStatsPeriodToggle}>
|
||||
{periodOptions.map((period) => (
|
||||
<Button
|
||||
key={period.value}
|
||||
size="compact-xs"
|
||||
variant={period.value === options.timePeriod ? "filled" : "default"}
|
||||
className={classes.beszelStatsPeriodButton}
|
||||
aria-pressed={period.value === options.timePeriod}
|
||||
disabled={isSelectionSavePending}
|
||||
onClick={() => handleTimePeriod(period.value)}
|
||||
>
|
||||
{period.label}
|
||||
</Button>
|
||||
))}
|
||||
</Button.Group>
|
||||
) : (
|
||||
<Select
|
||||
size="xs"
|
||||
value={options.timePeriod}
|
||||
onChange={(value) => value && handleTimePeriod(value)}
|
||||
disabled={isSelectionSavePending}
|
||||
data={periodOptions}
|
||||
className={classes.beszelStatsPeriodToggle}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Stack p="sm">
|
||||
<BeszelStatsView
|
||||
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
|
||||
systemId={selectedSystem?.systemId ?? ""}
|
||||
timePeriod={options.timePeriod as BeszelTimePeriod}
|
||||
columns={width > 600 ? 2 : 1}
|
||||
columns={responsiveWidth > 600 ? 2 : 1}
|
||||
availableHeight={Math.max(0, responsiveHeight - STACK_PADDING - SCROLL_FIT_BUFFER)}
|
||||
visibility={{
|
||||
cpu: options.showCpu,
|
||||
memory: options.showMemory,
|
||||
|
||||
@@ -101,12 +101,12 @@ interface BeszelChartPanelProps {
|
||||
|
||||
export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelChartPanelProps) => (
|
||||
<Stack gap={4} style={panelStyle}>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={600} style={{ flexShrink: 0 }}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text size="xs" c="dimmed" truncate style={{ minWidth: 0 }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -35,13 +35,13 @@ const tooltips = {
|
||||
bytesTotal: makeTooltipProps(formatStorageBytes, true),
|
||||
};
|
||||
|
||||
const ChartSkeleton = () => (
|
||||
const ChartSkeleton = ({ height }: { height: number }) => (
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Skeleton h={14} w={72} radius="sm" />
|
||||
<Skeleton h={10} w={110} radius="sm" />
|
||||
</Group>
|
||||
<Skeleton h={CHART_HEIGHT} radius="sm" />
|
||||
<Skeleton h={height} radius="sm" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -62,9 +62,20 @@ interface BeszelStatsViewProps {
|
||||
timePeriod: BeszelTimePeriod;
|
||||
visibility: BeszelStatsVisibility;
|
||||
columns: 1 | 2;
|
||||
availableHeight?: number;
|
||||
onSwitchToHistorical?: () => void;
|
||||
}
|
||||
|
||||
const MIN_CHART_HEIGHT = 100;
|
||||
const PANEL_ROW_OVERHEAD = 25;
|
||||
const GRID_ROW_GAP = 16;
|
||||
|
||||
const computeChartHeight = (availableHeight: number | undefined, rowCount: number) => {
|
||||
if (availableHeight === undefined || rowCount <= 0) return CHART_HEIGHT;
|
||||
const perRow = (availableHeight - (rowCount - 1) * GRID_ROW_GAP) / rowCount - PANEL_ROW_OVERHEAD;
|
||||
return Math.max(MIN_CHART_HEIGHT, Math.min(CHART_HEIGHT, Math.floor(perRow)));
|
||||
};
|
||||
|
||||
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
|
||||
|
||||
export function BeszelStatsView({
|
||||
@@ -73,6 +84,7 @@ export function BeszelStatsView({
|
||||
timePeriod,
|
||||
visibility,
|
||||
columns,
|
||||
availableHeight,
|
||||
onSwitchToHistorical,
|
||||
}: BeszelStatsViewProps) {
|
||||
const t = useI18n("widget.beszelSystemStats");
|
||||
@@ -226,10 +238,13 @@ export function BeszelStatsView({
|
||||
|
||||
if (!activeStats) {
|
||||
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
|
||||
const effectiveColumns = Math.min(columns, Math.max(1, visibleChartCount)) as 1 | 2;
|
||||
const rowCount = Math.ceil(visibleChartCount / effectiveColumns);
|
||||
const chartHeight = computeChartHeight(availableHeight, rowCount);
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="md" aria-label={t("name")}>
|
||||
<SimpleGrid cols={effectiveColumns} spacing="md" aria-label={t("name")}>
|
||||
{Array.from({ length: visibleChartCount }, (_, index) => (
|
||||
<ChartSkeleton key={index} />
|
||||
<ChartSkeleton key={index} height={chartHeight} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
@@ -248,14 +263,29 @@ export function BeszelStatsView({
|
||||
);
|
||||
}
|
||||
|
||||
const renderedPanelCount =
|
||||
(visibility.cpu && cpuData.length > 0 ? 1 : 0) +
|
||||
(visibility.memory && memoryData.length > 0 ? 1 : 0) +
|
||||
(visibility.disk && diskData.length > 0 ? 1 : 0) +
|
||||
(visibility.diskIO && diskIOData.length > 0 ? 1 : 0) +
|
||||
(visibility.network && networkData.length > 0 ? 1 : 0) +
|
||||
(showDocker && containerSeries.length > 0
|
||||
? (visibility.dockerCpu && dockerCpuData.length > 0 ? 1 : 0) +
|
||||
(visibility.dockerMemory && dockerMemoryData.length > 0 ? 1 : 0) +
|
||||
(visibility.dockerNetwork && dockerNetworkData.length > 0 ? 1 : 0)
|
||||
: 0);
|
||||
const effectiveColumns = Math.min(columns, Math.max(1, renderedPanelCount)) as 1 | 2;
|
||||
const rowCount = Math.ceil(renderedPanelCount / effectiveColumns);
|
||||
const chartHeight = computeChartHeight(availableHeight, rowCount);
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="md">
|
||||
<SimpleGrid cols={effectiveColumns} spacing="md">
|
||||
{visibility.cpu && cpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.cpu.title")}
|
||||
subtitle={t("chart.cpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
h: chartHeight,
|
||||
data: cpuData,
|
||||
series: series.cpu,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
@@ -269,7 +299,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.memory.title")}
|
||||
subtitle={t("chart.memory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
h: chartHeight,
|
||||
data: memoryData,
|
||||
type: "stacked",
|
||||
series: series.memory,
|
||||
@@ -283,7 +313,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.disk.title")}
|
||||
subtitle={t("chart.disk.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
h: chartHeight,
|
||||
data: diskData,
|
||||
type: "stacked",
|
||||
series: diskSeries,
|
||||
@@ -297,7 +327,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.diskIO.title")}
|
||||
subtitle={t("chart.diskIO.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
h: chartHeight,
|
||||
data: diskIOData,
|
||||
series: series.diskIO,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
@@ -310,7 +340,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.network.title")}
|
||||
subtitle={t("chart.network.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
h: chartHeight,
|
||||
data: networkData,
|
||||
series: series.network,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
@@ -325,7 +355,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.dockerCpu.title")}
|
||||
subtitle={t("chart.dockerCpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
h: chartHeight,
|
||||
data: dockerCpuData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
@@ -339,7 +369,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.dockerMemory.title")}
|
||||
subtitle={t("chart.dockerMemory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
h: chartHeight,
|
||||
data: dockerMemoryData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
@@ -353,7 +383,7 @@ export function BeszelStatsView({
|
||||
title={t("chart.dockerNetwork.title")}
|
||||
subtitle={t("chart.dockerNetwork.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
h: chartHeight,
|
||||
data: dockerNetworkData,
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -13,15 +14,25 @@ interface CalendarDayProps {
|
||||
rootHeight: number;
|
||||
}
|
||||
|
||||
const fallbackDayScale = { textSize: "xs" as const, indicatorSize: 3, indicatorMarginTop: 2 };
|
||||
const dayScaleTiers = [
|
||||
{ minAxisSize: 700, textSize: "lg" as const, indicatorSize: 6, indicatorMarginTop: 6 },
|
||||
{ minAxisSize: 450, textSize: "md" as const, indicatorSize: 5, indicatorMarginTop: 4 },
|
||||
{ minAxisSize: 250, textSize: "sm" as const, indicatorSize: 4, indicatorMarginTop: 3 },
|
||||
{ minAxisSize: 0, ...fallbackDayScale },
|
||||
];
|
||||
|
||||
const getDayScale = (minAxisSize: number) =>
|
||||
dayScaleTiers.find((tier) => minAxisSize >= tier.minAxisSize) ?? fallbackDayScale;
|
||||
|
||||
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const mantineTheme = useMantineTheme();
|
||||
const actualItemRadius = mantineTheme.radius[board.itemRadius];
|
||||
|
||||
const minAxisSize = Math.min(rootWidth, rootHeight);
|
||||
const shouldScaleDown = minAxisSize < 350;
|
||||
const shouldShowIndicators = rootHeight >= 256;
|
||||
const indicatorSize = shouldScaleDown ? 3 : 4;
|
||||
const { textSize, indicatorSize, indicatorMarginTop } = useMemo(() => getDayScale(minAxisSize), [minAxisSize]);
|
||||
|
||||
const cell = (
|
||||
<Container
|
||||
@@ -39,10 +50,15 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
|
||||
<Text ta={"center"} size={textSize} lh={1}>
|
||||
{date.getDate()}
|
||||
</Text>
|
||||
<NotificationIndicator events={events} size={indicatorSize} visible={shouldShowIndicators} />
|
||||
<NotificationIndicator
|
||||
events={events}
|
||||
size={indicatorSize}
|
||||
marginTop={indicatorMarginTop}
|
||||
visible={shouldShowIndicators}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -70,10 +86,11 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
interface NotificationIndicatorProps {
|
||||
events: CalendarEvent[];
|
||||
size: number;
|
||||
marginTop: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorProps) => {
|
||||
const NotificationIndicator = ({ events, size, marginTop, visible }: NotificationIndicatorProps) => {
|
||||
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(
|
||||
(color): color is string => Boolean(color),
|
||||
);
|
||||
@@ -82,7 +99,7 @@ const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorP
|
||||
|
||||
return (
|
||||
<Flex
|
||||
mt={3}
|
||||
mt={marginTop}
|
||||
w="fit-content"
|
||||
maw="75%"
|
||||
h={size}
|
||||
|
||||
@@ -212,6 +212,7 @@ const CalendarBase = ({
|
||||
monthCell: {
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
padding: 3,
|
||||
},
|
||||
day: {
|
||||
borderRadius: actualItemRadius,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconMovieOff, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface MediaRequestsEmptyStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
integrationIds: string[];
|
||||
isEditMode: boolean;
|
||||
}
|
||||
|
||||
export const MediaRequestsEmptyState = ({
|
||||
title,
|
||||
description,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
}: MediaRequestsEmptyStateProps) => {
|
||||
const tSearch = useI18n("search.mode.media");
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap="xs" p="md">
|
||||
<ThemeIcon variant="light" size="xl" radius="xl">
|
||||
<IconMovieOff />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} align="center">
|
||||
<Text fw={600} ta="center">
|
||||
{title}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" ta="center" maw="28ch">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!isEditMode ? (
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<IconSearch size="var(--mantine-font-size-md)" />}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
{tSearch("action.search.label")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||
import actionTargetClasses from "../../common/action-target.module.css";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import { MediaRequestsEmptyState } from "../empty-state";
|
||||
import classes from "./component.module.css";
|
||||
import searchClasses from "../search-button.module.css";
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function MediaServerWidget({
|
||||
options,
|
||||
width,
|
||||
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||
const t = useI18n("widget.mediaRequests-requestList");
|
||||
const interactIntegrationIds = new Set(
|
||||
useIntegrationsWithInteractAccess()
|
||||
.filter(({ id }) => integrationIds.includes(id))
|
||||
@@ -48,7 +49,16 @@ export default function MediaServerWidget({
|
||||
if (isInitialWidgetQueryPending(mediaRequestQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!mediaRequestData) return <WidgetEmptyState />;
|
||||
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
|
||||
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
||||
if (mediaRequests.length === 0 && failedIntegrations.length === 0) {
|
||||
return (
|
||||
<MediaRequestsEmptyState
|
||||
title={t("empty.title")}
|
||||
description={t("empty.description")}
|
||||
integrationIds={integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1;
|
||||
|
||||
return (
|
||||
|
||||
@@ -28,7 +28,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||
import actionTargetClasses from "../../common/action-target.module.css";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import { MediaRequestsEmptyState } from "../empty-state";
|
||||
import classes from "./component.module.css";
|
||||
import searchClasses from "../search-button.module.css";
|
||||
|
||||
@@ -54,8 +54,16 @@ export default function MediaServerWidget({
|
||||
requestStats.users.length === 0 &&
|
||||
requestStats.stats.length === 0 &&
|
||||
requestStats.failedIntegrations.length === 0
|
||||
)
|
||||
throw new NoIntegrationDataError();
|
||||
) {
|
||||
return (
|
||||
<MediaRequestsEmptyState
|
||||
title={t("empty.title")}
|
||||
description={t("empty.description")}
|
||||
integrationIds={integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const data = [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -21,14 +21,10 @@ export default function SpeedtestTrackerWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"speedtestTracker">) {
|
||||
const t = useI18n("widget.speedtestTracker");
|
||||
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
|
||||
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
|
||||
const [advancedRange, setAdvancedRange] = useState("24");
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]);
|
||||
|
||||
const combined = useMemo(
|
||||
@@ -36,98 +32,24 @@ export default function SpeedtestTrackerWidget({
|
||||
[successfulDashboards],
|
||||
);
|
||||
|
||||
const rangeHours = isAdvanced ? Number(advancedRange) : 12;
|
||||
const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0;
|
||||
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
|
||||
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff);
|
||||
|
||||
const showLatestResult = isAdvanced || options.showLatestResult;
|
||||
const showStats = isAdvanced || options.showStats;
|
||||
const showRecentResults = isAdvanced || options.showRecentResults;
|
||||
const showPingGraph = isAdvanced || options.showPingGraph;
|
||||
const showLatestResult = options.showLatestResult;
|
||||
const showStats = options.showStats;
|
||||
const showRecentResults = options.showRecentResults;
|
||||
const showPingGraph = options.showPingGraph;
|
||||
const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null);
|
||||
const hasChart = showRecentResults && recentFiltered.length > 0;
|
||||
const noSectionsEnabled =
|
||||
!isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults;
|
||||
const advancedSectionWidth = width >= 720 ? width / 2 : width;
|
||||
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
|
||||
|
||||
const latest = showLatestResult && combined.latestResult && (
|
||||
<LatestResultSection
|
||||
result={combined.latestResult}
|
||||
width={isAdvanced ? advancedSectionWidth : width}
|
||||
compactSurface={!isAdvanced}
|
||||
/>
|
||||
<LatestResultSection result={combined.latestResult} width={width} compactSurface />
|
||||
);
|
||||
const averages = showStats && combined.stats && (
|
||||
<AveragesSection
|
||||
stats={combined.stats}
|
||||
width={isAdvanced ? advancedSectionWidth : width}
|
||||
compactSurface={!isAdvanced}
|
||||
/>
|
||||
<AveragesSection stats={combined.stats} width={width} compactSurface />
|
||||
);
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
|
||||
const sourcePanelWidth = sourceColumns === 2 ? Math.max(0, (width - 48) / 2) : Math.max(0, width - 32);
|
||||
const sourceSectionWidth = sourcePanelWidth >= 720 ? sourcePanelWidth / 2 : sourcePanelWidth;
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="md" p="md">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={advancedRange}
|
||||
onChange={setAdvancedRange}
|
||||
data={[
|
||||
{ value: "12", label: t("range.twelveHours") },
|
||||
{ value: "24", label: t("range.oneDay") },
|
||||
{ value: "0", label: t("range.lastThirty") },
|
||||
]}
|
||||
/>
|
||||
{successfulDashboards.length === 0 ? (
|
||||
<WidgetEmptyState />
|
||||
) : (
|
||||
<SimpleGrid cols={sourceColumns} spacing="md">
|
||||
{successfulDashboards.map(({ integrationId, integrationName, dashboard }) => {
|
||||
const sourceRecent = dashboard.recentResults.filter((result) => result.created_at.getTime() > cutoff);
|
||||
const sourceHasStats = dashboard.latestResult !== null || dashboard.stats !== null;
|
||||
const sourceHasChart = sourceRecent.length > 0;
|
||||
|
||||
return (
|
||||
<Card key={integrationId} data-integration-id={integrationId} p="sm" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{integrationName}
|
||||
</Text>
|
||||
{sourceHasStats && (
|
||||
<SimpleGrid cols={sourcePanelWidth >= 720 ? 2 : 1} spacing="md">
|
||||
{dashboard.latestResult && (
|
||||
<LatestResultSection result={dashboard.latestResult} width={sourceSectionWidth} />
|
||||
)}
|
||||
{dashboard.stats && <AveragesSection stats={dashboard.stats} width={sourceSectionWidth} />}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{sourceHasChart && (
|
||||
<Box h={Math.max(260, Math.min(420, height / 2))}>
|
||||
<RecentResultsSection results={sourceRecent} showPingGraph />
|
||||
</Box>
|
||||
)}
|
||||
{!sourceHasStats && !sourceHasChart && <WidgetEmptyState />}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
|
||||
icon: IconSpeedboat,
|
||||
supportsAdvancedFocus: true,
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: clamp(6px, 3cqi, 14px);
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
padding: clamp(6px, 3cqmin, 14px);
|
||||
gap: clamp(4px, 2cqmin, 10px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: clamp(8px, 3cqi, 18px);
|
||||
gap: clamp(8px, 3cqmin, 18px);
|
||||
flex-shrink: 0;
|
||||
padding-bottom: clamp(4px, 2cqi, 10px);
|
||||
border-bottom: 1px solid
|
||||
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
padding-bottom: clamp(4px, 2cqmin, 10px);
|
||||
border-bottom: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
}
|
||||
|
||||
.heroExpanded {
|
||||
@@ -37,25 +36,25 @@
|
||||
}
|
||||
|
||||
.heroTextOnly .heroValue {
|
||||
font-size: clamp(22px, 10cqi, 36px);
|
||||
font-size: clamp(22px, 10cqmin, 36px);
|
||||
}
|
||||
|
||||
.heroText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(2px, 1cqi, 4px);
|
||||
gap: clamp(2px, 1cqmin, 4px);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.heroLabel {
|
||||
font-size: clamp(9px, 2.8cqi, 13px);
|
||||
font-size: clamp(9px, 2.8cqmin, 13px);
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.heroValue {
|
||||
font-size: clamp(18px, 8cqi, 32px);
|
||||
font-size: clamp(18px, 8cqmin, 32px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
@@ -68,7 +67,7 @@
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
gap: clamp(4px, 2cqmin, 10px);
|
||||
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
align-content: stretch;
|
||||
@@ -81,11 +80,10 @@
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
gap: clamp(2px, 1.5cqi, 5px);
|
||||
padding: clamp(4px, 2.5cqi, 10px);
|
||||
gap: clamp(2px, 1.5cqmin, 5px);
|
||||
padding: clamp(4px, 2.5cqmin, 10px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: 1px solid
|
||||
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
border: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -95,14 +93,14 @@
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: clamp(14px, 5.5cqi, 24px);
|
||||
font-size: clamp(14px, 5.5cqmin, 24px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: clamp(8px, 2.5cqi, 12px);
|
||||
font-size: clamp(8px, 2.5cqmin, 12px);
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
@@ -119,6 +117,6 @@
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-size: clamp(11px, 3cqi, 14px);
|
||||
font-size: clamp(11px, 3cqmin, 14px);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -129,8 +129,9 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
||||
);
|
||||
const uptimeValue = clampPercent(combined.averageUptimePercent);
|
||||
const uptimeColor = getUptimeColor(uptimeValue);
|
||||
const ringSize = getRingSize(width);
|
||||
const iconSize = getIconSize(width);
|
||||
const visualScale = Math.min(width, height);
|
||||
const ringSize = getRingSize(visualScale);
|
||||
const iconSize = getIconSize(visualScale);
|
||||
const gridCols = getGridCols(width);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
@@ -247,7 +248,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="lg" p="md">
|
||||
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
|
||||
<Box h={Math.min(420, Math.max(180, Math.floor(height * 0.55)))}>{summaryContent}</Box>
|
||||
{monitorList}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
Reference in new issue
Block a user