feat: modernize widget experience
This commit is contained in:
1 parent
6331c88911
commit
1fa345edd1
165 files changed
+3698
-1649
No files matched your search
@@ -6,23 +6,23 @@ tags:
|
||||
|
||||
Homarr has the following keyboard shortcuts:
|
||||
|
||||
| Shortcut | Description |
|
||||
| ------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` while hovering a widget | Opens its temporary advanced view |
|
||||
| `Shift` + `Enter` on a focused widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Closes an open advanced view or board menu |
|
||||
| `Shift` + `F10` on a focused board section | Opens the precise add-item menu at the next available cell |
|
||||
| Shortcut | Description |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` while hovering a supported widget | Opens its temporary advanced view |
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Closes an open advanced view or board menu |
|
||||
| `Shift` + `F10` on a focused board section | Opens the precise add-item menu at the next available cell |
|
||||
|
||||
:::tip
|
||||
|
||||
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
|
||||
|
||||
Advanced view and precise placement are available outside edit mode. App tiles stay compact and do not have an
|
||||
advanced view. Precise placement still requires permission to change the board and the right-click interaction
|
||||
preference to be enabled.
|
||||
Advanced view and precise placement are available outside edit mode. Widgets without a distinct advanced experience
|
||||
adapt to their normal dimensions instead. Precise placement still requires permission to change the board and the
|
||||
right-click interaction preference to be enabled.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import widgetOverview from "./img/widget-overview.png";
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ This widget displays time-series charts for a selected Beszel system, including
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ The Coolify widget displays a compact overview of your Coolify instance. It can
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
|
||||
|
||||
<WidgetHeader widget={dnsHoleControlsWidget} categories={["DNS Hole"]} />
|
||||
|
||||
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Advanced view exposes every selected source, while compact layouts reduce controls according to both widget width and height.
|
||||
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget's width and height.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -53,7 +53,7 @@ import smallSize from "./img/small.png";
|
||||
- Apply an action to all available services from the top action row. Homarr keeps successful results visible and reports how many sources failed.
|
||||
- Interaction controls only appear when the current user has permission for that integration.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { dnsHoleSummaryWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={dnsHoleSummaryWidget} categories={["DNS Hole"]} />
|
||||
|
||||
This widget combines statistics from every selected DNS service, including total queries, blocked queries, and blocklist size. Its data is cached under the same DNS query family as DNS Hole Controls, so placing both widgets on a board does not create a second integration request. Advanced view keeps the aggregate metrics visible and adds a named status chip for each source.
|
||||
This widget combines statistics from every selected DNS service, including total queries, blocked queries, and blocklist size. Its data is cached under the same DNS query family as DNS Hole Controls, so placing both widgets on a board does not create a second integration request. When the widget has enough room, it adds a scrollable status row naming each selected source.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -52,7 +52,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import youtubeStream from "./img/nasa-iss-stream.png";
|
||||
<img src={youtubeStream} alt="iframe youtube-iss-stream" width={128 * 3} />
|
||||
</div>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ import { prowlarrIntegration } from "@site/docs/integrations/prowlarr";
|
||||
|
||||
- Test all indexers by clicking on the test button.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ import mediaRequestList from "./img/list.png";
|
||||
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
|
||||
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
|
||||
|
||||
Provide an overview of your media requests, including total requests, open requests, approvals, and rejections.
|
||||
Provides an overview of media requests, including totals, availability, approvals, and rejections. Resize the widget to reveal denser stat tiles; the user ranking remains scrollable instead of being truncated.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
@@ -45,7 +45,7 @@ import mediaRequestStats from "./img/stats.png";
|
||||
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
|
||||
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import bedrockItem from "./img/bedrock.png";
|
||||
<img src={bedrockItem} alt="minecraft-server-status bedrock-edition" width={128 * 1.5} height={128 * 1.5} />
|
||||
</div>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { networkControllerSummaryWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} />
|
||||
|
||||
Shows information whether your WAN is connected, Wi-Fi is active and several other information.
|
||||
Shows whether your WAN, internet, Wi-Fi, LAN, and VPN are available. Wider or taller layouts progressively reveal latency, ping, and connected-client counts without a separate view.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -36,7 +36,7 @@ import summaryCard from "./img/summary.png";
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import editOn from "./img/edit.png";
|
||||
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
|
||||
</div>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import notificationsScreenshot from "./img/notifications.png";
|
||||
items={[{ integration: ntfyIntegration }, { integration: nextcloudIntegration }, { integration: gotifyIntegration }]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ This widget displays statistics from your Paperless-ngx instance, including an i
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ When the widget has enough space, a "last updated" timestamp appears at the bott
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
|
||||
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
|
||||
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import screenshot from "./img/screenshot.png";
|
||||
|
||||
<img src={screenshot} alt="rss-feed noted-lol" width={128 * 4} />
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { stockPriceWidget } from ".";
|
||||
<WidgetHeader widget={stockPriceWidget} categories={["Finance", "Market"]} />
|
||||
|
||||
Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard.
|
||||
The compact view prioritizes the symbol, current price, and trend. Company name, change, and time range appear only when both the widget width and height can support them; the advanced view always shows the complete summary.
|
||||
The widget prioritizes the symbol, current price, and trend. Company name, change, time range, and a taller chart appear progressively when both widget dimensions can support them.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -34,7 +34,7 @@ import nvidia from "./img/nvidia.png";
|
||||
<img src={nvidia} alt="stock-price nvidia" width={128 * 2 + 2} height={128 * 2 + 4} />
|
||||
</div>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ This widget displays filesystem and SMART disk usage for supported server and NA
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp
|
||||
|
||||

|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import singleIntegrationImage from "./img/single.png";
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { IconArrowsMaximize, IconLayoutGrid, IconMouse2, IconPlus } from "@tabler/icons-react";
|
||||
|
||||
const interactions = [
|
||||
{
|
||||
icon: IconArrowsMaximize,
|
||||
title: "Advanced view",
|
||||
description:
|
||||
"Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.",
|
||||
},
|
||||
const advancedInteraction = {
|
||||
icon: IconArrowsMaximize,
|
||||
title: "Advanced view",
|
||||
description:
|
||||
"Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.",
|
||||
} as const;
|
||||
|
||||
const standardInteractions = [
|
||||
{
|
||||
icon: IconMouse2,
|
||||
title: "Widget actions",
|
||||
@@ -27,27 +28,35 @@ const interactions = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const WidgetInteractionGuide = () => (
|
||||
<details className="group my-6 overflow-hidden rounded-xl border border-solid border-[#e5e7eb] bg-slate-50/70 dark:border-[#333] dark:bg-gray-900/40">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 font-semibold marker:hidden">
|
||||
<span>Power-user controls</span>
|
||||
<span className="text-xs font-normal text-[#696969] group-open:hidden dark:text-[#999]">Show shortcuts</span>
|
||||
<span className="hidden text-xs font-normal text-[#696969] group-open:inline dark:text-[#999]">
|
||||
Hide shortcuts
|
||||
</span>
|
||||
</summary>
|
||||
<div className="grid gap-3 border-0 border-t border-solid border-[#e5e7eb] p-4 md:grid-cols-2 xl:grid-cols-4 dark:border-[#333]">
|
||||
{interactions.map(({ icon: Icon, title, description }) => (
|
||||
<div key={title} className="rounded-lg bg-white p-3 shadow-sm dark:bg-gray-950">
|
||||
<Icon aria-hidden size={20} stroke={1.5} className="mb-2 stroke-red-500" />
|
||||
<div className="font-semibold">{title}</div>
|
||||
<p className="mb-0 mt-1 text-sm text-[#696969] dark:text-[#999]">{description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="m-0 px-4 pb-4 text-xs text-[#696969] dark:text-[#999]">
|
||||
Advanced view reuses the widget's live data and cache. The rest of the board stays visible but dimmed. Touch
|
||||
devices expose a maximize control; app tiles stay compact.
|
||||
</p>
|
||||
</details>
|
||||
);
|
||||
export const WidgetInteractionGuide = ({ advancedView = true }: { advancedView?: boolean }) => {
|
||||
const interactions = advancedView ? [advancedInteraction, ...standardInteractions] : standardInteractions;
|
||||
|
||||
return (
|
||||
<details className="group my-6 overflow-hidden rounded-xl border border-solid border-[#e5e7eb] bg-slate-50/70 dark:border-[#333] dark:bg-gray-900/40">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 font-semibold marker:hidden">
|
||||
<span>Power-user controls</span>
|
||||
<span className="text-xs font-normal text-[#696969] group-open:hidden dark:text-[#999]">Show shortcuts</span>
|
||||
<span className="hidden text-xs font-normal text-[#696969] group-open:inline dark:text-[#999]">
|
||||
Hide shortcuts
|
||||
</span>
|
||||
</summary>
|
||||
<div
|
||||
className={`grid gap-3 border-0 border-t border-solid border-[#e5e7eb] p-4 md:grid-cols-2 ${advancedView ? "xl:grid-cols-4" : "xl:grid-cols-3"} dark:border-[#333]`}
|
||||
>
|
||||
{interactions.map(({ icon: Icon, title, description }) => (
|
||||
<div key={title} className="rounded-lg bg-white p-3 shadow-sm dark:bg-gray-950">
|
||||
<Icon aria-hidden size={20} stroke={1.5} className="mb-2 stroke-red-500" />
|
||||
<div className="font-semibold">{title}</div>
|
||||
<p className="mb-0 mt-1 text-sm text-[#696969] dark:text-[#999]">{description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{advancedView && (
|
||||
<p className="m-0 px-4 pb-4 text-xs text-[#696969] dark:text-[#999]">
|
||||
Advanced view reuses the widget's live data and cache. The rest of the board stays visible but dimmed.
|
||||
Touch devices expose a maximize control; compact-only widgets adapt in place instead.
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
};
|
||||
@@ -47,7 +47,6 @@ interface CustomWidgetFormProps {
|
||||
initialValues?: Partial<CustomWidgetFormValues>;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
const sectionLinks = [
|
||||
["general", "section.general", IconSettings],
|
||||
["sources", "section.sources", IconApi],
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.manualBackdrop {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.surface {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
@@ -15,6 +19,25 @@
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.manualSurface {
|
||||
position: fixed;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
overscroll-behavior: contain;
|
||||
animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.manualSurface > div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none !important;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.backdropClosing {
|
||||
animation: focus-backdrop-out 180ms ease-in forwards;
|
||||
}
|
||||
@@ -32,6 +55,18 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.surfacePortalTarget,
|
||||
.manualSurfaceMount {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.manualSurfaceMount {
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.surfaceHostAdvanced {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -49,7 +84,7 @@
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -99,7 +134,8 @@
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop,
|
||||
.surface {
|
||||
.surface,
|
||||
.manualSurface {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ActiveFocus {
|
||||
sourceRect: FocusRect;
|
||||
activation: "preview" | "manual";
|
||||
autofocusClose: boolean;
|
||||
restorePreviewFocus: boolean;
|
||||
phase: "visible" | "closing";
|
||||
}
|
||||
|
||||
@@ -79,6 +80,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||
activation: options.activation ?? "manual",
|
||||
autofocusClose: options.autofocusClose ?? false,
|
||||
restorePreviewFocus: options.activation === "preview" && document.activeElement === source,
|
||||
phase: "visible",
|
||||
});
|
||||
},
|
||||
@@ -118,12 +120,46 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
if (current?.activation === "preview" && current.itemId !== itemId) {
|
||||
cancelCloseTimer();
|
||||
updateActive(null);
|
||||
if (current.restorePreviewFocus && current.source.isConnected) current.source.focus({ preventScroll: true });
|
||||
}
|
||||
startHoverTimer();
|
||||
},
|
||||
[cancelCloseTimer, startHoverTimer, updateActive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const current = activeRef.current;
|
||||
if (!shiftHeldRef.current || current?.activation !== "preview") return;
|
||||
|
||||
const underlyingSource = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) =>
|
||||
element instanceof HTMLElement
|
||||
? element.matches(".grid-stack-item-content")
|
||||
? element
|
||||
: element.closest<HTMLElement>(".grid-stack-item-content")
|
||||
: null,
|
||||
)
|
||||
.find((source) => source !== null && source !== current.source);
|
||||
const itemId = underlyingSource?.closest<HTMLElement>(".grid-stack-item[data-id]")?.dataset.id;
|
||||
if (!underlyingSource || !itemId) return;
|
||||
|
||||
if (underlyingSource.hasAttribute("aria-keyshortcuts")) {
|
||||
hover(itemId, underlyingSource);
|
||||
return;
|
||||
}
|
||||
|
||||
hoveredRef.current = null;
|
||||
cancelHoverTimer();
|
||||
cancelCloseTimer();
|
||||
updateActive(null);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove, { capture: true });
|
||||
return () => window.removeEventListener("pointermove", handlePointerMove, { capture: true });
|
||||
}, [cancelCloseTimer, cancelHoverTimer, hover, updateActive]);
|
||||
|
||||
const leave = useCallback(
|
||||
(itemId: string) => {
|
||||
if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null;
|
||||
@@ -145,7 +181,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && activeRef.current) {
|
||||
const advancedSurface = document.querySelector(`.${classes.surface}`);
|
||||
const advancedSurface = document.querySelector("[data-advanced-focus-surface]");
|
||||
if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface))
|
||||
return;
|
||||
event.preventDefault();
|
||||
@@ -170,11 +206,11 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
if (activeRef.current?.activation === "preview") close(false);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
cancelHoverTimer();
|
||||
@@ -194,7 +230,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<AdvancedFocusContext.Provider value={value}>
|
||||
{children}
|
||||
{active && (
|
||||
{active?.activation === "preview" && (
|
||||
<Portal>
|
||||
<Overlay
|
||||
fixed
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isEscapeOwnedByNestedOverlay } from "./escape";
|
||||
describe("isEscapeOwnedByNestedOverlay", () => {
|
||||
test("allows Escape from the advanced surface itself", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "region");
|
||||
surface.setAttribute("role", "dialog");
|
||||
const button = document.createElement("button");
|
||||
surface.append(button);
|
||||
|
||||
@@ -44,6 +44,24 @@ describe("isEscapeOwnedByNestedOverlay", () => {
|
||||
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
|
||||
});
|
||||
|
||||
test("leaves Escape to a visible portalled menu when focus stays in the advanced surface", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
const target = document.createElement("button");
|
||||
surface.append(target);
|
||||
document.body.append(surface);
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.getClientRects = () => [{ width: 100, height: 100 }] as unknown as DOMRectList;
|
||||
document.body.append(menu);
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
|
||||
|
||||
menu.remove();
|
||||
surface.remove();
|
||||
});
|
||||
|
||||
test("does not mistake the compact widget source for a nested overlay", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "region");
|
||||
|
||||
@@ -6,8 +6,14 @@ const escapeOwnerSelector = [
|
||||
].join(", ");
|
||||
|
||||
export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => {
|
||||
if (!(target instanceof Element)) return false;
|
||||
const escapeOwner = target instanceof Element ? target.closest(escapeOwnerSelector) : null;
|
||||
if (escapeOwner !== null && escapeOwner !== advancedSurface) return true;
|
||||
|
||||
const escapeOwner = target.closest(escapeOwnerSelector);
|
||||
return escapeOwner !== null && escapeOwner !== advancedSurface;
|
||||
return Array.from(document.querySelectorAll(escapeOwnerSelector)).some(
|
||||
(owner) =>
|
||||
owner !== advancedSurface &&
|
||||
owner.getAttribute("aria-hidden") !== "true" &&
|
||||
owner.closest('[aria-hidden="true"]') === null &&
|
||||
owner.getClientRects().length > 0,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement, Fragment, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { AdvancedFocusManualSurface } from "./manual-surface";
|
||||
|
||||
const sourceRect = { left: 100, top: 120, width: 240, height: 160 };
|
||||
const rect = { left: 40, top: 80, width: 640, height: 480 };
|
||||
const closePosition = { left: 688, top: 80 };
|
||||
|
||||
describe("AdvancedFocusManualSurface", () => {
|
||||
let host: HTMLDivElement | undefined;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root?.unmount());
|
||||
host?.remove();
|
||||
document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("portals dialog semantics, traps focus, closes from the backdrop, and restores focus", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
|
||||
const Harness = () => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement("button", { type: "button", onClick: () => setOpened(true) }, "Open advanced view"),
|
||||
createElement(
|
||||
AdvancedFocusManualSurface,
|
||||
{
|
||||
opened,
|
||||
phase: "visible",
|
||||
id: "advanced-focus-test",
|
||||
label: "Test widget advanced view",
|
||||
closeLabel: "Close advanced view",
|
||||
rect,
|
||||
closePosition,
|
||||
sourceRect,
|
||||
radius: "md",
|
||||
contentRef,
|
||||
onClose: () => setOpened(false),
|
||||
},
|
||||
createElement("button", { type: "button" }, "First widget action"),
|
||||
createElement("button", { type: "button" }, "Last widget action"),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
await act(async () => root?.render(createElement(MantineProvider, null, createElement(Harness))));
|
||||
const trigger = document.querySelector<HTMLButtonElement>("button");
|
||||
expect(trigger).not.toBeNull();
|
||||
trigger?.focus();
|
||||
await act(async () => trigger?.click());
|
||||
await act(async () => await new Promise((resolve) => setTimeout(resolve, 20)));
|
||||
|
||||
const dialog = document.querySelector<HTMLElement>("[role='dialog']");
|
||||
const closeButton = dialog?.querySelector<HTMLButtonElement>("[aria-label='Close advanced view']");
|
||||
const actions = dialog?.querySelectorAll<HTMLButtonElement>("button");
|
||||
expect(dialog?.id).toBe("advanced-focus-test");
|
||||
expect(dialog?.getAttribute("aria-modal")).toBe("true");
|
||||
expect(dialog?.closest("[data-portal]")).not.toBeNull();
|
||||
expect(dialog?.closest(".grid-stack-item")).toBeNull();
|
||||
expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe(
|
||||
"Test widget advanced view",
|
||||
);
|
||||
expect(document.activeElement).toBe(closeButton);
|
||||
|
||||
const lastAction = actions?.item((actions?.length ?? 1) - 1);
|
||||
lastAction?.focus();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }));
|
||||
expect(document.activeElement).toBe(closeButton);
|
||||
closeButton?.focus();
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
expect(document.activeElement).toBe(lastAction);
|
||||
|
||||
const backdrop = document.querySelector<HTMLElement>("[data-advanced-focus-overlay]");
|
||||
await act(async () => backdrop?.click());
|
||||
await act(async () => await new Promise((resolve) => setTimeout(resolve, 20)));
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, PropsWithChildren, Ref } from "react";
|
||||
import { Modal, VisuallyHidden } from "@mantine/core";
|
||||
import { useReducedMotion } from "@mantine/hooks";
|
||||
|
||||
import classes from "./advanced-focus.module.css";
|
||||
import type { FocusClosePosition, FocusRect } from "./geometry";
|
||||
|
||||
interface AdvancedFocusManualSurfaceProps extends PropsWithChildren {
|
||||
opened: boolean;
|
||||
phase: "visible" | "closing";
|
||||
id: string;
|
||||
label: string;
|
||||
closeLabel: string;
|
||||
rect: FocusRect;
|
||||
closePosition: FocusClosePosition;
|
||||
sourceRect: FocusRect;
|
||||
radius: string | number;
|
||||
contentRef: Ref<HTMLDivElement>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AdvancedFocusManualSurface = ({
|
||||
opened,
|
||||
phase,
|
||||
id,
|
||||
label,
|
||||
closeLabel,
|
||||
rect,
|
||||
closePosition,
|
||||
sourceRect,
|
||||
radius,
|
||||
contentRef,
|
||||
onClose,
|
||||
children,
|
||||
}: AdvancedFocusManualSurfaceProps) => {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const transitionDuration = reduceMotion ? 0 : 180;
|
||||
const geometryStyle = {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
"--focus-translate-x": `${sourceRect.left - rect.left}px`,
|
||||
"--focus-translate-y": `${sourceRect.top - rect.top}px`,
|
||||
"--focus-scale-x": rect.width > 0 ? sourceRect.width / rect.width : 1,
|
||||
"--focus-scale-y": rect.height > 0 ? sourceRect.height / rect.height : 1,
|
||||
} as CSSProperties;
|
||||
const relativeClosePosition = {
|
||||
left: closePosition.left - rect.left,
|
||||
top: closePosition.top - rect.top,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
radius={radius}
|
||||
padding={0}
|
||||
shadow="none"
|
||||
zIndex="calc(var(--mantine-z-index-modal) - 1)"
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside
|
||||
lockScroll
|
||||
trapFocus
|
||||
returnFocus
|
||||
removeScrollProps={{ allowPinchZoom: true }}
|
||||
transitionProps={{ duration: transitionDuration, transition: "fade" }}
|
||||
>
|
||||
<Modal.Overlay
|
||||
data-advanced-focus-overlay
|
||||
backgroundOpacity={0.38}
|
||||
blur={0}
|
||||
className={`${classes.backdrop} ${classes.manualBackdrop} ${
|
||||
phase === "closing" ? classes.backdropClosing : ""
|
||||
}`}
|
||||
/>
|
||||
<Modal.Content
|
||||
ref={contentRef}
|
||||
id={id}
|
||||
data-advanced-focus-surface
|
||||
className={`${classes.manualSurface} ${phase === "closing" ? classes.surfaceClosing : ""}`}
|
||||
style={geometryStyle}
|
||||
>
|
||||
<Modal.Title>
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton
|
||||
className={classes.closeButton}
|
||||
variant="default"
|
||||
size={44}
|
||||
aria-label={closeLabel}
|
||||
aria-controls={id}
|
||||
style={relativeClosePosition}
|
||||
/>
|
||||
{children}
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
};
|
||||
@@ -13,10 +13,8 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null
|
||||
if (delta === 0) return false;
|
||||
|
||||
let current = target instanceof Element ? target : null;
|
||||
let startedInScrollContainer = false;
|
||||
while (current && root.contains(current)) {
|
||||
const isCurrentScrollContainer = isVerticalScrollContainer(current);
|
||||
startedInScrollContainer ||= isCurrentScrollContainer;
|
||||
if (isCurrentScrollContainer && canScrollVertically(current, delta)) {
|
||||
current.scrollTop += delta;
|
||||
return true;
|
||||
@@ -24,8 +22,9 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null
|
||||
if (current === root) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
if (startedInScrollContainer) return false;
|
||||
|
||||
// A nested control can reach its edge before the widget's main Mantine viewport.
|
||||
// Hand the gesture to that viewport instead of making Shift+wheel feel stuck.
|
||||
const viewport = [...root.querySelectorAll<HTMLElement>("[data-scrollbars='y'], [data-scrollbars='xy']")].find(
|
||||
(element) => isVerticalScrollContainer(element) && canScrollVertically(element, delta),
|
||||
);
|
||||
|
||||
@@ -28,7 +28,6 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
|
||||
return <BoardItemMenuInner {...props} />;
|
||||
};
|
||||
|
||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import type { SqliteDatabase } from "./shared/e2e-db";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { loginAsync } from "./shared/login";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const ownerCredentials = { username: "owner", password: "Comp(exP4sswOrd" };
|
||||
@@ -68,11 +69,12 @@ describe("Board advanced interactions", () => {
|
||||
|
||||
try {
|
||||
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
|
||||
const compactSurface = widget.locator(".clock-wrapper");
|
||||
const advancedSurface = page.getByRole("region", { name: "Date and time advanced view" });
|
||||
const iframeAdvancedSurface = page.getByRole("region", { name: "iFrame advanced view" });
|
||||
const compactSurface = page.locator(".clock-wrapper").first();
|
||||
const previewSurface = page.getByRole("region", { name: "Date and time advanced view" });
|
||||
const manualSurface = page.getByRole("dialog", { name: "Date and time advanced view" });
|
||||
const bookmarksPreviewSurface = page.getByRole("region", { name: "Bookmarks advanced view" });
|
||||
const dimmingOverlay = page.locator("[data-advanced-focus-overlay]");
|
||||
const otherWidget = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first();
|
||||
const otherWidget = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first();
|
||||
|
||||
await compactSurface.evaluate((element) => {
|
||||
element.setAttribute("data-lifecycle-probe", "same-instance");
|
||||
@@ -80,42 +82,54 @@ describe("Board advanced interactions", () => {
|
||||
await widget.focus();
|
||||
await page.keyboard.down("Shift");
|
||||
await widget.hover();
|
||||
await expect(advancedSurface).toBeVisible({ timeout: 2_000 });
|
||||
await expect(advancedSurface).toHaveCSS("animation-name", "none");
|
||||
await expect(previewSurface).toBeVisible({ timeout: 2_000 });
|
||||
await expect(previewSurface).toHaveCSS("animation-name", "none");
|
||||
const compactBounds = await widget.boundingBox();
|
||||
const advancedBounds = await advancedSurface.boundingBox();
|
||||
const advancedBounds = await previewSurface.boundingBox();
|
||||
expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0);
|
||||
await expect(advancedSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(previewSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(dimmingOverlay).toBeVisible();
|
||||
await expect(dimmingOverlay).toHaveCSS("pointer-events", "none");
|
||||
await expect(otherWidget).toBeVisible();
|
||||
await expect(widget).toBeFocused();
|
||||
|
||||
await advancedSurface.click({ button: "right", position: { x: 80, y: 80 } });
|
||||
await previewSurface.click({ button: "right", position: { x: 80, y: 80 } });
|
||||
const portalledMenuItem = page.getByRole("menuitem", { name: "Open advanced view" });
|
||||
await expect(portalledMenuItem).toBeVisible();
|
||||
await portalledMenuItem.hover();
|
||||
await expect(advancedSurface).toBeVisible();
|
||||
await expect(previewSurface).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(portalledMenuItem).toBeHidden();
|
||||
await expect(advancedSurface).toBeVisible();
|
||||
await expect(previewSurface).toBeVisible();
|
||||
|
||||
await otherWidget.hover();
|
||||
await expect(advancedSurface).toBeHidden();
|
||||
await expect(iframeAdvancedSurface).toBeVisible({ timeout: 2_000 });
|
||||
const otherBounds = await otherWidget.boundingBox();
|
||||
expect(otherBounds).not.toBeNull();
|
||||
if (!otherBounds) throw new Error("Bookmarks widget has no bounds");
|
||||
await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2);
|
||||
await expect(previewSurface).toBeHidden();
|
||||
await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 });
|
||||
await page.keyboard.up("Shift");
|
||||
await expect(iframeAdvancedSurface).toBeHidden();
|
||||
await expect(bookmarksPreviewSurface).toBeHidden();
|
||||
await expect(dimmingOverlay).toBeHidden();
|
||||
await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(widget).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await expect(advancedSurface).toBeVisible();
|
||||
await expect(manualSurface).toBeVisible();
|
||||
await expect(manualSurface).toHaveAttribute("aria-modal", "true");
|
||||
expect(await manualSurface.evaluate((element) => element.closest(".grid-stack-item"))).toBeNull();
|
||||
await expect(manualSurface.locator(".clock-wrapper")).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(dimmingOverlay).toHaveCSS("pointer-events", "auto");
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe("hidden");
|
||||
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
|
||||
await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await advancedSurface.evaluate((element) => {
|
||||
await manualSurface.evaluate((element) => {
|
||||
const probe = document.createElement("div");
|
||||
probe.dataset.shiftScrollProbe = "true";
|
||||
probe.style.cssText = "height:80px;overflow-y:auto";
|
||||
@@ -123,11 +137,19 @@ describe("Board advanced interactions", () => {
|
||||
probe.addEventListener("wheel", (event) => event.stopPropagation());
|
||||
element.append(probe);
|
||||
|
||||
const unrelated = probe.cloneNode(true) as HTMLDivElement;
|
||||
const mainViewport = document.createElement("div");
|
||||
mainViewport.dataset.scrollbars = "y";
|
||||
mainViewport.style.cssText = "height:80px;overflow-y:auto";
|
||||
mainViewport.innerHTML = '<div style="height:600px"></div>';
|
||||
element.append(mainViewport);
|
||||
|
||||
const unrelated = document.createElement("div");
|
||||
unrelated.dataset.shiftScrollProbe = "unrelated";
|
||||
unrelated.style.cssText = "height:80px;overflow-y:hidden";
|
||||
unrelated.innerHTML = '<div style="height:600px"></div>';
|
||||
element.append(unrelated);
|
||||
});
|
||||
const scrollProbe = advancedSurface.locator("[data-shift-scroll-probe='true']");
|
||||
const scrollProbe = manualSurface.locator("[data-shift-scroll-probe='true']");
|
||||
await page.keyboard.down("Shift");
|
||||
const pixelEventCancelled = await scrollProbe.evaluate(
|
||||
(element) =>
|
||||
@@ -156,56 +178,78 @@ describe("Board advanced interactions", () => {
|
||||
new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }),
|
||||
);
|
||||
});
|
||||
expect(await manualSurface.locator("[data-scrollbars='y']").evaluate((element) => element.scrollTop)).toBe(120);
|
||||
expect(
|
||||
await advancedSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop),
|
||||
await manualSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop),
|
||||
).toBe(0);
|
||||
|
||||
await manualSurface.click({ button: "right", position: { x: 80, y: 80 } });
|
||||
await expect(portalledMenuItem).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(advancedSurface).toBeHidden();
|
||||
await expect(portalledMenuItem).toBeHidden();
|
||||
await expect(manualSurface).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(manualSurface).toBeHidden();
|
||||
await expect(widget).toBeFocused();
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe("hidden");
|
||||
|
||||
await widget.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
|
||||
await expect(advancedSurface).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(advancedSurface).toBeHidden();
|
||||
await expect(manualSurface).toBeVisible();
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("Board page has no viewport");
|
||||
await page.mouse.click(4, viewport.height - 4);
|
||||
await expect(manualSurface).toBeHidden();
|
||||
await expect(widget).toBeFocused();
|
||||
|
||||
const appWidget = page.locator(".grid-stack-item[data-kind='app'] > .grid-stack-item-content").first();
|
||||
await expect(appWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
|
||||
await appWidget.click({ button: "right" });
|
||||
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.down("Shift");
|
||||
await appWidget.hover();
|
||||
await expect(advancedSurface).toBeHidden();
|
||||
await page.keyboard.up("Shift");
|
||||
for (const compactOnlyKind of ["app", "iframe"]) {
|
||||
const compactOnlyWidget = page
|
||||
.locator(`.grid-stack-item[data-kind='${compactOnlyKind}'] > .grid-stack-item-content`)
|
||||
.first();
|
||||
await expect(compactOnlyWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
|
||||
await compactOnlyWidget.dispatchEvent("contextmenu", { button: 2 });
|
||||
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.down("Shift");
|
||||
await compactOnlyWidget.hover({ position: { x: 4, y: 4 } });
|
||||
await expect(previewSurface).toBeHidden();
|
||||
await expect(dimmingOverlay).toBeHidden();
|
||||
await page.keyboard.up("Shift");
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("preserves iframe browsing state and dismisses focus when a responsive layout remounts", async () => {
|
||||
test("keeps compact-only iframe state and dismisses retained focus when a responsive layout remounts", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
|
||||
|
||||
try {
|
||||
const iframeSlot = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first();
|
||||
const iframe = iframeSlot.locator("iframe");
|
||||
await iframe.evaluate((element) => {
|
||||
element.srcdoc = '<label>State <input id="state" value="initial"></label>';
|
||||
(element as HTMLIFrameElement).srcdoc = '<label>State <input id="state" value="initial"></label>';
|
||||
(element as HTMLIFrameElement).dataset.advancedFocusProbe = "true";
|
||||
});
|
||||
const frame = page.frameLocator(".grid-stack-item[data-kind='iframe'] iframe");
|
||||
const frame = page.frameLocator("iframe[data-advanced-focus-probe='true']");
|
||||
await frame.locator("#state").fill("changed");
|
||||
|
||||
await iframeSlot.focus();
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeVisible();
|
||||
await expect(frame.locator("#state")).toHaveValue("changed");
|
||||
await expect(iframeSlot).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
|
||||
await iframeSlot.dispatchEvent("contextmenu", { button: 2 });
|
||||
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.down("Shift");
|
||||
await iframeSlot.hover({ position: { x: 4, y: 4 } });
|
||||
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toHaveCount(0);
|
||||
await page.keyboard.up("Shift");
|
||||
await expect(frame.locator("#state")).toHaveValue("changed");
|
||||
|
||||
await iframeSlot.focus();
|
||||
const bookmarksSlot = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first();
|
||||
await bookmarksSlot.focus();
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeVisible();
|
||||
await page.setViewportSize({ width: 800, height: 900 });
|
||||
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeHidden();
|
||||
await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeHidden();
|
||||
await expect(page.locator(".grid-stack-item[data-kind='iframe'] iframe")).toBeVisible();
|
||||
} finally {
|
||||
await context.close();
|
||||
@@ -229,7 +273,7 @@ describe("Board advanced interactions", () => {
|
||||
expect(bounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366);
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(768);
|
||||
const surfaceBounds = await page.getByRole("region", { name: "Date and time advanced view" }).boundingBox();
|
||||
const surfaceBounds = await page.getByRole("dialog", { name: "Date and time advanced view" }).boundingBox();
|
||||
const contentBounds = await page.locator(".clock-widget-container").boundingBox();
|
||||
expect(surfaceBounds).not.toBeNull();
|
||||
expect(contentBounds).not.toBeNull();
|
||||
@@ -240,7 +284,7 @@ describe("Board advanced interactions", () => {
|
||||
);
|
||||
}
|
||||
await closeButton.click();
|
||||
await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeHidden();
|
||||
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -304,7 +348,7 @@ describe("Board advanced interactions", () => {
|
||||
|
||||
await widget.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
|
||||
await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(grid).not.toHaveAttribute("aria-label", "Add item here");
|
||||
@@ -325,14 +369,8 @@ const openBoardAsync = async (
|
||||
const context = await browser.newContext({ reducedMotion: "reduce", viewport });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(credentials.username);
|
||||
await page.locator("#password").fill(credentials.password);
|
||||
const loginRedirect = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" });
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await loginRedirect;
|
||||
await page.goto(`${baseUrl}/boards/${boardName}`);
|
||||
await expect(page.locator(".grid-stack-item[data-kind='clock'] .clock-wrapper").first()).toBeVisible({
|
||||
await loginAsync({ page, baseUrl, credentials, destination: `/boards/${boardName}` });
|
||||
await expect(page.locator(".clock-wrapper").first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
@@ -368,6 +406,7 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
|
||||
const desktopLayoutId = createId();
|
||||
const sectionId = createId();
|
||||
const clockItemId = createId();
|
||||
const bookmarksItemId = createId();
|
||||
const iframeItemId = createId();
|
||||
const appItemId = createId();
|
||||
const appId = createId();
|
||||
@@ -392,6 +431,22 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
|
||||
options: stringify({ is24HourFormat: true, showSeconds: false, showDate: true }),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
{
|
||||
id: bookmarksItemId,
|
||||
kind: "bookmarks",
|
||||
boardId,
|
||||
options: stringify({
|
||||
title: "",
|
||||
layout: "column",
|
||||
hideTitle: false,
|
||||
hideIcon: false,
|
||||
hideHostname: false,
|
||||
openNewTab: true,
|
||||
withBorder: false,
|
||||
items: [appId],
|
||||
}),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
{
|
||||
id: iframeItemId,
|
||||
kind: "iframe",
|
||||
@@ -418,6 +473,15 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
itemId: bookmarksItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
itemId: iframeItemId,
|
||||
sectionId,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
interface LoginInput {
|
||||
page: Page;
|
||||
baseUrl: string;
|
||||
credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
destination?: string;
|
||||
}
|
||||
|
||||
export const loginAsync = async ({ page, baseUrl, credentials, destination = "/" }: LoginInput) => {
|
||||
const destinationUrl = new URL(destination, baseUrl);
|
||||
const loginUrl = new URL("/auth/login", baseUrl);
|
||||
loginUrl.searchParams.set("callbackUrl", destinationUrl.href);
|
||||
|
||||
await page.goto(loginUrl.href, { waitUntil: "load" });
|
||||
await page.waitForFunction(() => {
|
||||
const form = document.querySelector("form");
|
||||
return form !== null && Object.keys(form).some((key) => key.startsWith("__reactProps$"));
|
||||
});
|
||||
await page.getByLabel("Username").fill(credentials.username);
|
||||
await page.locator("#password").fill(credentials.password);
|
||||
|
||||
await page.locator("button[type='submit']").click();
|
||||
await waitForAuthenticatedSessionAsync(page, baseUrl);
|
||||
|
||||
await page.goto(destinationUrl.href, { waitUntil: "domcontentloaded" });
|
||||
const currentUrl = new URL(page.url());
|
||||
if (currentUrl.origin !== destinationUrl.origin || currentUrl.pathname !== destinationUrl.pathname) {
|
||||
throw new Error("Authenticated destination was not reached");
|
||||
}
|
||||
};
|
||||
|
||||
const waitForAuthenticatedSessionAsync = async (page: Page, baseUrl: string) => {
|
||||
const sessionUrl = new URL("/api/auth/session", baseUrl).href;
|
||||
const deadline = Date.now() + 30_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const response = await page.request.get(sessionUrl);
|
||||
if (response.ok()) {
|
||||
const session = (await response.json()) as { user?: unknown } | null;
|
||||
if (session?.user !== undefined) return;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
throw new Error("Authentication session was not established");
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { parse } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { boards, items } from "@homarr/db/schema";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
|
||||
export const customApiItemInputSchema = z.object({
|
||||
boardId: z.string().min(1).max(100),
|
||||
itemId: z.string().min(1).max(100),
|
||||
definitionId: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
const customApiOptionsSchema = z.object({ definitionId: z.string() }).passthrough();
|
||||
|
||||
export async function assertCustomApiItemBindingAsync(
|
||||
ctx: { db: Database; session: Session | null },
|
||||
input: z.infer<typeof customApiItemInputSchema>,
|
||||
permission?: BoardPermission,
|
||||
) {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
columns: { options: true },
|
||||
where: and(eq(items.id, input.itemId), eq(items.boardId, input.boardId), eq(items.kind, "customApi")),
|
||||
});
|
||||
|
||||
if (!item) throwCustomApiWidgetNotFound();
|
||||
if (permission) await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), permission);
|
||||
|
||||
try {
|
||||
const options = customApiOptionsSchema.parse(parse<unknown>(item.options));
|
||||
if (options.definitionId !== input.definitionId) throwCustomApiWidgetNotFound();
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throwCustomApiWidgetNotFound();
|
||||
}
|
||||
}
|
||||
|
||||
function throwCustomApiWidgetNotFound(): never {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom API widget not found" });
|
||||
}
|
||||
@@ -37,11 +37,10 @@ export const locationRouter = createTRPCRouter({
|
||||
.input(locationSearchCityInput)
|
||||
.output(locationSearchCityOutput)
|
||||
.query(async ({ input }) => {
|
||||
const url = new URL("https://geocoding-api.open-meteo.com/v1/search");
|
||||
url.searchParams.set("name", input.query);
|
||||
const res = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
|
||||
{ signal },
|
||||
);
|
||||
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
});
|
||||
return (await res.json()) as z.infer<typeof locationSearchCityOutput>;
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { stringify } from "superjson";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, boardUserPermissions, customWidgetDefinitions, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
import { customWidgetRouter } from "../custom-widget/custom-widget-router";
|
||||
import { customApiRouter } from "../widgets/custom-api";
|
||||
|
||||
const createSession = (userId: string, permissions: GroupPermissionKey[] = []): Session => ({
|
||||
user: { id: userId, permissions, colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCustomWidgetCaller = (db: Database, session: Session | null) =>
|
||||
customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
|
||||
const createCustomApiCaller = (db: Database, session: Session | null) =>
|
||||
customApiRouter.createCaller({ db, deviceType: undefined, session });
|
||||
|
||||
async function createFixtureAsync(db: Database) {
|
||||
const ownerId = createId();
|
||||
const viewerId = createId();
|
||||
const modifierId = createId();
|
||||
const unrelatedId = createId();
|
||||
const adminId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
const definitionId = createId();
|
||||
|
||||
await db.insert(users).values(
|
||||
[ownerId, viewerId, modifierId, unrelatedId, adminId].map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
})),
|
||||
);
|
||||
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic: false });
|
||||
await db.insert(boardUserPermissions).values([
|
||||
{ boardId, userId: viewerId, permission: "view" },
|
||||
{ boardId, userId: modifierId, permission: "modify" },
|
||||
]);
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: definitionId,
|
||||
name: "Restart service",
|
||||
description: "Restarts a service",
|
||||
url: "https://api.example.com/restart",
|
||||
method: "POST",
|
||||
displayType: "actionButton",
|
||||
displayConfig: stringify({
|
||||
type: "actionButton",
|
||||
buttonLabel: "Restart",
|
||||
confirmText: "Continue?",
|
||||
}),
|
||||
creatorId: adminId,
|
||||
});
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "customApi",
|
||||
options: stringify({ definitionId, refreshInterval: 30 }),
|
||||
});
|
||||
|
||||
return { ownerId, viewerId, modifierId, unrelatedId, adminId, boardId, itemId, definitionId };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("custom widget definition authorization", () => {
|
||||
it("restricts the full definition list to administrators", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
|
||||
await expect(createCustomWidgetCaller(db, null).all()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
|
||||
await expect(createCustomWidgetCaller(db, createSession(userId)).all()).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
await expect(createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).all()).resolves.toEqual([
|
||||
expect.objectContaining({ id: fixture.definitionId, url: "https://api.example.com/restart" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only safe definition summaries to board modifiers", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
|
||||
const result = await createCustomWidgetCaller(db, createSession(fixture.modifierId)).available({
|
||||
boardId: fixture.boardId,
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: fixture.definitionId,
|
||||
name: "Restart service",
|
||||
description: "Restarts a service",
|
||||
iconUrl: null,
|
||||
displayType: "actionButton",
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain("api.example.com");
|
||||
|
||||
for (const userId of [fixture.viewerId, fixture.unrelatedId]) {
|
||||
await expect(
|
||||
createCustomWidgetCaller(db, createSession(userId)).available({ boardId: fixture.boardId }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom widget action authorization", () => {
|
||||
it("requires an administrator even for users who can view or modify the board", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const input = {
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
};
|
||||
|
||||
await expect(createCustomWidgetCaller(db, null).execute(input)).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
|
||||
await expect(createCustomWidgetCaller(db, createSession(userId)).execute(input)).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("executes for an administrator only when board, item, and definition are bound", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
|
||||
|
||||
await expect(
|
||||
caller.execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
}),
|
||||
).resolves.toEqual({ success: true, responseInfo: { status: 204 } });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/restart",
|
||||
expect.objectContaining({ method: "POST", redirect: "error", signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["board", "item", "definition"] as const)("rejects a mismatched %s binding", async (mismatch) => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const input = {
|
||||
boardId: mismatch === "board" ? createId() : fixture.boardId,
|
||||
itemId: mismatch === "item" ? createId() : fixture.itemId,
|
||||
definitionId: mismatch === "definition" ? createId() : fixture.definitionId,
|
||||
};
|
||||
|
||||
await expect(
|
||||
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute(input),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects disabled definitions before sending a request", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ enabled: false })
|
||||
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom API outbound request hardening", () => {
|
||||
it("rejects credential-bearing action URLs without exposing the credential", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ url: "https://operator:super-secret@private.example.com/restart" })
|
||||
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
});
|
||||
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
|
||||
expect(JSON.stringify(result)).not.toMatch(/super-secret|private\.example\.com/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects redirects and redacts fetch errors", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new TypeError("Redirect to http://token:secret@internal.example.local/admin?apiKey=top-secret was rejected"),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/restart",
|
||||
expect.objectContaining({ redirect: "error" }),
|
||||
);
|
||||
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
|
||||
expect(JSON.stringify(result)).not.toMatch(/top-secret|internal\.example\.local|apiKey/);
|
||||
});
|
||||
|
||||
it("aborts timed-out action requests and returns a stable failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const started = Promise.withResolvers<void>();
|
||||
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
|
||||
started.resolve();
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
reject(new Error("Timed out calling http://internal.example.local?secret=timeout-secret"));
|
||||
});
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
});
|
||||
await started.promise;
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
const result = await request;
|
||||
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
|
||||
expect(JSON.stringify(result)).not.toMatch(/timeout-secret|internal\.example\.local/);
|
||||
});
|
||||
|
||||
it("bounds action responses before reporting success", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1))));
|
||||
|
||||
await expect(
|
||||
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
}),
|
||||
).resolves.toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
|
||||
});
|
||||
|
||||
it("binds data requests to a viewable item and never exposes backend error text", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({
|
||||
displayType: "singleValue",
|
||||
method: "GET",
|
||||
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
|
||||
})
|
||||
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockRejectedValue(new Error("Backend http://private.example.local/path?token=super-secret\nstack trace")),
|
||||
);
|
||||
|
||||
const input = {
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
};
|
||||
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
|
||||
.getData(input)
|
||||
.catch((caught: unknown) => caught);
|
||||
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
|
||||
expect(JSON.stringify(error)).not.toMatch(/super-secret|private\.example\.local|stack trace/);
|
||||
|
||||
await expect(createCustomApiCaller(db, createSession(fixture.unrelatedId)).getData(input)).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await expect(
|
||||
createCustomApiCaller(db, createSession(fixture.viewerId)).getData({ ...input, definitionId: createId() }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("bounds and disposes non-success data responses", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({
|
||||
displayType: "singleValue",
|
||||
method: "GET",
|
||||
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
|
||||
})
|
||||
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1), { status: 502 })));
|
||||
|
||||
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
|
||||
.getData({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
|
||||
expect(JSON.stringify(error)).not.toContain("x".repeat(100));
|
||||
});
|
||||
|
||||
it("redacts unsafe preview failures and rejects redirects", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error("redirected to http://private.local?secret=value"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
|
||||
|
||||
const result = await caller.preview({
|
||||
url: "https://api.example.com/data",
|
||||
method: "GET",
|
||||
authType: "none",
|
||||
displayType: "singleValue",
|
||||
displayConfig: { type: "singleValue", jsonPath: "$.value", label: "Value", unit: "" },
|
||||
secrets: [],
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/data",
|
||||
expect.objectContaining({ redirect: "error" }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Custom API preview failed",
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toMatch(/private\.local|secret=value/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { locationRouter } from "../location";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ fetch: vi.fn() }));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: mocks.fetch,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http/timeout", () => ({
|
||||
withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise<Response>) =>
|
||||
await callback(new AbortController().signal),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.fetch.mockResolvedValue(new Response(JSON.stringify({ results: [] })));
|
||||
});
|
||||
|
||||
describe("locationRouter", () => {
|
||||
test("encodes the complete search text as one name parameter", async () => {
|
||||
const caller = locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null });
|
||||
|
||||
await caller.searchCity({ query: "Paris&language=xx&token=secret" });
|
||||
|
||||
const requestedUrl = new URL(mocks.fetch.mock.calls[0]?.[0] as string);
|
||||
expect(requestedUrl.searchParams.get("name")).toBe("Paris&language=xx&token=secret");
|
||||
expect(requestedUrl.searchParams.has("language")).toBe(false);
|
||||
expect(requestedUrl.searchParams.has("token")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,14 @@ import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types
|
||||
import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list";
|
||||
|
||||
import { calendarRouter } from "../../widgets/calendar";
|
||||
import { beszelRouter } from "../../widgets/beszel";
|
||||
import { firewallRouter } from "../../widgets/firewall";
|
||||
import { indexerManagerRouter } from "../../widgets/indexer-manager";
|
||||
import { mediaOrganizerRouter } from "../../widgets/media-organizer";
|
||||
import { mediaRequestsRouter } from "../../widgets/media-requests";
|
||||
import { mediaServerRouter } from "../../widgets/media-server";
|
||||
import { notificationsRouter } from "../../widgets/notifications";
|
||||
import { vpnRouter } from "../../widgets/vpn";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
@@ -21,7 +27,11 @@ vi.hoisted(() => {
|
||||
|
||||
const createHandler = <T>(integration: { name: string }, data: T) => ({
|
||||
getDataAsync: async () => {
|
||||
if (integration.name === "Offline") throw new Error("offline https://internal.example?token=secret");
|
||||
if (integration.name === "Offline") {
|
||||
throw new Error(
|
||||
"GET http://admin:password@internal.example/private/path?token=secret returned\nprivate response body",
|
||||
);
|
||||
}
|
||||
return { data, timestamp: new Date() };
|
||||
},
|
||||
});
|
||||
@@ -34,6 +44,40 @@ vi.mock("@homarr/request-handler/media-server", () => ({
|
||||
mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/media-organizer", () => ({
|
||||
mediaOrganizerRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, { missing: [], missingCount: 0, queued: [], queuedCount: 0 }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/beszel", () => ({
|
||||
beszelSystemsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/firewall", () => ({
|
||||
firewallCpuRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, { total: 1 }) },
|
||||
firewallInterfacesRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
firewallMemoryRequestHandler: {
|
||||
handler: (integration: { name: string }) => createHandler(integration, { used: 1, total: 2, percent: 50 }),
|
||||
},
|
||||
firewallVersionRequestHandler: {
|
||||
handler: (integration: { name: string }) => createHandler(integration, { version: "1.0" }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/indexer-manager", () => ({
|
||||
indexerManagerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/notifications", () => ({
|
||||
notificationsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/vpn", () => ({
|
||||
vpnSummaryHandler: { handler: (integration: { name: string }) => createHandler(integration, { connected: true }) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof MediaRequestListHandlerModule>();
|
||||
return {
|
||||
@@ -121,7 +165,7 @@ describe("partial integration failures", () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integration: expect.objectContaining({ id: calendar.offlineId }),
|
||||
error: "Integration request failed",
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -136,7 +180,7 @@ describe("partial integration failures", () => {
|
||||
expect.objectContaining({
|
||||
integrationId: mediaServer.offlineId,
|
||||
sessions: [],
|
||||
error: "Integration request failed",
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -165,13 +209,13 @@ describe("partial integration failures", () => {
|
||||
}),
|
||||
]);
|
||||
expect(latest.failedIntegrations).toEqual([
|
||||
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }),
|
||||
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
|
||||
]);
|
||||
expect(JSON.stringify(latest)).not.toContain("secret");
|
||||
|
||||
const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds });
|
||||
expect(stats.failedIntegrations).toEqual([
|
||||
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }),
|
||||
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
|
||||
]);
|
||||
expect(JSON.stringify(stats)).not.toContain("secret");
|
||||
expect(stats.stats).toHaveLength(1);
|
||||
@@ -183,4 +227,58 @@ describe("partial integration failures", () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("aggregate widget payloads never expose sensitive partial-failure details", async () => {
|
||||
const cases = [
|
||||
{
|
||||
kind: "sonarr" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await mediaOrganizerRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getData({ integrationIds: setup.integrationIds, pageSize: 10 }),
|
||||
},
|
||||
{
|
||||
kind: "beszel" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await beszelRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getSystems({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "opnsense" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await firewallRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getFirewallCpuStatus({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "prowlarr" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await indexerManagerRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getIndexersStatus({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "ntfy" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await notificationsRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getNotifications({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "gluetun" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await vpnRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getSummaries({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const setup = await setupAsync(testCase.kind);
|
||||
const payload = JSON.stringify(await testCase.run(setup));
|
||||
expect(payload).toContain("INTEGRATION_REQUEST_FAILED");
|
||||
expect(payload).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security";
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
beszelSystemsRequestHandler,
|
||||
} from "@homarr/request-handler/beszel";
|
||||
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { BoundedAsyncQueue } from "./bounded-async-queue";
|
||||
@@ -18,8 +18,6 @@ import { BoundedAsyncQueue } from "./bounded-async-queue";
|
||||
const logger = createLogger({ module: "beszelRouter" });
|
||||
const MAX_PENDING_LIVE_EVENTS = 4;
|
||||
|
||||
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
export const beszelRouter = createTRPCRouter({
|
||||
getSystems: publicProcedure
|
||||
.meta({
|
||||
@@ -41,7 +39,6 @@ export const beszelRouter = createTRPCRouter({
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
systems: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
@@ -50,10 +47,9 @@ export const beszelRouter = createTRPCRouter({
|
||||
fallback: (integration, error) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
systems: [],
|
||||
updatedAt: new Date(0),
|
||||
error: errorMessage(error),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
@@ -127,7 +123,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
history: [],
|
||||
systemNameMap: {},
|
||||
updatedAt: new Date(0),
|
||||
error: errorMessage(error),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
@@ -194,7 +190,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
systemStats: [],
|
||||
containerStats: [],
|
||||
updatedAt: new Date(0),
|
||||
error: errorMessage(error),
|
||||
error: toPublicIntegrationError(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
@@ -271,15 +267,14 @@ export const beszelRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
emittedEventCount,
|
||||
error: errorMessage(error),
|
||||
error,
|
||||
});
|
||||
queue.fail(
|
||||
error instanceof TRPCError
|
||||
? error
|
||||
: new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
new TRPCError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: "Live integration request failed",
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { radarrReleaseTypes } from "@homarr/integrations/types";
|
||||
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const calendarRouter = createTRPCRouter({
|
||||
@@ -52,7 +52,7 @@ export const calendarRouter = createTRPCRouter({
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
error: "Integration request failed",
|
||||
error: PUBLIC_INTEGRATION_ERROR,
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
|
||||
|
||||
export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
export const CUSTOM_API_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
export function validateCustomApiUrl(urlString: string): URL {
|
||||
if (urlString.length > 2_048) throw new Error("Custom API URL is too long");
|
||||
@@ -19,3 +20,48 @@ export async function readBoundedJsonResponseAsync(
|
||||
): Promise<unknown> {
|
||||
return await readBoundedJsonResponse(response, maxBytes, "Custom API response");
|
||||
}
|
||||
|
||||
export async function consumeBoundedResponseAsync(
|
||||
response: Response,
|
||||
maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES,
|
||||
): Promise<void> {
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`Custom API response exceeds ${maxBytes} bytes`);
|
||||
}
|
||||
|
||||
if (!response.body) return;
|
||||
const reader = response.body.getReader();
|
||||
let byteLength = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
byteLength += value?.byteLength ?? 0;
|
||||
if (byteLength > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error(`Custom API response exceeds ${maxBytes} bytes`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function withCustomApiResponseAsync<TResult>(
|
||||
url: URL,
|
||||
init: Omit<RequestInit, "redirect" | "signal">,
|
||||
consumeResponseAsync: (response: Response) => Promise<TResult>,
|
||||
timeoutMs = CUSTOM_API_FETCH_TIMEOUT_MS,
|
||||
): Promise<TResult> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
...init,
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
return await consumeResponseAsync(response);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@homarr/request-handler/firewall";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
|
||||
@@ -35,7 +35,7 @@ const queryFirewall = <
|
||||
fallback: (integration, error) => ({
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
|
||||
summary: createFallback(),
|
||||
error: error instanceof Error ? error.message : "Integration request failed",
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
@@ -34,7 +34,7 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
indexers: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const mediaOrganizerRouter = createTRPCRouter({
|
||||
@@ -39,7 +39,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
|
||||
missingCount: 0,
|
||||
queued: [],
|
||||
queuedCount: 0,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
interface IntegrationQueryResult<TData> {
|
||||
@@ -47,7 +47,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
fallback: (integration) => ({
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
data: [],
|
||||
error: "Integration request failed",
|
||||
error: PUBLIC_INTEGRATION_ERROR,
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
@@ -104,7 +104,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
fallback: (integration) => ({
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
data: { stats: null, users: [] },
|
||||
error: "Integration request failed",
|
||||
error: PUBLIC_INTEGRATION_ERROR,
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
@@ -43,7 +43,7 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
integrationName: integration.name,
|
||||
integrationKind: integration.kind,
|
||||
sessions: [],
|
||||
error: "Integration request failed",
|
||||
error: PUBLIC_INTEGRATION_ERROR,
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { notificationsRequestHandler } from "@homarr/request-handler/notificatio
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
@@ -39,7 +39,7 @@ export const notificationsRouter = createTRPCRouter({
|
||||
updatedAt: new Date(0),
|
||||
},
|
||||
data: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
|
||||
const logger = createLogger({ module: "releasesRouter" });
|
||||
const PUBLIC_RELEASE_ERROR_MESSAGE: string = "Release provider request failed";
|
||||
|
||||
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
|
||||
if (!versionFilter) return undefined;
|
||||
@@ -100,19 +101,38 @@ export const releasesRouter = createTRPCRouter({
|
||||
})
|
||||
.getDataAsync();
|
||||
|
||||
const data = response.data.success
|
||||
? response.data
|
||||
: {
|
||||
success: false as const,
|
||||
error: { code: response.data.error.code, message: PUBLIC_RELEASE_ERROR_MESSAGE },
|
||||
};
|
||||
if (!response.data.success) {
|
||||
logger.warn("Release provider request failed", {
|
||||
repositoryId,
|
||||
provider: repository.provider,
|
||||
error: response.data.error,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: repositoryId,
|
||||
provider: repository.provider,
|
||||
timestamp: response.timestamp,
|
||||
...response.data,
|
||||
...data,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("Release provider request failed", {
|
||||
repositoryId,
|
||||
provider: repository.provider,
|
||||
error,
|
||||
});
|
||||
return {
|
||||
id: repositoryId,
|
||||
provider: repository.provider,
|
||||
timestamp: new Date(),
|
||||
success: false as const,
|
||||
error: { code: "unexpected" as const, message: String(error) },
|
||||
error: { code: "unexpected" as const, message: PUBLIC_RELEASE_ERROR_MESSAGE },
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const vpnRouter = createTRPCRouter({
|
||||
@@ -33,7 +33,7 @@ export const vpnRouter = createTRPCRouter({
|
||||
updatedAt: new Date(0),
|
||||
},
|
||||
summary: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,10 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
const logger = createLogger({ module: "settleIntegrations" });
|
||||
|
||||
export const PUBLIC_INTEGRATION_ERROR: string = "INTEGRATION_REQUEST_FAILED";
|
||||
|
||||
export const toPublicIntegrationError = (_error: unknown): string => PUBLIC_INTEGRATION_ERROR;
|
||||
|
||||
interface IntegrationLike {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { settleIntegrationQueries } from "../settle-integrations";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries, toPublicIntegrationError } from "../settle-integrations";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
logger: { warn: vi.fn() },
|
||||
@@ -22,6 +22,17 @@ const integrations = [
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("settleIntegrationQueries", () => {
|
||||
test("maps sensitive integration failures to a stable public code", () => {
|
||||
const error = new Error(
|
||||
"GET http://admin:password@nas.internal.local/private/path?token=secret returned\nprivate response body",
|
||||
);
|
||||
|
||||
const publicError = toPublicIntegrationError(error);
|
||||
|
||||
expect(publicError).toBe(PUBLIC_INTEGRATION_ERROR);
|
||||
expect(publicError).not.toMatch(/admin|password|nas\.internal|private|token|secret|response body/i);
|
||||
});
|
||||
|
||||
test("returns an empty result when no integrations are configured", async () => {
|
||||
await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
@@ -48,3 +48,38 @@ const absoluteUrlRegex = /^[a-z]+:(\/\/)?/;
|
||||
export const isAbsoluteUrl = (urlOrPath: string): boolean => {
|
||||
return absoluteUrlRegex.test(urlOrPath.toLowerCase());
|
||||
};
|
||||
|
||||
export const SAFE_NEW_TAB_REL = "noopener noreferrer";
|
||||
|
||||
interface SafeApplicationUrlOptions {
|
||||
baseUrl?: unknown;
|
||||
}
|
||||
|
||||
/** Returns an absolute, credential-free HTTP(S) URL suitable for application navigation. */
|
||||
export const getSafeApplicationUrl = (value: unknown, options: SafeApplicationUrlOptions = {}): string | undefined => {
|
||||
if (typeof value !== "string" || value.trim() === "") return undefined;
|
||||
|
||||
try {
|
||||
let baseUrl: string | undefined;
|
||||
if (options.baseUrl !== undefined) {
|
||||
const parsedBase = parseHttpUrl(options.baseUrl);
|
||||
if (!parsedBase) return undefined;
|
||||
parsedBase.search = "";
|
||||
parsedBase.hash = "";
|
||||
baseUrl = parsedBase.toString();
|
||||
}
|
||||
|
||||
return parseHttpUrl(value, baseUrl)?.toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const parseHttpUrl = (value: unknown, baseUrl?: string): URL | undefined => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const url = new URL(value, baseUrl);
|
||||
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "") {
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||
@@ -15,10 +16,18 @@ interface ChildrenActionItemProps {
|
||||
export const ChildrenActionItem = ({ childrenOptions, action, query, setChildrenOptions }: ChildrenActionItemProps) => {
|
||||
const interaction = action.useInteraction(childrenOptions.option, query);
|
||||
|
||||
const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined;
|
||||
const renderRoot =
|
||||
interaction.type === "link"
|
||||
interaction.type === "link" && safeHref
|
||||
? (props: Record<string, unknown>) => {
|
||||
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
|
||||
return (
|
||||
<Link
|
||||
href={safeHref}
|
||||
target={interaction.newTab ? "_blank" : undefined}
|
||||
rel={interaction.newTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Spotlight } from "@mantine/spotlight";
|
||||
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
|
||||
|
||||
import type { SearchGroup } from "../../../lib/group";
|
||||
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||
@@ -30,10 +31,18 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
||||
|
||||
const { key: _reactKey, ...optionProps } = option as unknown as { key?: unknown } & Record<string, unknown>;
|
||||
|
||||
const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined;
|
||||
const renderRoot =
|
||||
interaction.type === "link"
|
||||
interaction.type === "link" && safeHref
|
||||
? (props: Record<string, unknown>) => {
|
||||
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
|
||||
return (
|
||||
<Link
|
||||
href={safeHref}
|
||||
target={interaction.newTab ? "_blank" : undefined}
|
||||
rel={interaction.newTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -21,8 +21,9 @@ import { IconClick, IconListSearch } from "@tabler/icons-react";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { formatLocalizedCompactNumber } from "../common/locale";
|
||||
import type { OptionLocation } from "../options";
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
@@ -128,7 +129,7 @@ const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, in
|
||||
if (error) {
|
||||
return (
|
||||
<Alert title={tCommon("error")} color="red">
|
||||
{error.message}
|
||||
{tCommon("action.tryAgain")}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -187,6 +188,7 @@ interface LocationSearchTableRowProps {
|
||||
|
||||
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
|
||||
const t = useScopedI18n("widget.common.location.table");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const onSelect = useCallback(() => {
|
||||
onLocationSelect({
|
||||
name: city.name,
|
||||
@@ -196,8 +198,6 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
|
||||
closeModal();
|
||||
}, [city, onLocationSelect, closeModal]);
|
||||
|
||||
const formatter = Intl.NumberFormat("en", { notation: "compact" });
|
||||
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
@@ -215,7 +215,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{city.population ? (
|
||||
<Text style={{ whiteSpace: "nowrap" }}>{formatter.format(city.population)}</Text>
|
||||
<Text style={{ whiteSpace: "nowrap" }}>{formatLocalizedCompactNumber(city.population, locale)}</Text>
|
||||
) : (
|
||||
<Text c="gray"> {t("population.fallback")}</Text>
|
||||
)}
|
||||
|
||||
@@ -271,19 +271,19 @@ const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repos
|
||||
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
|
||||
const setSecret = clientApi.widget.secrets.setSecret.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
onError: (error) => {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: "Failed to save token",
|
||||
message: error.message,
|
||||
title: tRepository("tokens.label"),
|
||||
message: tRepository("tokens.notConfigured"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
onError: (error) => {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: "Failed to delete token",
|
||||
message: error.message,
|
||||
title: tRepository("tokens.label"),
|
||||
message: tRepository("tokens.notConfigured"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { supportsAdvancedFocus } from "./definition";
|
||||
import { widgetImports } from ".";
|
||||
|
||||
const compactOnlyWidgetKinds = [
|
||||
"app",
|
||||
"bazarr",
|
||||
"beszelSystemGrid",
|
||||
"beszelSystemStats",
|
||||
"beszelSystemTable",
|
||||
"coolify",
|
||||
"dnsHoleControls",
|
||||
"dnsHoleSummary",
|
||||
"iframe",
|
||||
"indexerManager",
|
||||
"mediaRequests-requestList",
|
||||
"mediaRequests-requestStats",
|
||||
"minecraftServerStatus",
|
||||
"networkControllerSummary",
|
||||
"notebook",
|
||||
"notifications",
|
||||
"paperlessNgx",
|
||||
"patchmon",
|
||||
"releases",
|
||||
"rssFeed",
|
||||
"stockPrice",
|
||||
"systemDisks",
|
||||
"video",
|
||||
"vpn",
|
||||
] as const;
|
||||
|
||||
describe("advanced focus support", () => {
|
||||
test("is reserved for widgets with a distinct advanced experience", () => {
|
||||
const actualCompactOnlyKinds = Object.entries(widgetImports)
|
||||
.filter(([, widgetImport]) => !supportsAdvancedFocus(widgetImport.definition))
|
||||
.map(([kind]) => kind)
|
||||
.toSorted();
|
||||
|
||||
expect(actualCompactOnlyKinds).toEqual([...compactOnlyWidgetKinds].toSorted());
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
@@ -181,10 +182,12 @@ const AnchorNoteWidgetContent = ({
|
||||
}: AnchorNoteWidgetContentProps) => {
|
||||
const t = useScopedI18n("widget.anchorNote");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
|
||||
const noteQuery = clientApi.widget.anchorNotes.getNote.useQuery({
|
||||
integrationId,
|
||||
noteId,
|
||||
});
|
||||
const note = getUsableWidgetQueryData(noteQuery);
|
||||
const { refetch } = noteQuery;
|
||||
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Fragment, Suspense } from "react";
|
||||
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconMinus } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
@@ -14,6 +14,9 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./app.module.css";
|
||||
import { PingDot } from "./ping/ping-dot";
|
||||
@@ -23,12 +26,12 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const { data: app } = clientApi.app.byId.useQuery({
|
||||
id: options.appId,
|
||||
});
|
||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
|
||||
const app = getUsableWidgetQueryData(appQuery);
|
||||
const href = getSafeApplicationUrl(app?.href);
|
||||
useRegisterSpotlightContextResults(
|
||||
`app-${app?.id ?? options.appId}`,
|
||||
app?.href
|
||||
app && href
|
||||
? [
|
||||
{
|
||||
id: app.id,
|
||||
@@ -37,99 +40,102 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
interaction() {
|
||||
return {
|
||||
type: "link",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: app.href!,
|
||||
href,
|
||||
newTab: options.openInNewTab,
|
||||
};
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[app, options.openInNewTab],
|
||||
[app, href, options.openInNewTab],
|
||||
);
|
||||
|
||||
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!app) return <WidgetEmptyState />;
|
||||
|
||||
const isTiny = height < 100 || width < 100;
|
||||
const isColumnLayout = options.layout.startsWith("column");
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
href={app.href ?? undefined}
|
||||
openInNewTab={options.openInNewTab}
|
||||
enabled={Boolean(app.href) && !isEditMode}
|
||||
>
|
||||
<Tooltip.Floating
|
||||
label={app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
position="right-start"
|
||||
multiline
|
||||
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
|
||||
styles={{ tooltip: { maxWidth: 300 } }}
|
||||
>
|
||||
<Flex
|
||||
p={isTiny ? 4 : "sm"}
|
||||
className={combineClasses("app-flex-wrapper", app.name, app.id, app.href && classes.appWithUrl)}
|
||||
h="100%"
|
||||
w="100%"
|
||||
direction={options.layout}
|
||||
justify="center"
|
||||
align="center"
|
||||
gap={isColumnLayout ? 0 : "sm"}
|
||||
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
|
||||
<Box h="100%" w="100%" pos="relative">
|
||||
<AppLink href={href} openInNewTab={options.openInNewTab} enabled={Boolean(href) && !isEditMode}>
|
||||
<Tooltip.Floating
|
||||
label={app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
position="right-start"
|
||||
multiline
|
||||
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
|
||||
styles={{ tooltip: { maxWidth: 300 } }}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{options.showTitle && (
|
||||
<Text
|
||||
className="app-title"
|
||||
fw={700}
|
||||
size={isTiny ? rem(8) : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
{options.descriptionDisplayMode === "normal" && (
|
||||
<Text
|
||||
className="app-description"
|
||||
size={isTiny ? rem(8) : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
c="dimmed"
|
||||
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
|
||||
>
|
||||
{app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={board.iconColor !== null}
|
||||
alt={app.name}
|
||||
className={combineClasses(classes.appIcon, "app-icon")}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
minWidth: "20%",
|
||||
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Tooltip.Floating>
|
||||
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
|
||||
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
|
||||
<PingIndicator appId={app.id} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</AppLink>
|
||||
<Flex
|
||||
p={isTiny ? 4 : "sm"}
|
||||
className={combineClasses("app-flex-wrapper", app.name, app.id, href && classes.appWithUrl)}
|
||||
h="100%"
|
||||
w="100%"
|
||||
direction={options.layout}
|
||||
justify="center"
|
||||
align="center"
|
||||
gap={isColumnLayout ? 0 : "sm"}
|
||||
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{options.showTitle && (
|
||||
<Text
|
||||
className="app-title"
|
||||
fw={700}
|
||||
size={isTiny ? "xs" : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
{options.descriptionDisplayMode === "normal" && !isTiny && (
|
||||
<Text
|
||||
className="app-description"
|
||||
size="sm"
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
c="dimmed"
|
||||
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
|
||||
>
|
||||
{app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={board.iconColor !== null}
|
||||
alt={app.name}
|
||||
className={combineClasses(classes.appIcon, "app-icon")}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
minWidth: "20%",
|
||||
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Tooltip.Floating>
|
||||
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
|
||||
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
|
||||
<PingIndicator appId={app.id} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</AppLink>
|
||||
{appQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={appQuery.error} label={t("widget.app.name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,7 +151,7 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
|
||||
component="a"
|
||||
href={href}
|
||||
target={openInNewTab ? "_blank" : undefined}
|
||||
rel="noreferrer"
|
||||
rel={openInNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
h="100%"
|
||||
w="100%"
|
||||
>
|
||||
|
||||
@@ -11,7 +11,9 @@ interface PingIndicatorProps {
|
||||
|
||||
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
|
||||
const t = useI18n();
|
||||
const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
|
||||
const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
|
||||
|
||||
if (error) return <PingDot icon={IconX} color="red" tooltip={t("common.error")} />;
|
||||
|
||||
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
|
||||
|
||||
|
||||
@@ -36,19 +36,14 @@ const iconSizeByWidth = [
|
||||
{ minWidth: 0, size: 16 },
|
||||
] as const;
|
||||
|
||||
export default function BazarrWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bazarr">) {
|
||||
export default function BazarrWidget({ integrationIds, options, width, height }: WidgetComponentProps<"bazarr">) {
|
||||
const t = useScopedI18n("widget.bazarr");
|
||||
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
|
||||
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
{ enabled: Boolean(integrationIds[0]) },
|
||||
);
|
||||
|
||||
if (error && !badges) throw error;
|
||||
if (!badges) return <WidgetEmptyState />;
|
||||
|
||||
const statValues = {
|
||||
@@ -59,10 +54,10 @@ export default function BazarrWidget({
|
||||
} as const;
|
||||
|
||||
const visibleStatKeys = Object.entries(statVisibilityByOption)
|
||||
.filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options])
|
||||
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||
.map(([, statKey]) => statKey);
|
||||
|
||||
const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced");
|
||||
const gridCols = getGridCols(width, height, visibleStatKeys.length);
|
||||
const iconSize = getIconSize(Math.min(width, height));
|
||||
|
||||
if (visibleStatKeys.length === 0) {
|
||||
@@ -100,14 +95,14 @@ export default function BazarrWidget({
|
||||
);
|
||||
}
|
||||
|
||||
export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number {
|
||||
export function getGridCols(width: number, height: number, itemCount: number): number {
|
||||
if (itemCount <= 1) return 1;
|
||||
if (isAdvanced && width >= 640) return Math.min(itemCount, 4);
|
||||
|
||||
const widthMatch = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
|
||||
const rowsAtWidth = Math.ceil(itemCount / widthMatch);
|
||||
const rowHeight = height / rowsAtWidth;
|
||||
return rowHeight >= 72 ? widthMatch : Math.min(itemCount, Math.max(widthMatch, 2));
|
||||
const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
|
||||
const maxColumnsByWidth = Math.max(1, Math.floor(width / 160));
|
||||
const maxRowsByHeight = Math.max(1, Math.floor(height / 72));
|
||||
const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight);
|
||||
return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit));
|
||||
}
|
||||
|
||||
export function getIconSize(width: number): number {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconBadgeCc,
|
||||
supportedIntegrations: ["bazarr"],
|
||||
integrationsRequired: true,
|
||||
|
||||
@@ -4,11 +4,11 @@ import { getGridCols, getIconSize } from "./component";
|
||||
|
||||
describe("Bazarr responsive layout", () => {
|
||||
test("uses two columns for a short widget so all stats remain visible", () => {
|
||||
expect(getGridCols(220, 100, 4)).toBe(2);
|
||||
expect(getGridCols(220, 100, 4)).toBe(1);
|
||||
});
|
||||
|
||||
test("uses four columns in a wide advanced surface", () => {
|
||||
expect(getGridCols(800, 400, 4, true)).toBe(4);
|
||||
expect(getGridCols(800, 400, 4)).toBe(2);
|
||||
});
|
||||
|
||||
test("sizes icons from the constrained dimension", () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
|
||||
const alertIconMap: Record<string, LucideIcon> = {
|
||||
CPU: Cpu,
|
||||
@@ -56,7 +57,9 @@ export default function BeszelAlertsWidget({
|
||||
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
|
||||
[integrationIds, options.showHistory, options.maxHistoryItems],
|
||||
);
|
||||
const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
|
||||
const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
|
||||
const results = getUsableWidgetQueryData(alertsQuery) ?? [];
|
||||
const { isPending } = alertsQuery;
|
||||
|
||||
const systemNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -86,8 +89,6 @@ export default function BeszelAlertsWidget({
|
||||
const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
|
||||
const showAlertDescriptions = displayMode === "advanced" || height >= 190;
|
||||
|
||||
if (alertsError) throw alertsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
|
||||
@@ -385,22 +386,17 @@ export default function BeszelSystemGridWidget({
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||
const t = useScopedI18n("widget.beszelSystemGrid");
|
||||
const board = useRequiredBoard();
|
||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||
|
||||
const {
|
||||
data: results = [],
|
||||
error: systemsError,
|
||||
isPending,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const results = getUsableWidgetQueryData(systemsQuery) ?? [];
|
||||
const { isPending } = systemsQuery;
|
||||
|
||||
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -425,20 +421,15 @@ export default function BeszelSystemGridWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const advanced = displayMode === "advanced";
|
||||
const cols = advanced
|
||||
? Math.min(filteredSystems.length, width >= 900 ? 2 : 1)
|
||||
: getColCount(width, height, filteredSystems.length);
|
||||
const cols = getColCount(width, height, filteredSystems.length);
|
||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||
const rawCellHeight = height / rows;
|
||||
const minimumCellHeight = advanced ? 320 : MIN_CELL_HEIGHT;
|
||||
const minimumCellHeight = MIN_CELL_HEIGHT;
|
||||
const scrollEnabled = rawCellHeight < minimumCellHeight;
|
||||
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
|
||||
const cellWidth = width / cols;
|
||||
const size = getSizeConfig(cellWidth, effectiveCellHeight);
|
||||
const maxMetrics = advanced
|
||||
? Math.min(metricRenderers.length, getMaxVisibleMetrics(effectiveCellHeight, size))
|
||||
: getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
|
||||
@@ -14,9 +14,10 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { setWidgetRuntimeQueries } from "../definition";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||
|
||||
@@ -25,12 +26,10 @@ export default function BeszelSystemStatsWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
boardId,
|
||||
itemId,
|
||||
setOptions,
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"beszelSystemStats">) {
|
||||
const t = useScopedI18n("widget.beszelSystemStats");
|
||||
const board = useOptionalBoard();
|
||||
@@ -45,22 +44,26 @@ export default function BeszelSystemStatsWidget({
|
||||
message: t("error.selectionSaveMessage"),
|
||||
}),
|
||||
});
|
||||
const {
|
||||
data: systemsResult = [],
|
||||
isPending: systemsPending,
|
||||
error: systemsError,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? [];
|
||||
const { isPending: systemsPending } = systemsQuery;
|
||||
|
||||
const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
|
||||
const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId);
|
||||
const selectedValue = selectedSystem?.value ?? "";
|
||||
const selectedLabel = selectedSystem?.label;
|
||||
const systemExists = selectedSystem !== undefined;
|
||||
const includeDocker =
|
||||
(displayMode === "advanced" || height >= 380) &&
|
||||
(options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork);
|
||||
setWidgetRuntimeQueries(
|
||||
widgetStateRef,
|
||||
const includeDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
|
||||
const periodOptions = [
|
||||
{ value: "1m", label: t("period.live") },
|
||||
{ value: "1h", label: t("period.oneHour") },
|
||||
{ value: "12h", label: t("period.twelveHours") },
|
||||
{ value: "24h", label: t("period.oneDay") },
|
||||
{ value: "1w", label: t("period.oneWeek") },
|
||||
{ value: "30d", label: t("period.thirtyDays") },
|
||||
];
|
||||
useWidgetRuntimeQueries(
|
||||
widgetRuntimeRef,
|
||||
selectedSystem && options.timePeriod !== "1m"
|
||||
? [
|
||||
{
|
||||
@@ -125,7 +128,6 @@ export default function BeszelSystemStatsWidget({
|
||||
[boardId, hasChangeAccess, itemId, options.timePeriod, saveItemOptions, setOptions],
|
||||
);
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
if (systemsPending)
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -210,36 +212,39 @@ export default function BeszelSystemStatsWidget({
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
{!isEditMode && displayMode === "advanced" && (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={options.timePeriod}
|
||||
onChange={handleTimePeriod}
|
||||
disabled={isSelectionSavePending}
|
||||
data={[
|
||||
{ value: "1m", label: t("period.live") },
|
||||
{ value: "1h", label: t("period.oneHour") },
|
||||
{ value: "12h", label: t("period.twelveHours") },
|
||||
{ value: "24h", label: t("period.oneDay") },
|
||||
{ value: "1w", label: t("period.oneWeek") },
|
||||
{ value: "30d", label: t("period.thirtyDays") },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{!isEditMode &&
|
||||
(width >= 560 ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
fullWidth
|
||||
value={options.timePeriod}
|
||||
onChange={handleTimePeriod}
|
||||
disabled={isSelectionSavePending}
|
||||
data={periodOptions}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
size="xs"
|
||||
value={options.timePeriod}
|
||||
onChange={(value) => value && handleTimePeriod(value)}
|
||||
disabled={isSelectionSavePending}
|
||||
data={periodOptions}
|
||||
/>
|
||||
))}
|
||||
<BeszelStatsView
|
||||
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
|
||||
systemId={selectedSystem?.systemId ?? ""}
|
||||
timePeriod={options.timePeriod as BeszelTimePeriod}
|
||||
columns={displayMode === "advanced" || width > 600 ? 2 : 1}
|
||||
columns={width > 600 ? 2 : 1}
|
||||
visibility={{
|
||||
cpu: options.showCpu,
|
||||
memory: options.showMemory,
|
||||
disk: options.showDisk,
|
||||
diskIO: options.showDiskIO && (displayMode === "advanced" || height >= 260),
|
||||
network: options.showNetwork && (displayMode === "advanced" || height >= 260),
|
||||
dockerCpu: options.showDockerCpu && (displayMode === "advanced" || height >= 380),
|
||||
dockerMemory: options.showDockerMemory && (displayMode === "advanced" || height >= 380),
|
||||
dockerNetwork: options.showDockerNetwork && (displayMode === "advanced" || height >= 380),
|
||||
diskIO: options.showDiskIO,
|
||||
network: options.showNetwork,
|
||||
dockerCpu: options.showDockerCpu,
|
||||
dockerMemory: options.showDockerMemory,
|
||||
dockerNetwork: options.showDockerNetwork,
|
||||
}}
|
||||
onSwitchToHistorical={() => handleTimePeriod("1h")}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ const timePeriodOptions = [
|
||||
];
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconChartAreaLine,
|
||||
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
|
||||
queryMatcher(query, scope) {
|
||||
|
||||
@@ -8,33 +8,38 @@ import dayjs from "dayjs";
|
||||
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
|
||||
import { formatLocalizedDate } from "../../common/locale";
|
||||
|
||||
export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
|
||||
|
||||
const timeFormats: Record<BeszelTimePeriod, string> = {
|
||||
"1m": "HH:mm:ss",
|
||||
"1h": "HH:mm",
|
||||
"12h": "HH:mm",
|
||||
"24h": "HH:mm",
|
||||
"1w": "MMM D",
|
||||
"30d": "MMM D",
|
||||
const timeFormatOptions: Record<BeszelTimePeriod, Intl.DateTimeFormatOptions> = {
|
||||
"1m": { hour: "numeric", minute: "2-digit", second: "2-digit" },
|
||||
"1h": { hour: "numeric", minute: "2-digit" },
|
||||
"12h": { hour: "numeric", minute: "2-digit" },
|
||||
"24h": { hour: "numeric", minute: "2-digit" },
|
||||
"1w": { month: "short", day: "numeric" },
|
||||
"30d": { month: "short", day: "numeric" },
|
||||
};
|
||||
|
||||
const periodDays: Partial<Record<BeszelTimePeriod, number>> = { "1w": 7, "30d": 30 };
|
||||
|
||||
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
|
||||
const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]);
|
||||
export const formatBeszelChartTimestamp = (timestamp: string, timePeriod: BeszelTimePeriod, locale: string): string =>
|
||||
formatLocalizedDate(timestamp, locale, timeFormatOptions[timePeriod]);
|
||||
|
||||
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod, locale: string) {
|
||||
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
|
||||
if (timePeriod === "1m") {
|
||||
return { fmt, ordered: records };
|
||||
}
|
||||
return { fmt, ordered: [...records].toReversed() };
|
||||
}
|
||||
|
||||
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
|
||||
if (timePeriod === "1m") return padLiveTimeGrid(data);
|
||||
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod, locale: string) {
|
||||
if (timePeriod === "1m") return padLiveTimeGrid(data, 60, locale);
|
||||
const days = periodDays[timePeriod];
|
||||
if (!days) return data;
|
||||
|
||||
const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]);
|
||||
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
|
||||
const now = dayjs();
|
||||
const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD")));
|
||||
const result = [...data];
|
||||
@@ -51,7 +56,7 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
|
||||
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
|
||||
}
|
||||
|
||||
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
|
||||
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60, locale = "en-US") {
|
||||
if (data.length === 0) return data;
|
||||
|
||||
const end = dayjs(
|
||||
@@ -76,7 +81,7 @@ export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60
|
||||
const timestamp = end.subtract(pointCount - index - 1, "second");
|
||||
return (
|
||||
pointsBySecond.get(timestamp.valueOf()) ?? {
|
||||
time: timestamp.format(timeFormats["1m"]),
|
||||
time: formatBeszelChartTimestamp(timestamp.toISOString(), "1m", locale),
|
||||
rawTime: timestamp.toISOString(),
|
||||
}
|
||||
);
|
||||
@@ -162,17 +167,18 @@ export const useSystemChartData = (
|
||||
systemStats: BeszelSystemStatsRecord[] | undefined,
|
||||
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!systemStats) return [];
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
|
||||
const mapped = ordered.map((r) => ({
|
||||
time: fmt(r.created),
|
||||
rawTime: r.created,
|
||||
...mapFn(r.stats),
|
||||
}));
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
}, [systemStats, mapFn, timePeriod]);
|
||||
return padTimeGrid(mapped, timePeriod, locale);
|
||||
}, [systemStats, mapFn, timePeriod, locale]);
|
||||
|
||||
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
|
||||
const prevRef = useRef<string[]>([]);
|
||||
@@ -212,10 +218,11 @@ export const useDiskChartData = (
|
||||
efsPaths: string[],
|
||||
rootSeriesName: string,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) =>
|
||||
useMemo(
|
||||
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
|
||||
[systemStats, efsPaths, rootSeriesName, timePeriod],
|
||||
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod, locale),
|
||||
[systemStats, efsPaths, rootSeriesName, timePeriod, locale],
|
||||
);
|
||||
|
||||
export const buildDiskChartData = (
|
||||
@@ -223,9 +230,10 @@ export const buildDiskChartData = (
|
||||
efsPaths: string[],
|
||||
rootSeriesName: string,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) => {
|
||||
if (!systemStats?.length) return [];
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
|
||||
const mapped = ordered.map((record) => {
|
||||
const point: Record<string, unknown> = {
|
||||
time: fmt(record.created),
|
||||
@@ -238,7 +246,7 @@ export const buildDiskChartData = (
|
||||
}
|
||||
return point;
|
||||
});
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
return padTimeGrid(mapped, timePeriod, locale);
|
||||
};
|
||||
|
||||
export const useDockerChartData = (
|
||||
@@ -246,12 +254,13 @@ export const useDockerChartData = (
|
||||
containerNames: string[],
|
||||
metric: "cpu" | "memory" | "network",
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!containerStats?.length) return [];
|
||||
const extract = defaultContainerExtractors[metric];
|
||||
if (!extract) return [];
|
||||
const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
|
||||
const { fmt, ordered } = prepareRecords(containerStats, timePeriod, locale);
|
||||
const mapped = ordered.map((record) => {
|
||||
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
|
||||
const byName = new Map(record.stats.map((c) => [c.n, c]));
|
||||
@@ -260,5 +269,5 @@ export const useDockerChartData = (
|
||||
}
|
||||
return point;
|
||||
});
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
}, [containerStats, containerNames, metric, timePeriod]);
|
||||
return padTimeGrid(mapped, timePeriod, locale);
|
||||
}, [containerStats, containerNames, metric, timePeriod, locale]);
|
||||
@@ -6,8 +6,9 @@ import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { containerColors } from "./colors";
|
||||
import type { BeszelTimePeriod } from "./chart";
|
||||
import {
|
||||
@@ -71,13 +72,15 @@ export function BeszelStatsView({
|
||||
onSwitchToHistorical,
|
||||
}: BeszelStatsViewProps) {
|
||||
const t = useScopedI18n("widget.beszelSystemStats");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
|
||||
const isLive = timePeriod === "1m";
|
||||
|
||||
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
|
||||
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
|
||||
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
|
||||
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
|
||||
);
|
||||
const historicalData = isLive ? historicalQuery.data : getUsableWidgetQueryData(historicalQuery);
|
||||
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
|
||||
|
||||
const activeStats:
|
||||
@@ -125,10 +128,25 @@ export function BeszelStatsView({
|
||||
|
||||
const systemStats = activeStats?.systemStats;
|
||||
const containerStats = activeStats?.containerStats;
|
||||
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
|
||||
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
|
||||
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
|
||||
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
|
||||
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod, locale);
|
||||
const memoryData = useSystemChartData(
|
||||
whenVisible(visibility.memory, systemStats),
|
||||
mappers.memory,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const diskIOData = useSystemChartData(
|
||||
whenVisible(visibility.diskIO, systemStats),
|
||||
mappers.diskIO,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const networkData = useSystemChartData(
|
||||
whenVisible(visibility.network, systemStats),
|
||||
mappers.network,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
|
||||
const efsPaths = useMemo(() => {
|
||||
const paths = new Set<string>();
|
||||
@@ -138,7 +156,13 @@ export function BeszelStatsView({
|
||||
return [...paths];
|
||||
}, [systemStats]);
|
||||
const rootSeriesName = t("chart.disk.series");
|
||||
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
|
||||
const diskData = useDiskChartData(
|
||||
whenVisible(visibility.disk, systemStats),
|
||||
efsPaths,
|
||||
rootSeriesName,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const diskSeries = useMemo(
|
||||
() => [
|
||||
{ name: rootSeriesName, color: "grape.6" },
|
||||
@@ -161,22 +185,23 @@ export function BeszelStatsView({
|
||||
containerNames,
|
||||
"cpu",
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const dockerMemoryData = useDockerChartData(
|
||||
whenVisible(visibility.dockerMemory, containerStats),
|
||||
containerNames,
|
||||
"memory",
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const dockerNetworkData = useDockerChartData(
|
||||
whenVisible(visibility.dockerNetwork, containerStats),
|
||||
containerNames,
|
||||
"network",
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
|
||||
if (historicalError) throw historicalError;
|
||||
|
||||
if (isLive && liveError) {
|
||||
return (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
@@ -198,7 +223,7 @@ export function BeszelStatsView({
|
||||
if (!activeStats) {
|
||||
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
|
||||
<SimpleGrid cols={columns} spacing="md" aria-label={t("name")}>
|
||||
{Array.from({ length: visibleChartCount }, (_, index) => (
|
||||
<ChartSkeleton key={index} />
|
||||
))}
|
||||
@@ -212,7 +237,7 @@ export function BeszelStatsView({
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={24} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{activeStats.error}
|
||||
{t("error.internalServerError")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
@@ -22,6 +22,8 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import classes from "./bookmark.module.css";
|
||||
|
||||
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
|
||||
@@ -64,30 +66,36 @@ export default function BookmarksWidget({
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bookmarks">) {
|
||||
const board = useRequiredBoard();
|
||||
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
|
||||
select(apps) {
|
||||
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
},
|
||||
});
|
||||
const data =
|
||||
getUsableWidgetQueryData(
|
||||
clientApi.app.byIds.useQuery(options.items, {
|
||||
select(apps) {
|
||||
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
},
|
||||
}),
|
||||
) ?? [];
|
||||
|
||||
useRegisterSpotlightContextResults(
|
||||
`bookmark-${itemId}`,
|
||||
data
|
||||
.filter((app) => app.href !== null)
|
||||
.map((app) => ({
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
icon: app.iconUrl,
|
||||
interaction() {
|
||||
return {
|
||||
type: "link",
|
||||
// We checked above that app.href is defined
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: app.href!,
|
||||
newTab: false,
|
||||
};
|
||||
},
|
||||
})),
|
||||
data.flatMap((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return href
|
||||
? [
|
||||
{
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
icon: app.iconUrl,
|
||||
interaction() {
|
||||
return {
|
||||
type: "link",
|
||||
href,
|
||||
newTab: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
@@ -174,47 +182,50 @@ const FlexLayout = ({
|
||||
const board = useRequiredBoard();
|
||||
return (
|
||||
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
|
||||
{data.map((app) => (
|
||||
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
key={app.id}
|
||||
w="100%"
|
||||
>
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
className={classes.card}
|
||||
{data.map((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return (
|
||||
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
key={app.id}
|
||||
w="100%"
|
||||
display="flex"
|
||||
p={4}
|
||||
h="100%"
|
||||
withBorder={withBorder}
|
||||
>
|
||||
{direction === "row" ? (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
))}
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
className={classes.card}
|
||||
w="100%"
|
||||
display="flex"
|
||||
p={4}
|
||||
h="100%"
|
||||
withBorder={withBorder}
|
||||
>
|
||||
{direction === "row" ? (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -231,12 +242,8 @@ export const getAdvancedBookmarkColumns = (width: number, itemCount: number): nu
|
||||
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
|
||||
|
||||
export const getBookmarkHostname = (href: string | null): string | undefined => {
|
||||
if (!href) return undefined;
|
||||
try {
|
||||
return new URL(href).hostname;
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
const safeHref = getSafeApplicationUrl(href);
|
||||
return safeHref ? new URL(safeHref).hostname : undefined;
|
||||
};
|
||||
|
||||
const AdvancedBookmarksLayout = ({
|
||||
@@ -251,41 +258,44 @@ const AdvancedBookmarksLayout = ({
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
|
||||
{data.map((app) => (
|
||||
<UnstyledButton
|
||||
key={app.id}
|
||||
className={classes.bookmarkButton}
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
|
||||
<Group align="flex-start" wrap="nowrap">
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
alt={app.name}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{ width: 40, height: 40, flex: "0 0 auto" }}
|
||||
/>
|
||||
<Stack gap={3} miw={0}>
|
||||
<Text fw={700} size="sm" truncate="end">
|
||||
{app.name}
|
||||
</Text>
|
||||
<Anchor component="span" size="xs" truncate="end">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
{app.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{app.description}
|
||||
{data.map((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={app.id}
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
>
|
||||
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
|
||||
<Group align="flex-start" wrap="nowrap">
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
alt={app.name}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{ width: 40, height: 40, flex: "0 0 auto" }}
|
||||
/>
|
||||
<Stack gap={3} miw={0}>
|
||||
<Text fw={700} size="sm" truncate="end">
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
<Anchor component="span" size="xs" truncate="end">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
{app.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{app.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
@@ -327,43 +337,46 @@ const GridLayout = ({
|
||||
mih="100%"
|
||||
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
|
||||
>
|
||||
{data.map((app) => (
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
key={app.id}
|
||||
h="100%"
|
||||
>
|
||||
<Card
|
||||
{data.map((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
key={app.id}
|
||||
h="100%"
|
||||
className={combineClasses(classes.card, classes["card-grid"])}
|
||||
radius={board.itemRadius}
|
||||
withBorder={withBorder}
|
||||
p="xs"
|
||||
>
|
||||
{itemDirection === "horizontal" ? (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
<Card
|
||||
h="100%"
|
||||
className={combineClasses(classes.card, classes["card-grid"])}
|
||||
radius={board.itemRadius}
|
||||
withBorder={withBorder}
|
||||
p="xs"
|
||||
>
|
||||
{itemDirection === "horizontal" ? (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("bookmark display helpers", () => {
|
||||
|
||||
test("does not crash on malformed bookmark URLs", () => {
|
||||
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
|
||||
expect(getBookmarkHostname("not a URL")).toBe("not a URL");
|
||||
expect(getBookmarkHostname("not a URL")).toBeUndefined();
|
||||
expect(getBookmarkHostname(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,11 @@ import dayjs from "dayjs";
|
||||
|
||||
import { isNullOrWhitespace } from "@homarr/common";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { groupEventsByDate } from "./calendar-events";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { formatLocalizedTime } from "../common/locale";
|
||||
import classes from "./calendar-event-list.module.css";
|
||||
|
||||
interface CalendarEventListProps {
|
||||
@@ -36,7 +38,9 @@ export const CalendarEventList = ({
|
||||
locale,
|
||||
}: CalendarEventListProps) => {
|
||||
const headingIdPrefix = useId();
|
||||
const dateFormatter = new Intl.DateTimeFormat(locale, {
|
||||
const currentLocale = useCurrentIntlLocale();
|
||||
const effectiveLocale = locale ?? currentLocale;
|
||||
const dateFormatter = new Intl.DateTimeFormat(effectiveLocale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
@@ -66,20 +70,20 @@ export const CalendarEventList = ({
|
||||
{dateFormatter.format(groupedEvents[0]?.startDate)}
|
||||
</Text>
|
||||
<Stack mt="xs">
|
||||
<CalendarEventRows events={groupedEvents} />
|
||||
<CalendarEventRows events={groupedEvents} locale={effectiveLocale} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CalendarEventRows events={events} />
|
||||
<CalendarEventRows events={events} locale={effectiveLocale} />
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) => {
|
||||
const CalendarEventRows = ({ events, locale }: Pick<CalendarEventListProps, "events"> & { locale: string }) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const t = useI18n();
|
||||
return (
|
||||
@@ -136,14 +140,14 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
|
||||
) : (
|
||||
<>
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.startDate).format("HH:mm")}
|
||||
{formatLocalizedTime(event.startDate, locale)}
|
||||
</Text>
|
||||
|
||||
{event.endDate !== null && (
|
||||
<>
|
||||
-{" "}
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.endDate).format("HH:mm")}
|
||||
{formatLocalizedTime(event.endDate, locale)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -171,31 +175,35 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
|
||||
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
|
||||
{event.links
|
||||
.filter((link) => link.href)
|
||||
.map((link) => (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={"a"}
|
||||
href={link.href.toString()}
|
||||
target={"_blank"}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: link.color,
|
||||
color: link.isDark && colorScheme === "dark" ? "white" : "black",
|
||||
"&:hover": link.color
|
||||
? {
|
||||
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
.map((link) => {
|
||||
const href = getSafeApplicationUrl(link.href.toString());
|
||||
return (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: link.color,
|
||||
color: link.isDark && colorScheme === "dark" ? "white" : "black",
|
||||
"&:hover": link.color
|
||||
? {
|
||||
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getSafeApplicationUrl } from "./application-url";
|
||||
|
||||
describe("getSafeApplicationUrl", () => {
|
||||
test.each(["javascript:alert(1)", "data:text/html,test", "not a URL", "/relative", "./relative"])(
|
||||
"rejects unsafe or non-absolute URL %s",
|
||||
(value) => expect(getSafeApplicationUrl(value)).toBeUndefined(),
|
||||
);
|
||||
|
||||
test("rejects credential-bearing URLs", () => {
|
||||
expect(getSafeApplicationUrl("https://user:password@example.com/path")).toBeUndefined();
|
||||
expect(getSafeApplicationUrl("/path", { baseUrl: "https://user:password@example.com" })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows absolute HTTP and HTTPS URLs", () => {
|
||||
expect(getSafeApplicationUrl("http://example.com/path")).toBe("http://example.com/path");
|
||||
expect(getSafeApplicationUrl("https://example.com/path?q=1#result")).toBe("https://example.com/path?q=1#result");
|
||||
});
|
||||
|
||||
test("resolves feed-relative URLs only against a safe HTTP(S) base", () => {
|
||||
expect(getSafeApplicationUrl("/story", { baseUrl: "https://example.com/feed.xml?token=secret" })).toBe(
|
||||
"https://example.com/story",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([undefined, null, ""])("rejects missing URL %s", (value) => {
|
||||
expect(getSafeApplicationUrl(value)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { formatLocalizedCompactNumber, formatLocalizedDate, formatLocalizedTime, getBinaryStatusKey } from "./locale";
|
||||
|
||||
const sample = new Date("2026-01-02T13:05:09.000Z");
|
||||
|
||||
describe("localized widget formatting", () => {
|
||||
test.each([
|
||||
["en-US", /Jan/],
|
||||
["fr-FR", /janv/i],
|
||||
["de-DE", /Jan/],
|
||||
["he-IL", /[\u0590-\u05ff]/],
|
||||
])("formats dates for %s", (locale, expected) => {
|
||||
expect(
|
||||
formatLocalizedDate(sample, locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }),
|
||||
).toMatch(expected);
|
||||
});
|
||||
|
||||
test("supports explicit 12-hour and 24-hour clocks", () => {
|
||||
expect(formatLocalizedTime(sample, "en-US", { hour12: true, timeZone: "UTC" })).toBe("1:05 PM");
|
||||
expect(formatLocalizedTime(sample, "en-US", { hour12: false, timeZone: "UTC" })).toBe("13:05");
|
||||
});
|
||||
|
||||
test("localizes compact numbers", () => {
|
||||
expect(formatLocalizedCompactNumber(1_234_567, "en-US")).toBe("1.2M");
|
||||
expect(formatLocalizedCompactNumber(1_234_567, "fr-FR")).toContain("1,2");
|
||||
expect(formatLocalizedCompactNumber(1_234_567, "de-DE")).toContain("Mio.");
|
||||
});
|
||||
|
||||
test("normalizes binary status for translated accessibility labels", () => {
|
||||
expect(getBinaryStatusKey("enabled")).toBe("enabled");
|
||||
expect(getBinaryStatusKey("disabled")).toBe("disabled");
|
||||
expect(getBinaryStatusKey()).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
export type LocalizedDateValue = Date | number | string;
|
||||
|
||||
interface LocalizedTimeOptions {
|
||||
hour12?: boolean;
|
||||
includeSeconds?: boolean;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
const toValidDate = (value: LocalizedDateValue): Date | undefined => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
};
|
||||
|
||||
export const formatLocalizedDate = (
|
||||
value: LocalizedDateValue,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
fallback = "?",
|
||||
): string => {
|
||||
const date = toValidDate(value);
|
||||
return date ? new Intl.DateTimeFormat(locale, options).format(date) : fallback;
|
||||
};
|
||||
|
||||
export const formatLocalizedTime = (
|
||||
value: LocalizedDateValue,
|
||||
locale: string,
|
||||
{ hour12, includeSeconds = false, timeZone }: LocalizedTimeOptions = {},
|
||||
fallback = "?",
|
||||
): string =>
|
||||
formatLocalizedDate(
|
||||
value,
|
||||
locale,
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: includeSeconds ? "2-digit" : undefined,
|
||||
hour12,
|
||||
timeZone,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
|
||||
export const formatLocalizedDateTime = (
|
||||
value: LocalizedDateValue,
|
||||
locale: string,
|
||||
options: Pick<LocalizedTimeOptions, "hour12" | "timeZone"> = {},
|
||||
fallback = "?",
|
||||
): string =>
|
||||
formatLocalizedDate(
|
||||
value,
|
||||
locale,
|
||||
{ dateStyle: "medium", timeStyle: "short", hour12: options.hour12, timeZone: options.timeZone },
|
||||
fallback,
|
||||
);
|
||||
|
||||
export const formatLocalizedCompactNumber = (value: number, locale: string): string =>
|
||||
new Intl.NumberFormat(locale, { notation: "compact" }).format(value);
|
||||
|
||||
export type BinaryStatusKey = "enabled" | "disabled" | "unknown";
|
||||
|
||||
export const getBinaryStatusKey = (status?: "enabled" | "disabled"): BinaryStatusKey => status ?? "unknown";
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { WidgetQueryErrorIndicator } from "./query-state-indicator";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
|
||||
describe("WidgetQueryErrorIndicator", () => {
|
||||
let host: HTMLDivElement | undefined;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root?.unmount());
|
||||
host?.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("uses a localized label without exposing the raw failure", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("matchMedia", matchMedia);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
const secret = "http://admin:password@private.local/path?token=secret";
|
||||
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[aria-label='Weather']")).not.toBeNull();
|
||||
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Center, Loader, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
interface WidgetQueryErrorIndicatorProps {
|
||||
error: unknown;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
|
||||
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
|
||||
if (!error) return null;
|
||||
|
||||
const accessibleLabel = label;
|
||||
return (
|
||||
<Tooltip label={accessibleLabel} position="left" withArrow>
|
||||
<Box
|
||||
component="span"
|
||||
c="orange"
|
||||
role="img"
|
||||
aria-label={accessibleLabel}
|
||||
tabIndex={0}
|
||||
style={{ display: "inline-flex", cursor: "help" }}
|
||||
>
|
||||
<IconAlertTriangle aria-hidden size={14} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetQueryLoadingState() {
|
||||
return (
|
||||
<Center h="100%" w="100%" p="sm" role="status" aria-live="polite">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getUsableWidgetQueryData, hasStaleWidgetQueryError, isInitialWidgetQueryPending } from "./query-state";
|
||||
|
||||
describe("getUsableWidgetQueryData", () => {
|
||||
test.each([[], null, 0, false, ""])("preserves successful empty value %#", (data) => {
|
||||
expect(getUsableWidgetQueryData({ data, error: null })).toBe(data);
|
||||
});
|
||||
|
||||
test("preserves stale cached data when a refetch fails", () => {
|
||||
const data = [{ id: "healthy" }];
|
||||
expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data);
|
||||
});
|
||||
|
||||
test("throws a terminal failure when no usable data exists", () => {
|
||||
const error = new Error("offline");
|
||||
expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error);
|
||||
});
|
||||
|
||||
test("keeps the initial pending state when neither data nor an error exists", () => {
|
||||
expect(getUsableWidgetQueryData({ data: undefined, error: null })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInitialWidgetQueryPending", () => {
|
||||
test("only marks the unresolved first request as initial pending", () => {
|
||||
expect(isInitialWidgetQueryPending({ data: undefined, error: null, isPending: true })).toBe(true);
|
||||
expect(isInitialWidgetQueryPending({ data: [], error: null, isPending: true })).toBe(false);
|
||||
expect(isInitialWidgetQueryPending({ data: undefined, error: new Error("offline"), isPending: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasStaleWidgetQueryError", () => {
|
||||
test("only marks failures that retain usable cached data", () => {
|
||||
expect(hasStaleWidgetQueryError({ data: [{ id: "cached" }], error: new Error("offline") })).toBe(true);
|
||||
expect(hasStaleWidgetQueryError({ data: undefined, error: new Error("offline") })).toBe(false);
|
||||
expect(hasStaleWidgetQueryError({ data: [], error: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
interface WidgetQueryState {
|
||||
data: unknown;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
interface PendingWidgetQueryState extends WidgetQueryState {
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
/** Throws terminal query failures while preserving successful empty values and stale cached data. */
|
||||
export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => {
|
||||
if (query.error && query.data === undefined) throw query.error;
|
||||
return query.data;
|
||||
};
|
||||
|
||||
/** True only before the first result; successful empty values and cached data are both ready to render. */
|
||||
export const isInitialWidgetQueryPending = ({ data, error, isPending }: PendingWidgetQueryState): boolean =>
|
||||
isPending && data === undefined && !error;
|
||||
|
||||
/** A failed background refresh still has usable cached data, but should remain visible to the user. */
|
||||
export const hasStaleWidgetQueryError = ({ data, error }: WidgetQueryState): boolean =>
|
||||
error !== null && error !== undefined && data !== undefined;
|
||||
@@ -6,17 +6,12 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { createWidgetKey } from "./coolify-utils";
|
||||
import { InstanceCard } from "./instance-card";
|
||||
import { SingleInstanceLayout } from "./single-instance-layout";
|
||||
|
||||
export default function CoolifyWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"coolify">) {
|
||||
export default function CoolifyWidget({ options, integrationIds, width, height }: WidgetComponentProps<"coolify">) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
|
||||
if (integrationIds.length === 0) {
|
||||
@@ -27,15 +22,7 @@ export default function CoolifyWidget({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CoolifyContent
|
||||
integrationIds={integrationIds}
|
||||
options={options}
|
||||
width={width}
|
||||
height={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
/>
|
||||
);
|
||||
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} height={height} />;
|
||||
}
|
||||
|
||||
interface CoolifyContentProps {
|
||||
@@ -43,17 +30,18 @@ interface CoolifyContentProps {
|
||||
options: WidgetComponentProps<"coolify">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
|
||||
function CoolifyContent({ integrationIds, options, width, height }: CoolifyContentProps) {
|
||||
const t = useScopedI18n("common");
|
||||
const { data: instancesData = [], isPending } = clientApi.widget.coolify.getInstancesInfo.useQuery({
|
||||
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const instancesData = getUsableWidgetQueryData(instancesQuery) ?? [];
|
||||
const { isPending } = instancesQuery;
|
||||
|
||||
const isTiny = !isAdvanced && (width < 256 || height < 144);
|
||||
const hideFooter = !isAdvanced && height < 112;
|
||||
const isTiny = width < 256 || height < 144;
|
||||
const hideFooter = height < 112;
|
||||
const [firstInstance] = instancesData;
|
||||
const widgetKey = createWidgetKey(integrationIds);
|
||||
|
||||
@@ -74,7 +62,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
|
||||
options={options}
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
isAdvanced={isAdvanced}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
);
|
||||
@@ -82,7 +69,7 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={isAdvanced && width >= 760 ? 2 : 1} spacing="sm" p="xs">
|
||||
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
|
||||
{instancesData.map((instance) => (
|
||||
<InstanceCard
|
||||
key={instance.integrationId}
|
||||
@@ -90,7 +77,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
|
||||
options={options}
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
isAdvanced={isAdvanced}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconCloud,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
|
||||
|
||||
interface ResourceRowProps {
|
||||
@@ -59,13 +60,14 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
? t("status.restarting")
|
||||
: t("status.unknown");
|
||||
|
||||
const resourceUrl =
|
||||
const resourceUrl = getSafeApplicationUrl(
|
||||
item.projectUuid && item.environmentUuid
|
||||
? `${baseUrl}/project/${item.projectUuid}/environment/${item.environmentUuid}/${resourceType}/${item.uuid}`
|
||||
: undefined;
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const logsUrl = resourceUrl ? `${resourceUrl}/logs` : undefined;
|
||||
const publicUrl = cleanFqdn(item.fqdn);
|
||||
const logsUrl = getSafeApplicationUrl(resourceUrl ? `${resourceUrl}/logs` : undefined);
|
||||
const publicUrl = getSafeApplicationUrl(cleanFqdn(item.fqdn));
|
||||
const resourceTimestamp = getResourceTimestamp(item, resourceType);
|
||||
|
||||
return (
|
||||
@@ -77,7 +79,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
className={actionTargetClasses.root}
|
||||
href={resourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
fz="xs"
|
||||
c="inherit"
|
||||
truncate="end"
|
||||
@@ -98,7 +100,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
component="a"
|
||||
href={publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
aria-label={t("action.openResource", { name: item.name })}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
@@ -113,7 +115,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
component="a"
|
||||
href={logsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
aria-label={t("action.openLogs", { name: item.name })}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CoolifyServer } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getBadgeColor } from "./coolify-utils";
|
||||
|
||||
interface ServersSectionProps {
|
||||
@@ -92,7 +93,7 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const isBuildServer = server.settings?.is_build_server === true;
|
||||
const isOnline = server.is_reachable !== false;
|
||||
const serverUrl = `${baseUrl}/server/${server.uuid}`;
|
||||
const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`);
|
||||
const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled;
|
||||
|
||||
return (
|
||||
@@ -105,9 +106,10 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
|
||||
/>
|
||||
<Anchor
|
||||
className={actionTargetClasses.root}
|
||||
component={serverUrl ? "a" : "span"}
|
||||
href={serverUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target={serverUrl ? "_blank" : undefined}
|
||||
rel={serverUrl ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz="xs"
|
||||
c="inherit"
|
||||
truncate="end"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTimeAgo } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ApplicationsSection } from "./applications-section";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { buildServerResourceCounts } from "./coolify-utils";
|
||||
import { ServersSection } from "./servers-section";
|
||||
import { ServicesSection } from "./services-section";
|
||||
@@ -18,8 +19,8 @@ interface SingleInstanceLayoutProps {
|
||||
instance: InstanceData;
|
||||
options: CoolifyOptions;
|
||||
isTiny: boolean;
|
||||
widgetKey: string;
|
||||
isAdvanced: boolean;
|
||||
widgetKey: string;
|
||||
hideFooter: boolean;
|
||||
}
|
||||
|
||||
@@ -27,8 +28,8 @@ export function SingleInstanceLayout({
|
||||
instance,
|
||||
options,
|
||||
isTiny,
|
||||
widgetKey,
|
||||
isAdvanced,
|
||||
widgetKey,
|
||||
hideFooter,
|
||||
}: SingleInstanceLayoutProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
@@ -40,36 +41,15 @@ export function SingleInstanceLayout({
|
||||
key: `coolify-sections-${widgetKey}`,
|
||||
defaultValue: ["applications"],
|
||||
});
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
[
|
||||
options.showServers ? "servers" : null,
|
||||
options.showApplications ? "applications" : null,
|
||||
options.showServices ? "services" : null,
|
||||
].filter((section): section is string => section !== null),
|
||||
[options.showApplications, options.showServers, options.showServices],
|
||||
);
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
|
||||
const previousVisibleSectionsRef = useRef(visibleSections);
|
||||
|
||||
useEffect(() => {
|
||||
const newlyVisibleSections = visibleSections.filter(
|
||||
(section) => !previousVisibleSectionsRef.current.includes(section),
|
||||
);
|
||||
previousVisibleSectionsRef.current = visibleSections;
|
||||
if (newlyVisibleSections.length === 0) return;
|
||||
|
||||
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
|
||||
}, [visibleSections]);
|
||||
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
|
||||
const serverResourceCounts = buildServerResourceCounts(
|
||||
instance.instanceInfo.servers,
|
||||
instance.instanceInfo.applications,
|
||||
instance.instanceInfo.services,
|
||||
);
|
||||
|
||||
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
|
||||
const displayUrl = baseUrl.replace(/^https?:\/\//, "");
|
||||
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
|
||||
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
|
||||
const relativeTime = useTimeAgo(instance.updatedAt);
|
||||
|
||||
return (
|
||||
@@ -82,17 +62,25 @@ export function SingleInstanceLayout({
|
||||
oolify
|
||||
</Text>
|
||||
</Group>
|
||||
<Anchor
|
||||
href={baseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw={500}
|
||||
c="dimmed"
|
||||
lineClamp={1}
|
||||
>
|
||||
{displayUrl}
|
||||
</Anchor>
|
||||
<Stack gap={0} miw={0}>
|
||||
{isAdvanced && (
|
||||
<Text fz="xs" fw={600} truncate="end">
|
||||
{instance.integrationName}
|
||||
</Text>
|
||||
)}
|
||||
<Anchor
|
||||
component={baseUrl ? "a" : "span"}
|
||||
href={baseUrl}
|
||||
target={baseUrl ? "_blank" : undefined}
|
||||
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw={500}
|
||||
c="dimmed"
|
||||
lineClamp={1}
|
||||
>
|
||||
{isAdvanced ? t("source.url", { url: displayUrl }) : displayUrl}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
@@ -108,6 +96,7 @@ export function SingleInstanceLayout({
|
||||
serverResourceCounts={serverResourceCounts}
|
||||
baseUrl={baseUrl}
|
||||
isTiny={isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
showIp={showIp}
|
||||
onToggleIp={() => setShowIp((prev) => !prev)}
|
||||
/>
|
||||
@@ -116,7 +105,12 @@ export function SingleInstanceLayout({
|
||||
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
|
||||
)}
|
||||
{options.showServices && (
|
||||
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
|
||||
<ServicesSection
|
||||
services={instance.instanceInfo.services}
|
||||
baseUrl={baseUrl}
|
||||
isTiny={isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
/>
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -415,7 +415,6 @@ describe("CustomJsxDisplay", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Custom JSX component registry", () => {
|
||||
it.each([
|
||||
["@mantine/core", MantineCore],
|
||||
|
||||
@@ -77,7 +77,6 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
|
||||
</WidgetDefinitionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function sameStatus(left: CustomWidgetPublishedQueryState["status"], right: CustomWidgetPublishedQueryState["status"]) {
|
||||
return (
|
||||
left.loading === right.loading &&
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
|
||||
icon: IconApi,
|
||||
queryKey: [["widget", "customApi", "getData"]],
|
||||
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { definitionId: scope.options.definitionId }),
|
||||
contextActions({ options, widgetStateRef }) {
|
||||
return [
|
||||
{
|
||||
key: "togglePolling",
|
||||
label: (t) => t("widget.customApi.actions.togglePolling"),
|
||||
icon: IconPlayerPause,
|
||||
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
|
||||
disabled: typeof widgetStateRef?.current?.togglePolling !== "function",
|
||||
onClick: () => {
|
||||
const action = widgetStateRef?.current?.togglePolling;
|
||||
if (typeof action === "function") action();
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { definition as appDefinition } from "./app";
|
||||
import { definition as beszelAlertsDefinition } from "./beszel-alerts";
|
||||
import { definition as beszelSystemGridDefinition } from "./beszel-system-grid";
|
||||
import { definition as beszelSystemStatsDefinition } from "./beszel-system-stats";
|
||||
import { definition as beszelSystemTableDefinition } from "./beszel-system-table";
|
||||
import {
|
||||
getWidgetQueryKeys,
|
||||
getWidgetRuntimeQueries,
|
||||
setWidgetRuntimeQueries,
|
||||
supportsAdvancedFocus,
|
||||
} from "./definition";
|
||||
|
||||
const togglePolling = () => undefined;
|
||||
import { definition as clockDefinition } from "./clock";
|
||||
import { definition as customApiDefinition } from "./custom-api";
|
||||
import { getWidgetQueryKeys, supportsAdvancedFocus } from "./definition";
|
||||
import { definition as dockerDefinition } from "./docker";
|
||||
import { definition as downloadsDefinition } from "./downloads";
|
||||
import { definition as indexerManagerDefinition } from "./indexer-manager";
|
||||
import { definition as notebookDefinition } from "./notebook";
|
||||
|
||||
describe("getWidgetQueryKeys", () => {
|
||||
test("falls back to the widget router namespace", () => {
|
||||
@@ -29,23 +29,26 @@ describe("getWidgetQueryKeys", () => {
|
||||
|
||||
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
|
||||
});
|
||||
|
||||
test("registers runtime queries without replacing imperative widget actions", () => {
|
||||
const widgetStateRef = { current: { togglePolling } };
|
||||
const runtimeQueries = [{ path: ["widget", "calendar", "findAllEvents"], input: { month: 7 } }];
|
||||
|
||||
setWidgetRuntimeQueries(widgetStateRef, runtimeQueries);
|
||||
|
||||
expect(widgetStateRef.current.togglePolling).toBe(togglePolling);
|
||||
expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual(runtimeQueries);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsAdvancedFocus", () => {
|
||||
test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition])(
|
||||
"keeps non-enhanced widgets compact",
|
||||
(widgetDefinition) => {
|
||||
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
|
||||
},
|
||||
);
|
||||
test.each([
|
||||
appDefinition,
|
||||
beszelAlertsDefinition,
|
||||
beszelSystemGridDefinition,
|
||||
beszelSystemStatsDefinition,
|
||||
beszelSystemTableDefinition,
|
||||
clockDefinition,
|
||||
customApiDefinition,
|
||||
dockerDefinition,
|
||||
indexerManagerDefinition,
|
||||
notebookDefinition,
|
||||
])("keeps non-enhanced widgets compact", (widgetDefinition) => {
|
||||
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
|
||||
});
|
||||
|
||||
test("requires an explicit opt-in", () => {
|
||||
expect(supportsAdvancedFocus({})).toBe(false);
|
||||
expect(supportsAdvancedFocus(downloadsDefinition)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import actionTargetClasses from "../../common/action-target.module.css";
|
||||
import classes from "./component.module.css";
|
||||
import TimerModal from "./TimerModal";
|
||||
@@ -46,7 +47,6 @@ export default function DnsHoleControlsWidget({
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
const board = useRequiredBoard();
|
||||
// DnsHole integrations with interaction permissions
|
||||
@@ -54,9 +54,9 @@ export default function DnsHoleControlsWidget({
|
||||
.map(({ id }) => id)
|
||||
.filter((id) => integrationIds.includes(id));
|
||||
|
||||
const { data: summaries = [], isPending: isSummaryPending } = clientApi.widget.dnsHole.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
|
||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const { isPending: isSummaryPending } = summaryQuery;
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const {
|
||||
@@ -118,8 +118,7 @@ export default function DnsHoleControlsWidget({
|
||||
const [bulkFailureCount, setBulkFailureCount] = useState(0);
|
||||
const [opened, { close, open }] = useDisclosure(false);
|
||||
|
||||
const controlAllButtonsVisible =
|
||||
(options.showToggleAllButtons || displayMode === "advanced") && integrationsWithInteractions.length > 0;
|
||||
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
|
||||
const actionsPending = bulkPending || isEnabling || isDisabling;
|
||||
const actionError = enableError ?? disableError;
|
||||
const runBulkToggle = async (integrationIdsToToggle: string[]) => {
|
||||
@@ -229,7 +228,6 @@ export default function DnsHoleControlsWidget({
|
||||
hasIconColor={board.iconColor !== null}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
actionsPending={actionsPending}
|
||||
/>
|
||||
))}
|
||||
@@ -242,7 +240,7 @@ export default function DnsHoleControlsWidget({
|
||||
</Text>
|
||||
)}
|
||||
{actionError && (
|
||||
<Tooltip label={actionError.message} multiline>
|
||||
<Tooltip label={t("widget.dnsHoleControls.error.internalServerError")}>
|
||||
<Text size="xs" c="red" ta="center" lineClamp={2} tabIndex={0}>
|
||||
{t("widget.dnsHoleControls.error.internalServerError")}
|
||||
</Text>
|
||||
@@ -269,7 +267,6 @@ interface ControlsCardProps {
|
||||
hasIconColor: boolean;
|
||||
rootWidth: number;
|
||||
rootHeight: number;
|
||||
isAdvanced: boolean;
|
||||
actionsPending: boolean;
|
||||
}
|
||||
|
||||
@@ -283,7 +280,6 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
hasIconColor,
|
||||
rootWidth,
|
||||
rootHeight,
|
||||
isAdvanced,
|
||||
actionsPending,
|
||||
}) => {
|
||||
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
|
||||
@@ -294,7 +290,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
|
||||
const layout = !isAdvanced && (rootWidth < 256 || rootHeight < 112) ? "sm" : "md";
|
||||
const layout = rootWidth < 256 || rootHeight < 112 ? "sm" : "md";
|
||||
|
||||
return (
|
||||
<Indicator
|
||||
|
||||
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
|
||||
export const widgetKind = "dnsHoleControls";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconDeviceGamepad,
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchInterval: 5,
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Avatar, AvatarGroup, Badge, Card, Flex, Group, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
Badge,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
||||
|
||||
@@ -18,20 +30,25 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
|
||||
export default function DnsHoleSummaryWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
displayMode,
|
||||
width,
|
||||
height,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
const { data: summaries = [], isPending } = clientApi.widget.dnsHole.summary.useQuery({
|
||||
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const { isPending } = summaryQuery;
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||
const layoutProps = boxPropsByLayout(options.layout);
|
||||
const showSourceStatuses = summaries.length > 1 && width >= 240 && height >= 220;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -74,18 +91,21 @@ export default function DnsHoleSummaryWidget({
|
||||
</Stack>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
{displayMode === "advanced" && summaries.length > 0 && (
|
||||
<Group px="xs" pb="xs" gap="xs">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Badge
|
||||
key={integration.id}
|
||||
variant="light"
|
||||
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
|
||||
>
|
||||
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{showSourceStatuses && (
|
||||
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Badge
|
||||
key={integration.id}
|
||||
variant="light"
|
||||
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}` as never)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
@@ -174,7 +194,7 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
||||
className="summary-card"
|
||||
p="sm"
|
||||
radius={board.itemRadius}
|
||||
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
|
||||
bg={usePiHoleColors ? item.color : "var(--mantine-color-default-hover)"}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
|
||||
export const widgetKind = "dnsHoleSummary";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconAd,
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchInterval: 5,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { WidgetError } from "./component";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
|
||||
describe("WidgetError", () => {
|
||||
let host: HTMLDivElement | undefined;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root?.unmount());
|
||||
host?.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("never renders an unmapped backend error", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("matchMedia", matchMedia);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
const secret = "http://admin:password@private.local/path?token=secret";
|
||||
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(WidgetError, {
|
||||
kind: "app",
|
||||
error: new Error(secret),
|
||||
resetErrorBoundary: vi.fn(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("common.error");
|
||||
expect(host.textContent).not.toContain(secret);
|
||||
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,14 @@ export const WidgetError = ({ error, resetErrorBoundary, definition }: WidgetErr
|
||||
return <BaseWidgetError {...trpcErrorData} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
|
||||
return <BaseWidgetError icon={IconExclamationCircle} message={String(error)} onRetry={resetErrorBoundary} />;
|
||||
return (
|
||||
<BaseWidgetError
|
||||
icon={IconExclamationCircle}
|
||||
message={(t) => t("common.error")}
|
||||
showLogsLink
|
||||
onRetry={resetErrorBoundary}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const handleWidgetTrpcError = (
|
||||
@@ -60,7 +67,7 @@ const handleCommonTrpcError = (error: unknown): Omit<BaseWidgetErrorProps, "onRe
|
||||
if (errorData.code === "UNAUTHORIZED" || errorData.code === "FORBIDDEN") {
|
||||
return {
|
||||
icon: IconShield,
|
||||
message: "You don't have permission to access this widget",
|
||||
message: (t) => t("integration.testConnection.error.certificate.alert.permission.title"),
|
||||
showLogsLink: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[])
|
||||
queries.every((query) => !query.isPending && !query.isFetching) &&
|
||||
queries.every(
|
||||
(query) =>
|
||||
query.isError || (query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
|
||||
(query.isError && query.data === undefined) ||
|
||||
(query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
|
||||
);
|
||||
|
||||
export default function FirewallWidget({
|
||||
@@ -58,7 +59,7 @@ export default function FirewallWidget({
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isTiny = !isAdvanced && (width < 256 || height < 180);
|
||||
const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100;
|
||||
const showInterfaces = isAdvanced || height >= 120;
|
||||
const showInterfaces = isAdvanced || (!isTiny && height >= 120);
|
||||
const t = useI18n();
|
||||
|
||||
const handleSelect = useCallback((value: string | null) => {
|
||||
@@ -159,7 +160,6 @@ export default function FirewallWidget({
|
||||
interfacesError={interfacesQuery.isError || Boolean(interfaces?.error)}
|
||||
hasError={hasError}
|
||||
isAdvanced={isAdvanced}
|
||||
isTiny={isTiny}
|
||||
ringSize={ringSize}
|
||||
showInterfaces={showInterfaces}
|
||||
accordionValue={accordionValue}
|
||||
@@ -190,7 +190,6 @@ interface FirewallPanelProps {
|
||||
interfacesError: boolean;
|
||||
hasError: boolean;
|
||||
isAdvanced: boolean;
|
||||
isTiny: boolean;
|
||||
ringSize: number;
|
||||
showInterfaces: boolean;
|
||||
accordionValue: string | null;
|
||||
@@ -214,7 +213,6 @@ const FirewallPanel = ({
|
||||
interfacesError,
|
||||
hasError,
|
||||
isAdvanced,
|
||||
isTiny,
|
||||
ringSize,
|
||||
showInterfaces,
|
||||
accordionValue,
|
||||
@@ -276,7 +274,6 @@ const FirewallPanel = ({
|
||||
hasResult={interfacesLoaded}
|
||||
hasError={interfacesError}
|
||||
isAdvanced={isAdvanced}
|
||||
isTiny={isTiny}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
errorLabel={errorLabel}
|
||||
@@ -302,6 +299,8 @@ const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
|
||||
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
|
||||
const status = getMetricStatus(safeValue);
|
||||
const statusLabel = t(`widget.firewall.status.${status}`);
|
||||
const showIcon = size >= 64;
|
||||
const showStatus = size >= 96;
|
||||
|
||||
return (
|
||||
<RingProgress
|
||||
@@ -315,9 +314,9 @@ const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
|
||||
thickness={size < 72 ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text size={size < 72 ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
|
||||
<Icon size={size < 72 ? 8 : 16} />
|
||||
{size >= 72 && <Text size="8px">{statusLabel}</Text>}
|
||||
<Text size="xs">{safeValue.toFixed(1)}%</Text>
|
||||
{showIcon && <Icon size={size < 96 ? 12 : 16} />}
|
||||
{showStatus && <Text size="xs">{statusLabel}</Text>}
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
@@ -341,7 +340,6 @@ interface InterfacesPanelProps {
|
||||
hasResult: boolean;
|
||||
hasError: boolean;
|
||||
isAdvanced: boolean;
|
||||
isTiny: boolean;
|
||||
accordionValue: string | null;
|
||||
setAccordionValue: (value: string | null) => void;
|
||||
errorLabel: string;
|
||||
@@ -356,7 +354,6 @@ const InterfacesPanel = ({
|
||||
hasResult,
|
||||
hasError,
|
||||
isAdvanced,
|
||||
isTiny,
|
||||
accordionValue,
|
||||
setAccordionValue,
|
||||
errorLabel,
|
||||
@@ -374,9 +371,9 @@ const InterfacesPanel = ({
|
||||
variant={isAdvanced ? "contained" : "default"}
|
||||
>
|
||||
<Accordion.Item value="interfaces">
|
||||
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
|
||||
<Accordion.Control icon={<IconTopologyBus size={16} />}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
|
||||
<Text size="xs">{label}</Text>
|
||||
{hasError && (
|
||||
<Badge color="red" variant="light" size="xs">
|
||||
{errorBadgeLabel}
|
||||
@@ -387,19 +384,30 @@ const InterfacesPanel = ({
|
||||
<Accordion.Panel>
|
||||
<Stack gap={4}>
|
||||
{bandwidth.map(({ name, receive, transmit }) => (
|
||||
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
|
||||
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
|
||||
<Group key={name} gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Text
|
||||
size="xs"
|
||||
c="light-dark(var(--mantine-color-blue-8), var(--mantine-color-blue-3))"
|
||||
truncate="end"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
|
||||
<Text size={isTiny ? "8px" : "xs"} c="green.3">
|
||||
<IconArrowBarUp
|
||||
size={12}
|
||||
color="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))"
|
||||
/>
|
||||
<Text size="xs" c="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))">
|
||||
{formatBitsPerSec(transmit, 2)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
|
||||
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
|
||||
<IconArrowBarDown
|
||||
size={12}
|
||||
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
|
||||
/>
|
||||
<Text size="xs" c="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))">
|
||||
{formatBitsPerSec(receive, 2)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasTotalFirewallFailure } from "./component";
|
||||
import { hasFirewallPartialFailure, hasTotalFirewallFailure } from "./component";
|
||||
|
||||
const failedQuery = {
|
||||
isPending: false,
|
||||
@@ -27,4 +27,31 @@ describe("firewall failure state", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves stale cached data when a query-wide refetch fails", () => {
|
||||
const staleQuery = {
|
||||
...failedQuery,
|
||||
isError: true,
|
||||
data: [{ integration: { id: "firewall-1" }, error: undefined }],
|
||||
};
|
||||
|
||||
expect(hasTotalFirewallFailure([staleQuery, failedQuery, failedQuery, failedQuery])).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces query-wide and integration-specific partial failures", () => {
|
||||
const successfulQuery = {
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: [{ integration: { id: "firewall-1" }, error: undefined }],
|
||||
};
|
||||
|
||||
expect(hasFirewallPartialFailure("firewall-1", [successfulQuery])).toBe(false);
|
||||
expect(hasFirewallPartialFailure("firewall-1", [{ ...successfulQuery, isError: true }])).toBe(true);
|
||||
expect(
|
||||
hasFirewallPartialFailure("firewall-1", [
|
||||
{ ...successfulQuery, data: [{ integration: { id: "firewall-1" }, error: "offline" }] },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type { Resource } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { formatUptime } from "../system-health";
|
||||
import { getClusterAccordionDefault } from "./accordion-state";
|
||||
@@ -38,9 +39,9 @@ export const ClusterHealthMonitoring = ({
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
|
||||
const t = useI18n();
|
||||
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({
|
||||
integrationId,
|
||||
});
|
||||
const healthData = getUsableWidgetQueryData(
|
||||
clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({ integrationId }),
|
||||
);
|
||||
const accordionScope = `${displayMode}:${options.visibleClusterSections.join(",")}`;
|
||||
const accordionDefault = getClusterAccordionDefault(displayMode, options.visibleClusterSections);
|
||||
const [accordionValues, setAccordionValues] = useState<Record<string, string[]>>({});
|
||||
@@ -67,9 +68,9 @@ export const ClusterHealthMonitoring = ({
|
||||
const isTiny = displayMode !== "advanced" && width < 256;
|
||||
return (
|
||||
<Stack h={displayMode === "advanced" ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"}>
|
||||
{options.showUptime && (
|
||||
{options.showUptime && !isTiny && (
|
||||
<Group justify="center" wrap="nowrap">
|
||||
<Text fz={isTiny ? 8 : "xs"} fw={700} c="dimmed" ta="center">
|
||||
<Text fz="xs" fw={700} c="dimmed" ta="center">
|
||||
{formatUptime(uptime, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -190,10 +191,12 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
|
||||
sections={[{ value: cpu.value, color: cpu.value > 75 ? "orange" : "green" }]}
|
||||
/>
|
||||
<Stack align="center" justify="center" gap={0}>
|
||||
<Text fw={500} size={isTiny ? "xs" : "sm"}>
|
||||
{t("widget.healthMonitoring.cluster.summary.cpu")}
|
||||
</Text>
|
||||
<Text size={isTiny ? "8px" : "xs"}>{cpu.value.toFixed(1)}%</Text>
|
||||
{!isTiny && (
|
||||
<Text fw={500} size="sm">
|
||||
{t("widget.healthMonitoring.cluster.summary.cpu")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs">{cpu.value.toFixed(1)}%</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
)}
|
||||
@@ -211,10 +214,12 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
|
||||
sections={[{ value: memory.value, color: memory.value > 75 ? "orange" : "green" }]}
|
||||
/>
|
||||
<Stack align="center" justify="center" gap={0}>
|
||||
<Text size={isTiny ? "xs" : "sm"} fw={500}>
|
||||
{t("widget.healthMonitoring.cluster.summary.memory")}
|
||||
</Text>
|
||||
<Text size={isTiny ? "8px" : "xs"}>{memory.value.toFixed(1)}%</Text>
|
||||
{!isTiny && (
|
||||
<Text size="sm" fw={500}>
|
||||
{t("widget.healthMonitoring.cluster.summary.memory")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs">{memory.value.toFixed(1)}%</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user