feat: modernize widget experience
This commit is contained in:
198 files changed
+4209
-2295
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.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -191,10 +191,10 @@ When entering the edit mode, you'll get some additional buttons to work with:
|
||||
- **Exit edit mode**: Click this button to save your changes and exit edit mode.
|
||||
|
||||
Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions.
|
||||
Hold `Shift` while hovering a widget to expand an advanced preview in place while the rest of the board stays visible
|
||||
but dimmed. Release `Shift` to close it. You can also focus the widget and press `Shift` + `Enter` to keep its advanced
|
||||
view open until you close it. App tiles stay compact. If you can change the board, right-click empty grid space to add
|
||||
an item at that position.
|
||||
Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board
|
||||
stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to
|
||||
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place. If you
|
||||
can change the board, right-click empty grid space to add an item at that position.
|
||||
On a touch device, touch and hold empty grid space for the same precise-placement menu.
|
||||
|
||||
To continue, click the option "New Item":
|
||||
|
||||
@@ -179,9 +179,9 @@ This will disable the status check for apps.
|
||||
|
||||
Widgets expose their routine controls without requiring edit mode:
|
||||
|
||||
- Hold `Shift` while hovering a widget to expand its advanced preview in place while the other widgets remain visible
|
||||
but dimmed. Release `Shift` to close it. Focus the widget and press `Shift` + `Enter` to keep the view open until you
|
||||
close it, or use the maximize control on touch devices. App tiles stay compact.
|
||||
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
|
||||
remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
|
||||
open until you close it, or use the maximize control on touch devices. Other widgets adapt in place instead.
|
||||
- Right-click a widget to inspect its live query status, refresh its data, change quick options, open settings, or run
|
||||
supported actions. Available controls follow your board and integration permissions.
|
||||
- If you can modify the board, right-click or touch and hold an empty grid cell to add an item there. Focus a board
|
||||
|
||||
@@ -50,6 +50,16 @@ To create a custom widget, navigate to the **Custom Widgets** section in the man
|
||||
| **Action Button** | Clickable button executing the API request | Triggers: restart, clear cache |
|
||||
| **Custom JSX** | Custom JSX template with Mantine v9 components | Full layout control |
|
||||
|
||||
## Security and permissions
|
||||
|
||||
Custom widget definitions, endpoint details, authentication settings, and secrets are managed by administrators.
|
||||
Board editors can select an enabled definition without receiving its endpoint or credentials, while board viewers only
|
||||
receive the rendered widget data they are allowed to view. **Action Button** requests are administrator-only.
|
||||
|
||||
Custom API requests accept HTTP and HTTPS endpoints without URL-embedded credentials. Redirects are rejected, requests
|
||||
time out after 10 seconds, and responses are limited to 2 MiB. Detailed upstream errors stay in the server logs; the
|
||||
dashboard only shows a generic failure.
|
||||
|
||||
### AI-Powered JSX Generation
|
||||
|
||||
When editing or creating a custom widget with the **Custom JSX** display type, you'll find a **Copy Prompt** button. Clicking it copies a pre-formatted prompt describing your widget's name, API endpoint, and available Mantine components. Paste this into your preferred AI assistant (ChatGPT, Claude, Copilot, etc.) to generate a JSX template tailored to your data.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -26,4 +26,5 @@ See the [Custom Widgets documentation](/docs/management/custom-widgets) for full
|
||||
|
||||
:::note
|
||||
Custom API widgets must first be created in **Management → Custom Widgets** before they appear in the widget picker.
|
||||
Action buttons can only be executed by administrators.
|
||||
:::
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
@@ -103,7 +103,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -611,7 +611,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
setPreviewRefreshSignal((n) => n + 1);
|
||||
await utils.customWidget.all.invalidate();
|
||||
await utils.customWidget.byId.invalidate({ id: definitionId });
|
||||
await utils.widget.customApi.getData.invalidate({ definitionId });
|
||||
await utils.widget.customApi.getData.invalidate();
|
||||
}
|
||||
} catch (err) {
|
||||
const serverErrors = extractServerErrors(err, values.displayType);
|
||||
@@ -657,7 +657,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
hasTestedRef.current = true;
|
||||
setPreviewFetchResult({
|
||||
success: res.success,
|
||||
error: res.success ? undefined : res.error,
|
||||
error: res.success ? undefined : t("notification.previewError"),
|
||||
responseInfo: res.responseInfo,
|
||||
rawResponse: res.rawResponse,
|
||||
});
|
||||
@@ -679,7 +679,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
});
|
||||
setPreviewJson(null);
|
||||
}
|
||||
}, [getPreviewInput, previewMutation]);
|
||||
}, [getPreviewInput, previewMutation, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewRefreshSignal > 0 && hasTestedRef.current) {
|
||||
@@ -816,7 +816,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
cachedJson={previewJson}
|
||||
onTest={() => void handlePreviewTest()}
|
||||
isTesting={previewMutation.isPending}
|
||||
testError={previewMutation.error?.message ?? null}
|
||||
testError={previewMutation.error ? t("notification.previewError") : null}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -866,7 +866,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
cachedJson={previewJson}
|
||||
onTest={() => void handlePreviewTest()}
|
||||
isTesting={previewMutation.isPending}
|
||||
testError={previewMutation.error?.message ?? null}
|
||||
testError={previewMutation.error ? t("notification.previewError") : null}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -24,7 +24,7 @@ interface PreviewInput {
|
||||
export interface PreviewFetchResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
responseInfo: { status: number; statusText: string } | null;
|
||||
responseInfo: { status: number } | null;
|
||||
rawResponse: string | null;
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function CustomWidgetPreview({
|
||||
</Text>
|
||||
{fetchResult.responseInfo && (
|
||||
<Badge size="xs" color="red" variant="light" mt={4}>
|
||||
{fetchResult.responseInfo.status} {fetchResult.responseInfo.statusText}
|
||||
{fetchResult.responseInfo.status}
|
||||
</Badge>
|
||||
)}
|
||||
</Alert>
|
||||
@@ -104,7 +104,7 @@ export function CustomWidgetPreview({
|
||||
{fetchResult?.success && (
|
||||
<Group gap="xs">
|
||||
<Badge size="xs" color="green" variant="light">
|
||||
{fetchResult.responseInfo?.status} {fetchResult.responseInfo?.statusText}
|
||||
{fetchResult.responseInfo?.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { CSSProperties, KeyboardEvent, MutableRefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ActionIcon, Badge, Box, Card } from "@mantine/core";
|
||||
import { useElementSize, useViewportSize } from "@mantine/hooks";
|
||||
import { IconMaximize, IconX } from "@tabler/icons-react";
|
||||
import type { CSSProperties, KeyboardEvent } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ActionIcon, Badge, Box, Card, Portal } from "@mantine/core";
|
||||
import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
|
||||
import { IconMaximize } from "@tabler/icons-react";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
@@ -12,8 +13,9 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetDefinition } from "@homarr/widgets";
|
||||
import type { WidgetRuntimeRef } from "@homarr/widgets";
|
||||
import {
|
||||
createWidgetRuntimeState,
|
||||
loadWidgetDynamic,
|
||||
reduceWidgetOptionsWithDefaultValues,
|
||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||
@@ -25,6 +27,7 @@ import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
|
||||
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
||||
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
@@ -52,13 +55,12 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const widgetName = t(`widget.${item.kind}.name`);
|
||||
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
||||
const [isEditMode] = useEditMode();
|
||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(
|
||||
widgetImports[item.kind].definition as WidgetDefinition,
|
||||
);
|
||||
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(widgetImports[item.kind].definition);
|
||||
const advancedViewId = `advanced-focus-${item.id}`;
|
||||
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
|
||||
const isAdvanced = activeFocus !== null;
|
||||
@@ -66,16 +68,21 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight })
|
||||
: null;
|
||||
const closePosition = advancedRect ? getAdvancedFocusClosePosition(advancedRect, viewportWidth) : null;
|
||||
const isPreview = activeFocus?.activation === "preview";
|
||||
const isManual = activeFocus?.activation === "manual";
|
||||
|
||||
// Keep one widget subtree mounted while moving its stable portal target between the grid and viewport layers.
|
||||
// This preserves iframe/editor state and escapes Gridstack's transformed containing block.
|
||||
useIsomorphicEffect(() => {
|
||||
const target = document.createElement("div");
|
||||
target.className = advancedFocusClasses.surfacePortalTarget ?? "";
|
||||
setSurfacePortalTarget(target);
|
||||
return () => target.remove();
|
||||
}, []);
|
||||
|
||||
// Responsive layout changes remount grid items. Never leave their focus backdrop orphaned.
|
||||
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeFocus?.autofocusClose || activeFocus.phase !== "visible") return;
|
||||
const frame = requestAnimationFrame(() => closeButtonRef.current?.focus());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [activeFocus?.autofocusClose, activeFocus?.phase]);
|
||||
|
||||
const openAdvanced = (autofocusClose = false) => {
|
||||
if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose });
|
||||
};
|
||||
@@ -92,7 +99,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
openAdvanced(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
const surface = surfaceRef.current;
|
||||
const surface = isManual ? manualSurface : cardRef.current;
|
||||
if (!isAdvanced || !surface) return;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
@@ -106,11 +113,87 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
|
||||
surface.addEventListener("wheel", handleWheel, { capture: true, passive: false });
|
||||
return () => surface.removeEventListener("wheel", handleWheel, { capture: true });
|
||||
}, [isAdvanced]);
|
||||
}, [isAdvanced, isManual, manualSurface]);
|
||||
|
||||
const mountPortalTarget = (node: HTMLDivElement | null, activation: "compact" | "preview" | "manual") => {
|
||||
if (!node || !surfacePortalTarget) return;
|
||||
const shouldMount =
|
||||
(activation === "compact" && !isAdvanced) ||
|
||||
(activation === "preview" && isPreview) ||
|
||||
(activation === "manual" && isManual);
|
||||
if (shouldMount) node.append(surfacePortalTarget);
|
||||
};
|
||||
|
||||
const previewStyle =
|
||||
isPreview && advancedRect
|
||||
? ({
|
||||
left: advancedRect.left,
|
||||
top: advancedRect.top,
|
||||
width: advancedRect.width,
|
||||
height: advancedRect.height,
|
||||
"--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`,
|
||||
"--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`,
|
||||
"--focus-scale-x": advancedRect.width > 0 ? activeFocus.sourceRect.width / advancedRect.width : 1,
|
||||
"--focus-scale-y": advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1,
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const widgetCard = (
|
||||
<Card
|
||||
ref={cardRef}
|
||||
id={isPreview ? advancedViewId : undefined}
|
||||
role={isPreview ? "region" : undefined}
|
||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||
data-advanced-focus-surface={isPreview || undefined}
|
||||
radius={board.itemRadius}
|
||||
p={isAdvanced ? undefined : 0}
|
||||
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
isPreview && advancedFocusClasses.surface,
|
||||
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
style={previewStyle}
|
||||
>
|
||||
<div className={advancedFocusClasses.surfaceControls}>
|
||||
{!isEditMode && supportsAdvancedFocus && !isAdvanced && (
|
||||
<ActionIcon
|
||||
className={advancedFocusClasses.touchButton}
|
||||
variant="default"
|
||||
size={44}
|
||||
aria-label={t("item.advancedFocus.open")}
|
||||
onClick={() => openAdvanced()}
|
||||
>
|
||||
<IconMaximize size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
||||
<InnerContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
displayMode={isAdvanced ? "advanced" : "compact"}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef} sourceRef={sourceRef}>
|
||||
<WidgetContextMenu item={item} widgetRuntimeRef={widgetRuntimeRef} sourceRef={sourceRef}>
|
||||
<Box
|
||||
ref={sourceRef}
|
||||
tabIndex={!isEditMode && supportsAdvancedFocus ? 0 : undefined}
|
||||
@@ -130,99 +213,44 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
}}
|
||||
className={combineClasses("grid-stack-item-content", isAdvanced && advancedFocusClasses.sourcePlaceholder)}
|
||||
>
|
||||
<div
|
||||
className={combineClasses(
|
||||
advancedFocusClasses.surfaceHost,
|
||||
isAdvanced && advancedFocusClasses.surfaceHostAdvanced,
|
||||
)}
|
||||
>
|
||||
{activeFocus?.activation === "manual" && closePosition && (
|
||||
<ActionIcon
|
||||
ref={closeButtonRef}
|
||||
className={advancedFocusClasses.closeButton}
|
||||
variant="default"
|
||||
size={44}
|
||||
aria-label={t("item.advancedFocus.close")}
|
||||
aria-controls={advancedViewId}
|
||||
style={closePosition}
|
||||
onClick={() => close()}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Card
|
||||
ref={surfaceRef}
|
||||
id={isAdvanced ? advancedViewId : undefined}
|
||||
role={isAdvanced ? "region" : undefined}
|
||||
aria-label={isAdvanced ? advancedViewLabel : undefined}
|
||||
data-advanced-focus-surface={isAdvanced || undefined}
|
||||
radius={board.itemRadius}
|
||||
p={isAdvanced ? undefined : 0}
|
||||
h={isAdvanced ? undefined : "100%"}
|
||||
w={isAdvanced ? undefined : "100%"}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
isAdvanced && advancedFocusClasses.surface,
|
||||
activeFocus?.phase === "closing" && advancedFocusClasses.surfaceClosing,
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color":
|
||||
item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
style={
|
||||
activeFocus && advancedRect
|
||||
? ({
|
||||
left: advancedRect.left,
|
||||
top: advancedRect.top,
|
||||
width: advancedRect.width,
|
||||
height: advancedRect.height,
|
||||
"--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`,
|
||||
"--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`,
|
||||
"--focus-scale-x": advancedRect.width > 0 ? activeFocus.sourceRect.width / advancedRect.width : 1,
|
||||
"--focus-scale-y":
|
||||
advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className={advancedFocusClasses.surfaceControls}>
|
||||
{!isEditMode && supportsAdvancedFocus && !isAdvanced && (
|
||||
<ActionIcon
|
||||
className={advancedFocusClasses.touchButton}
|
||||
variant="default"
|
||||
size={44}
|
||||
aria-label={t("item.advancedFocus.open")}
|
||||
onClick={() => openAdvanced()}
|
||||
>
|
||||
<IconMaximize size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
||||
<InnerContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
displayMode={isAdvanced ? "advanced" : "compact"}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
<div ref={(node) => mountPortalTarget(node, "compact")} className={advancedFocusClasses.surfaceHost}>
|
||||
{!surfacePortalTarget && widgetCard}
|
||||
</div>
|
||||
{surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)}
|
||||
{isPreview && (
|
||||
<Portal reuseTargetNode={false}>
|
||||
<div
|
||||
ref={(node) => mountPortalTarget(node, "preview")}
|
||||
className={advancedFocusClasses.surfaceHostAdvanced}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
{isManual && advancedRect && closePosition && (
|
||||
<AdvancedFocusManualSurface
|
||||
opened={activeFocus.phase === "visible"}
|
||||
phase={activeFocus.phase}
|
||||
id={advancedViewId}
|
||||
label={advancedViewLabel}
|
||||
closeLabel={t("item.advancedFocus.close")}
|
||||
rect={advancedRect}
|
||||
closePosition={closePosition}
|
||||
sourceRect={activeFocus.sourceRect}
|
||||
radius={board.itemRadius}
|
||||
contentRef={setManualSurface}
|
||||
onClose={() => close()}
|
||||
>
|
||||
<div
|
||||
ref={(node) => mountPortalTarget(node, "manual")}
|
||||
className={advancedFocusClasses.manualSurfaceMount}
|
||||
/>
|
||||
</AdvancedFocusManualSurface>
|
||||
)}
|
||||
</Box>
|
||||
</WidgetContextMenu>
|
||||
{!isAdvanced && item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
// It's 4 because of the mantine-react-table that has z-index 3
|
||||
style={{ zIndex: 4 }}
|
||||
style={{ zIndex: "var(--mantine-z-index-app)" }}
|
||||
top={2}
|
||||
left={16}
|
||||
size="xs"
|
||||
@@ -247,7 +275,7 @@ interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
displayMode: "compact" | "advanced";
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
pos="absolute"
|
||||
top={offset}
|
||||
right={offset}
|
||||
style={{ zIndex: 10 }}
|
||||
style={{ zIndex: "var(--mantine-z-index-popover)" }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ItemSelectModal = createModal<ItemSelectModalProps>(({ actions, inn
|
||||
const { createItem } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.board.id });
|
||||
const settings = useSettings();
|
||||
const { updateAndPersistBoard } = usePersistBoard(innerProps.board);
|
||||
|
||||
@@ -208,7 +208,7 @@ export const ItemSelectModal = createModal<ItemSelectModalProps>(({ actions, inn
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? def.url}
|
||||
{def.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject, ReactNode, RefObject } from "react";
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets";
|
||||
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets";
|
||||
import {
|
||||
getWidgetQueryKeys,
|
||||
getWidgetRuntimeQueries,
|
||||
@@ -48,12 +48,12 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
sourceRef: RefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: WidgetContextMenuProps) => {
|
||||
export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children }: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
@@ -70,8 +70,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const canOpenAdvancedFocus = supportsAdvancedFocus(currentDefinition as WidgetDefinition);
|
||||
const currentDefinition = useMemo(
|
||||
() => widgetImports[item.kind].definition as WidgetDefinition & { kind: string },
|
||||
[item.kind],
|
||||
);
|
||||
const canOpenAdvancedFocus = supportsAdvancedFocus(currentDefinition);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const queryClient = useQueryClient();
|
||||
const { open: openAdvancedFocus } = useAdvancedFocus();
|
||||
@@ -93,15 +96,12 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options),
|
||||
[item.kind, settings, item.options],
|
||||
);
|
||||
|
||||
const widgetQueryKeys = useMemo(
|
||||
() => getWidgetQueryKeys(currentDefinition as Parameters<typeof getWidgetQueryKeys>[0]),
|
||||
[currentDefinition],
|
||||
);
|
||||
const widgetQueryMatcher = (currentDefinition as WidgetDefinition).queryMatcher;
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(currentDefinition), [currentDefinition]);
|
||||
const widgetQueryMatcher = "queryMatcher" in currentDefinition ? currentDefinition.queryMatcher : undefined;
|
||||
const matchesWidgetQuery = useCallback(
|
||||
(queryKey: QueryKey) =>
|
||||
matchesWidgetItemQuery(
|
||||
@@ -112,11 +112,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
|
||||
boardId: board.id,
|
||||
integrationIds: item.integrationIds,
|
||||
options,
|
||||
runtimeQueries: getWidgetRuntimeQueries(widgetStateRef),
|
||||
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
||||
},
|
||||
widgetQueryMatcher,
|
||||
),
|
||||
[board.id, item.id, item.integrationIds, options, widgetQueryKeys, widgetQueryMatcher, widgetStateRef],
|
||||
[board.id, item.id, item.integrationIds, options, widgetQueryKeys, widgetQueryMatcher, widgetRuntimeRef],
|
||||
);
|
||||
const isWidgetFetching =
|
||||
useIsFetching({
|
||||
@@ -156,11 +156,8 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
|
||||
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
|
||||
}, [currentDefinition, hasChangeAccess, settings]);
|
||||
|
||||
const widgetContextActions = (() => {
|
||||
const def = currentDefinition as Record<string, unknown>;
|
||||
if (typeof def.contextActions !== "function") return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actions = (def.contextActions as any)({
|
||||
const widgetContextActions =
|
||||
currentDefinition.contextActions?.({
|
||||
options,
|
||||
setOptions: setItemOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
@@ -170,10 +167,8 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
|
||||
itemId: item.id,
|
||||
canInteractWithSelectedIntegrations,
|
||||
},
|
||||
widgetStateRef,
|
||||
});
|
||||
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
|
||||
})();
|
||||
widgetRuntimeRef,
|
||||
}) ?? [];
|
||||
|
||||
const openEditModal = useCallback(() => {
|
||||
openModal(
|
||||
@@ -294,7 +289,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
|
||||
disabled={action.disabled}
|
||||
color={action.color}
|
||||
>
|
||||
{String(translateIfNecessary(t, action.label))}
|
||||
{t(action.label as never)}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
@@ -416,14 +411,8 @@ const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQ
|
||||
const hasError = queries.some((q) => q.state.status === "error");
|
||||
|
||||
if (hasError) {
|
||||
const errorQuery = queries.find((q) => q.state.status === "error");
|
||||
const errorMessage =
|
||||
errorQuery?.state.error instanceof Error
|
||||
? errorQuery.state.error.message
|
||||
: String(errorQuery?.state.error ?? "Unknown error");
|
||||
|
||||
return (
|
||||
<Tooltip label={errorMessage} multiline position="left" w={250}>
|
||||
<Tooltip label={t("item.menu.status.error")} position="left">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" c="red.6">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
right: 10px;
|
||||
bottom: calc(100% - 10px);
|
||||
margin-bottom: 1px;
|
||||
z-index: 200;
|
||||
z-index: var(--mantine-z-index-app);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
@@ -101,6 +101,15 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.panel {
|
||||
border-color: CanvasText;
|
||||
background: Canvas;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.metadataLabel {
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from "react";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createId, getSafeApplicationUrl } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -116,10 +116,13 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
});
|
||||
|
||||
const apps = await fetchApi.app.byIds.query(appIds);
|
||||
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
|
||||
const safeUrls = apps.flatMap((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return href ? [href] : [];
|
||||
});
|
||||
|
||||
for (const app of appsWithUrls) {
|
||||
const openedWindow = window.open(app.href ?? undefined);
|
||||
for (const href of safeUrls) {
|
||||
const openedWindow = window.open(href, "_blank", "noopener,noreferrer");
|
||||
if (openedWindow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 10 }}
|
||||
style={{ zIndex: "var(--mantine-z-index-popover)" }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, test } from "vitest";
|
||||
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { loginAsync } from "./shared/login";
|
||||
import { startMockApiServerAsync } from "./shared/mock-api-server";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
@@ -17,7 +18,7 @@ const initialTemplate =
|
||||
const updatedTemplate = '<Stack gap="xs"><Text>{data.title}</Text><Badge color="green">{data.value}</Badge></Stack>';
|
||||
|
||||
describe("Custom JSX custom widgets", () => {
|
||||
test("creates, previews, and edits a customJsx widget", async () => {
|
||||
test("reliably creates, previews, and edits a customJsx widget", async () => {
|
||||
const mockApi = await startMockApiServerAsync({
|
||||
title: "E2E Widget",
|
||||
status: "online",
|
||||
@@ -38,48 +39,56 @@ describe("Custom JSX custom widgets", () => {
|
||||
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const destination = "/manage/custom-widgets/new";
|
||||
|
||||
try {
|
||||
await page.goto(`${baseUrl}/auth/login?callbackUrl=${encodeURIComponent(destination)}`, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
await Promise.all([
|
||||
page.waitForURL(`**${destination}`, { timeout: 30_000 }),
|
||||
page.locator("css=button[type='submit']").click(),
|
||||
]);
|
||||
await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("E2E Custom JSX");
|
||||
await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`);
|
||||
await page.getByRole("combobox", { name: "Display Type" }).click();
|
||||
await page.getByRole("option", { name: "Custom JSX" }).click();
|
||||
await page.getByLabel("JSX Template").fill(initialTemplate);
|
||||
for (const attempt of [1, 2, 3]) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const widgetName = `E2E Custom JSX ${attempt}`;
|
||||
|
||||
await page.getByRole("button", { name: "Test" }).first().click();
|
||||
const previewPanel = page.locator(".mantine-Card-root").filter({ hasText: "Preview" });
|
||||
await expect(previewPanel.getByRole("heading", { name: "E2E Widget" }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
try {
|
||||
await loginAsync({ page, baseUrl, credentials: adminCredentials, destination });
|
||||
await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("textbox", { name: "Name" }).fill(widgetName);
|
||||
await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`);
|
||||
await page.getByRole("combobox", { name: "Display Type" }).click();
|
||||
await page.getByRole("option", { name: "Custom JSX" }).click();
|
||||
await page.getByLabel("JSX Template").fill(initialTemplate);
|
||||
|
||||
await page.getByRole("button", { name: "Create" }).first().click();
|
||||
await expect(page.getByText('Widget "E2E Custom JSX" created successfully.')).toBeVisible({ timeout: 15_000 });
|
||||
await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 });
|
||||
await expect(page.getByLabel("JSX Template")).toHaveValue(initialTemplate);
|
||||
await page.getByRole("button", { name: "Test" }).first().click();
|
||||
const previewPanel = page.locator(".mantine-Card-root").filter({ hasText: "Preview" });
|
||||
await expect(previewPanel.getByRole("heading", { name: "E2E Widget" }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await page.getByLabel("JSX Template").fill(updatedTemplate);
|
||||
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
|
||||
const editNavigation = page.waitForURL((url) => url.pathname.startsWith("/manage/custom-widgets/edit/"), {
|
||||
timeout: 15_000,
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await page.getByRole("button", { name: "Create" }).first().click();
|
||||
await editNavigation;
|
||||
await expect(page.getByText(`Widget "${widgetName}" created successfully.`)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByLabel("JSX Template")).toHaveValue(initialTemplate);
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).first().click();
|
||||
await expect(page.getByText('Widget "E2E Custom JSX" updated successfully.')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
|
||||
await page.getByLabel("JSX Template").fill(updatedTemplate);
|
||||
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).first().click();
|
||||
await expect(page.getByText(`Widget "${widgetName}" updated successfully.`)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
await mockApi.close();
|
||||
}
|
||||
}, 120_000);
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import { boards, customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import { eq } from "@homarr/db";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import {
|
||||
@@ -19,17 +19,23 @@ import {
|
||||
} from "@homarr/validation/custom-widget";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { assertCustomApiItemBindingAsync, customApiItemInputSchema } from "./custom-widget-access";
|
||||
import { applyAuth } from "./auth";
|
||||
import { extractDisplayDataWithFallback } from "./display-data";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
import {
|
||||
consumeBoundedResponseAsync,
|
||||
readBoundedJsonResponseAsync,
|
||||
validateCustomApiUrl,
|
||||
withCustomApiResponseAsync,
|
||||
} from "../widgets/custom-api-security";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
const validateUrl = (urlString: string): URL => new URL(urlString);
|
||||
const PUBLIC_PREVIEW_ERROR = "Custom API preview failed";
|
||||
|
||||
const updateFieldSerializers: Record<string, (value: unknown) => unknown> = {
|
||||
displayConfig: (value) => superjson.stringify(value),
|
||||
@@ -54,7 +60,7 @@ function getImportJsonSchema() {
|
||||
export const customWidgetRouter = createTRPCRouter({
|
||||
schema: publicProcedure.query(() => getImportJsonSchema()),
|
||||
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
all: adminProcedure.query(async ({ ctx }) => {
|
||||
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
});
|
||||
@@ -72,7 +78,24 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
}));
|
||||
}),
|
||||
|
||||
byId: protectedProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
available: protectedProcedure
|
||||
.input(z.object({ boardId: z.string().min(1).max(100) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
return await ctx.db.query.customWidgetDefinitions.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
iconUrl: true,
|
||||
displayType: true,
|
||||
enabled: true,
|
||||
},
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
});
|
||||
}),
|
||||
|
||||
byId: adminProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
with: { secrets: true },
|
||||
@@ -275,75 +298,65 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const secrets = [...input.secrets];
|
||||
|
||||
if (input.definitionId) {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (existing) {
|
||||
for (const dbSecret of existing.secrets) {
|
||||
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
|
||||
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
|
||||
try {
|
||||
const secrets = [...input.secrets];
|
||||
if (input.definitionId) {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (existing) {
|
||||
for (const dbSecret of existing.secrets) {
|
||||
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
|
||||
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const url = validateUrl(input.url);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
const url = validateCustomApiUrl(input.url);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
if (input.method !== "GET" && input.requestBody) headers.set("Content-Type", "application/json");
|
||||
applyAuth(headers, url, input.authType, secrets, input.headerName);
|
||||
|
||||
if (input.method !== "GET" && input.requestBody) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
return await withCustomApiResponseAsync(
|
||||
url,
|
||||
{
|
||||
method: input.method,
|
||||
headers,
|
||||
body: input.method !== "GET" ? input.requestBody : undefined,
|
||||
},
|
||||
async (response) => {
|
||||
const responseInfo = { status: response.status };
|
||||
if (!response.ok) {
|
||||
await consumeBoundedResponseAsync(response);
|
||||
return {
|
||||
success: false as const,
|
||||
error: PUBLIC_PREVIEW_ERROR,
|
||||
responseInfo,
|
||||
rawResponse: null,
|
||||
};
|
||||
}
|
||||
|
||||
applyAuth(headers, url, input.authType, secrets, input.headerName);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: input.method,
|
||||
headers,
|
||||
body: input.method !== "GET" ? input.requestBody : undefined,
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseInfo = { status: response.status, statusText: response.statusText };
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
return {
|
||||
success: false as const,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseInfo,
|
||||
rawResponse: body,
|
||||
};
|
||||
}
|
||||
|
||||
const json: unknown = await response.json();
|
||||
const displayData = extractDisplayDataWithFallback(json, input.displayType, input.displayConfig);
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
responseInfo,
|
||||
rawResponse: JSON.stringify(json, null, 2),
|
||||
displayData,
|
||||
};
|
||||
const json = await readBoundedJsonResponseAsync(response);
|
||||
const displayData = extractDisplayDataWithFallback(json, input.displayType, input.displayConfig);
|
||||
return {
|
||||
success: true as const,
|
||||
responseInfo,
|
||||
rawResponse: JSON.stringify(json, null, 2),
|
||||
displayData,
|
||||
};
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget preview failed", { error });
|
||||
const errorId = createId();
|
||||
logger.error("Custom widget preview failed", { errorId, error });
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Failed to fetch data",
|
||||
error: PUBLIC_PREVIEW_ERROR,
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -389,7 +402,8 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
return { id: newId, name: `${definition.name} (copy)` };
|
||||
}),
|
||||
|
||||
execute: protectedProcedure.input(z.object({ definitionId: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
execute: adminProcedure.input(customApiItemInputSchema).mutation(async ({ ctx, input }) => {
|
||||
await assertCustomApiItemBindingAsync(ctx, input);
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
@@ -407,50 +421,41 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only actionButton widgets can be executed" });
|
||||
}
|
||||
|
||||
const decryptedSecrets = definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
value: decryptSecret(s.value),
|
||||
}));
|
||||
|
||||
const url = validateUrl(definition.url);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
|
||||
if (definition.method !== "GET" && definition.requestBody) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: definition.method,
|
||||
headers,
|
||||
body: definition.method !== "GET" ? definition.requestBody : undefined,
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const decryptedSecrets = definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
value: decryptSecret(s.value),
|
||||
}));
|
||||
const url = validateCustomApiUrl(definition.url);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
if (definition.method !== "GET" && definition.requestBody) headers.set("Content-Type", "application/json");
|
||||
applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName);
|
||||
|
||||
const responseInfo = { status: response.status, statusText: response.statusText };
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false as const, error: `HTTP ${response.status}: ${response.statusText}`, responseInfo };
|
||||
}
|
||||
|
||||
logger.info("Executed custom widget action", { definitionId: input.definitionId, status: response.status });
|
||||
return { success: true as const, responseInfo };
|
||||
return await withCustomApiResponseAsync(
|
||||
url,
|
||||
{
|
||||
method: definition.method,
|
||||
headers,
|
||||
body: definition.method !== "GET" ? definition.requestBody : undefined,
|
||||
},
|
||||
async (response) => {
|
||||
await consumeBoundedResponseAsync(response);
|
||||
const responseInfo = { status: response.status };
|
||||
if (!response.ok) {
|
||||
return { success: false as const, errorCode: "UPSTREAM_REJECTED" as const, responseInfo };
|
||||
}
|
||||
logger.info("Executed custom widget action", { definitionId: input.definitionId, status: response.status });
|
||||
return { success: true as const, responseInfo };
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget execute failed", { definitionId: input.definitionId, error });
|
||||
const errorId = createId();
|
||||
logger.error("Custom widget execute failed", { errorId, definitionId: input.definitionId, error });
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Request failed",
|
||||
errorCode: "REQUEST_FAILED" as const,
|
||||
responseInfo: null,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -111,13 +111,7 @@ const extractors: Record<string, Extractor> = {
|
||||
data: query(json, (c.jsonPath as string) ?? "$"),
|
||||
maxHeight: c.maxHeight ?? 300,
|
||||
}),
|
||||
actionButton: (_json, c) => ({
|
||||
type: "actionButton",
|
||||
buttonLabel: c.buttonLabel ?? "Execute",
|
||||
buttonColor: c.buttonColor ?? "blue",
|
||||
confirmText: c.confirmText ?? "",
|
||||
successMessage: c.successMessage ?? "",
|
||||
}),
|
||||
actionButton: (_json, c) => extractActionButtonDisplay(c),
|
||||
customJsx: (json, config) => ({
|
||||
type: "customJsx" as const,
|
||||
template: config.template as string,
|
||||
@@ -140,6 +134,20 @@ export function extractDisplayDataWithFallback(json: unknown, displayType: strin
|
||||
return extractor?.(json, displayConfig);
|
||||
}
|
||||
|
||||
export function extractActionButtonDisplay(displayConfig: Config): unknown {
|
||||
return extractors.actionButton?.(null, displayConfig);
|
||||
export interface ActionButtonDisplayData {
|
||||
type: "actionButton";
|
||||
buttonLabel: string;
|
||||
buttonColor: string;
|
||||
confirmText: string;
|
||||
successMessage: string;
|
||||
}
|
||||
|
||||
export function extractActionButtonDisplay(displayConfig: Config): ActionButtonDisplayData {
|
||||
return {
|
||||
type: "actionButton",
|
||||
buttonLabel: typeof displayConfig.buttonLabel === "string" ? displayConfig.buttonLabel : "Execute",
|
||||
buttonColor: typeof displayConfig.buttonColor === "string" ? displayConfig.buttonColor : "blue",
|
||||
confirmText: typeof displayConfig.confirmText === "string" ? displayConfig.confirmText : "",
|
||||
successMessage: typeof displayConfig.successMessage === "string" ? displayConfig.successMessage : "",
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
import { parse } from "superjson";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import { eq } from "@homarr/db";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
import { applyAuth } from "../custom-widget/auth";
|
||||
import { assertCustomApiItemBindingAsync, customApiItemInputSchema } from "../custom-widget/custom-widget-access";
|
||||
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
|
||||
import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "./custom-api-security";
|
||||
import {
|
||||
consumeBoundedResponseAsync,
|
||||
readBoundedJsonResponseAsync,
|
||||
validateCustomApiUrl,
|
||||
withCustomApiResponseAsync,
|
||||
} from "./custom-api-security";
|
||||
|
||||
const logger = createLogger({ module: "widget:customApi" });
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
export const customApiRouter = createTRPCRouter({
|
||||
getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => {
|
||||
getData: protectedProcedure.input(customApiItemInputSchema).query(async ({ ctx, input }) => {
|
||||
await assertCustomApiItemBindingAsync(ctx, input, "view");
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
@@ -32,59 +37,52 @@ export const customApiRouter = createTRPCRouter({
|
||||
|
||||
let displayConfig: Record<string, unknown>;
|
||||
try {
|
||||
displayConfig = superjson.parse(definition.displayConfig) as Record<string, unknown>;
|
||||
displayConfig = parse(definition.displayConfig) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
|
||||
}
|
||||
|
||||
if (definition.displayType === "actionButton") {
|
||||
return extractActionButtonDisplay(displayConfig);
|
||||
const actionDisplay = extractActionButtonDisplay(displayConfig);
|
||||
return {
|
||||
...actionDisplay,
|
||||
canExecute: ctx.session.user.permissions.includes("admin"),
|
||||
};
|
||||
}
|
||||
|
||||
const decryptedSecrets = definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
value: decryptSecret(s.value),
|
||||
}));
|
||||
|
||||
const url = validateCustomApiUrl(definition.url);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
|
||||
if (definition.method !== "GET" && definition.requestBody) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: definition.method,
|
||||
headers,
|
||||
body: definition.method !== "GET" ? definition.requestBody : undefined,
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const decryptedSecrets = definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
value: decryptSecret(s.value),
|
||||
}));
|
||||
const url = validateCustomApiUrl(definition.url);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
if (definition.method !== "GET" && definition.requestBody) headers.set("Content-Type", "application/json");
|
||||
applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `API returned ${response.status}: ${response.statusText}`,
|
||||
});
|
||||
}
|
||||
|
||||
const json = await readBoundedJsonResponseAsync(response);
|
||||
return extractDisplayDataWithFallback(json, definition.displayType, displayConfig);
|
||||
return await withCustomApiResponseAsync(
|
||||
url,
|
||||
{
|
||||
method: definition.method,
|
||||
headers,
|
||||
body: definition.method !== "GET" ? definition.requestBody : undefined,
|
||||
},
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
await consumeBoundedResponseAsync(response);
|
||||
throw new Error(`Custom API returned HTTP ${response.status}`);
|
||||
}
|
||||
const json = await readBoundedJsonResponseAsync(response);
|
||||
return extractDisplayDataWithFallback(json, definition.displayType, displayConfig);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Failed to fetch custom API data", { definitionId: input.definitionId, error });
|
||||
const errorId = createId();
|
||||
logger.error("Failed to fetch custom API data", { errorId, definitionId: input.definitionId, error });
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error instanceof Error ? error.message : "Failed to fetch data from external API",
|
||||
message: "Custom API request failed",
|
||||
});
|
||||
} 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;
|
||||
|
||||
|
||||
@@ -13,10 +13,14 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
property,
|
||||
kind,
|
||||
options,
|
||||
boardId,
|
||||
}: CommonWidgetInputProps<"customWidgetSelect">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const form = useFormContext();
|
||||
const { data, isLoading } = clientApi.customWidget.all.useQuery();
|
||||
const { data, isLoading } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: boardId ?? "" },
|
||||
{ enabled: Boolean(boardId) },
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const currentValue = form.values.options[property] as string;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -272,19 +272,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")}…`} />;
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import { formatBytes, formatDuration } from "@homarr/common";
|
||||
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { setWidgetRuntimeQueries } from "../definition";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { AudioStatsContent } from "./audio-stats-content";
|
||||
|
||||
export default function AudioStatsWidget({
|
||||
@@ -19,7 +19,7 @@ export default function AudioStatsWidget({
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"audioStats">) {
|
||||
const statsInput = { integrationId: integrationIds[0] ?? "" };
|
||||
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
|
||||
@@ -29,7 +29,7 @@ export default function AudioStatsWidget({
|
||||
streamsInput,
|
||||
{ enabled: streamsEnabled },
|
||||
);
|
||||
setWidgetRuntimeQueries(widgetStateRef, [
|
||||
useWidgetRuntimeQueries(widgetRuntimeRef, [
|
||||
getQueryKey(clientApi.widget.audioStats.getStats, statsInput, "query"),
|
||||
...(streamsEnabled ? [getQueryKey(clientApi.widget.mediaServer.getCurrentStreams, streamsInput, "query")] : []),
|
||||
]);
|
||||
@@ -37,7 +37,6 @@ export default function AudioStatsWidget({
|
||||
if (statsError && response === undefined) throw statsError;
|
||||
if (streamsEnabled && streamsError && streamResults === undefined) throw streamsError;
|
||||
const currentStreams = streamResults ?? [];
|
||||
if (streamsEnabled && currentStreams.some(({ error }) => Boolean(error))) throw new NoIntegrationDataError();
|
||||
if (!response) return <WidgetEmptyState />;
|
||||
|
||||
const summary = (
|
||||
@@ -56,9 +55,15 @@ export default function AudioStatsWidget({
|
||||
);
|
||||
const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null;
|
||||
const advancedGridColumns = width >= 760 ? 2 : 1;
|
||||
const hasStreamErrors = currentStreams.some(({ error }) => Boolean(error));
|
||||
return (
|
||||
<Stack h="100%" gap="lg" p="md">
|
||||
<div style={{ minHeight: 150 }}>{summary}</div>
|
||||
{hasStreamErrors && (
|
||||
<Group justify="flex-end">
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
</Group>
|
||||
)}
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
{response.kind === "navidrome" ? (
|
||||
<SimpleGrid cols={advancedGridColumns} spacing="xs">
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -15,9 +15,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";
|
||||
|
||||
@@ -26,12 +27,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();
|
||||
@@ -46,22 +45,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"
|
||||
? [
|
||||
getQueryKey(
|
||||
@@ -127,7 +130,6 @@ export default function BeszelSystemStatsWidget({
|
||||
[boardId, hasChangeAccess, itemId, options.timePeriod, saveItemOptions, setOptions],
|
||||
);
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
if (systemsPending)
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -212,36 +214,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) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Center, Group, Indicator, Loader, Progress, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
@@ -26,8 +25,8 @@ import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { setWidgetRuntimeQueries } from "../definition";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||
@@ -43,7 +42,6 @@ import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
|
||||
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
||||
|
||||
@@ -96,8 +94,6 @@ export default function BeszelSystemTableWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
displayMode = "compact",
|
||||
widgetStateRef,
|
||||
boardId,
|
||||
itemId,
|
||||
setOptions,
|
||||
@@ -107,14 +103,10 @@ export default function BeszelSystemTableWidget({
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
|
||||
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 size = useMemo(() => getSizeConfig(width), [width]);
|
||||
const [selectedSystem, setSelectedSystem] = useState<SystemRowWithKey | null>(null);
|
||||
|
||||
const visibleMetricKeys = useMemo(() => {
|
||||
const enabled = [
|
||||
"showCpu",
|
||||
@@ -129,18 +121,9 @@ export default function BeszelSystemTableWidget({
|
||||
"showUptime",
|
||||
"showAgent",
|
||||
].filter((key) => options[key as keyof typeof options]) as string[];
|
||||
const budget =
|
||||
displayMode === "advanced"
|
||||
? enabled.length
|
||||
: width < 360
|
||||
? 1
|
||||
: width < 560
|
||||
? 2
|
||||
: width < 760
|
||||
? 4
|
||||
: enabled.length;
|
||||
const budget = width < 360 ? 1 : width < 560 ? 2 : width < 760 ? 4 : enabled.length;
|
||||
return new Set(enabled.slice(0, budget));
|
||||
}, [displayMode, options, width]);
|
||||
}, [options, width]);
|
||||
|
||||
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
|
||||
onError: () =>
|
||||
@@ -383,38 +366,12 @@ export default function BeszelSystemTableWidget({
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
({ record }: { record: SystemRowWithKey }) => {
|
||||
if (displayMode === "advanced") {
|
||||
setSelectedSystem(record);
|
||||
return;
|
||||
}
|
||||
const integrationId = record._key.split(":")[0] ?? "";
|
||||
openModal({ integrationId, systemId: record.id }, { title: record.name });
|
||||
},
|
||||
[displayMode, openModal],
|
||||
[openModal],
|
||||
);
|
||||
|
||||
const activeSystem = selectedSystem ?? sortedSystems[0];
|
||||
const activeIntegrationId = activeSystem?._key.split(":")[0] ?? "";
|
||||
setWidgetRuntimeQueries(
|
||||
widgetStateRef,
|
||||
displayMode === "advanced" && activeSystem
|
||||
? [
|
||||
getQueryKey(
|
||||
clientApi.widget.beszel.getSystemStats,
|
||||
{
|
||||
integrationIds: [activeIntegrationId],
|
||||
systemId: activeSystem.id,
|
||||
timePeriod: "1h",
|
||||
includeDocker: false,
|
||||
},
|
||||
"query",
|
||||
),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -444,42 +401,5 @@ export default function BeszelSystemTableWidget({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (displayMode === "compact") return table;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: width >= 900 ? "minmax(0, 3fr) minmax(320px, 2fr)" : "1fr",
|
||||
height: "100%",
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
{table}
|
||||
{activeSystem && (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="sm">
|
||||
<Text fw={700}>{activeSystem.name}</Text>
|
||||
<BeszelStatsView
|
||||
integrationIds={[activeIntegrationId]}
|
||||
systemId={activeSystem.id}
|
||||
timePeriod="1h"
|
||||
columns={1}
|
||||
visibility={{
|
||||
cpu: options.showCpu,
|
||||
memory: options.showMemory,
|
||||
disk: options.showDisk,
|
||||
diskIO: options.showDisk,
|
||||
network: options.showNet,
|
||||
dockerCpu: false,
|
||||
dockerMemory: false,
|
||||
dockerNetwork: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return table;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -4,10 +4,10 @@ import type { CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getDefaultZIndex } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
import { useCurrentIntlLocale } from "@homarr/translation/client";
|
||||
|
||||
import { formatLocalizedDate } from "../../common/locale";
|
||||
|
||||
const styles: Record<string, CSSProperties> = {
|
||||
wrapper: {
|
||||
@@ -60,6 +60,7 @@ const MARGIN = 14;
|
||||
const BACKGROUND_TOOLTIP_Z_INDEX = getDefaultZIndex("modal") - 3;
|
||||
|
||||
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
const [pos, setPos] = useState({ x: 0, y: 0, isAdvanced: false });
|
||||
const rafRef = useRef(0);
|
||||
@@ -116,7 +117,9 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
if (!sorted.length) return null;
|
||||
|
||||
const rawTime = sorted[0]?.payload?.rawTime as string | undefined;
|
||||
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
|
||||
const tooltipLabel = rawTime
|
||||
? formatLocalizedDate(rawTime, locale, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
|
||||
: label;
|
||||
const total = sorted.reduce((sum, p) => sum + p.value, 0);
|
||||
|
||||
const portalStyle: CSSProperties = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,7 +16,8 @@ import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { setWidgetRuntimeQueries } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
import {
|
||||
@@ -31,9 +32,21 @@ import classes from "./component.module.css";
|
||||
|
||||
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
|
||||
const [month, setMonth] = useState(() => new Date());
|
||||
const runtimeInput = {
|
||||
integrationIds: props.integrationIds,
|
||||
month: month.getMonth(),
|
||||
year: month.getFullYear(),
|
||||
releaseType: props.options.releaseType,
|
||||
showUnmonitored: props.options.showUnmonitored,
|
||||
};
|
||||
useWidgetRuntimeQueries(
|
||||
props.widgetRuntimeRef,
|
||||
props.integrationIds.length > 0
|
||||
? [getQueryKey(clientApi.widget.calendar.findAllEvents, runtimeInput, "query")]
|
||||
: [],
|
||||
);
|
||||
|
||||
if (props.integrationIds.length === 0) {
|
||||
setWidgetRuntimeQueries(props.widgetStateRef, []);
|
||||
return (
|
||||
<CalendarBase
|
||||
{...props}
|
||||
@@ -54,15 +67,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
|
||||
setMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
const FetchCalendar = ({
|
||||
month,
|
||||
setMonth,
|
||||
isEditMode,
|
||||
integrationIds,
|
||||
options,
|
||||
displayMode,
|
||||
widgetStateRef,
|
||||
}: FetchCalendarProps) => {
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
|
||||
const input = {
|
||||
integrationIds,
|
||||
month: month.getMonth(),
|
||||
@@ -70,8 +75,9 @@ const FetchCalendar = ({
|
||||
releaseType: options.releaseType,
|
||||
showUnmonitored: options.showUnmonitored,
|
||||
};
|
||||
setWidgetRuntimeQueries(widgetStateRef, [getQueryKey(clientApi.widget.calendar.findAllEvents, input, "query")]);
|
||||
const { data, isPending } = clientApi.widget.calendar.findAllEvents.useQuery(input);
|
||||
const calendarQuery = clientApi.widget.calendar.findAllEvents.useQuery(input);
|
||||
const data = getUsableWidgetQueryData(calendarQuery);
|
||||
const { isPending } = calendarQuery;
|
||||
|
||||
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
|
||||
const failedIntegrations =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||
import { Box, Loader, Stack, Text, Title } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||
import timezones from "dayjs/plugin/timezone";
|
||||
@@ -9,8 +9,10 @@ import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { metricToImperial } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||
|
||||
@@ -79,10 +81,22 @@ interface ClockWeatherCornerProps {
|
||||
}
|
||||
|
||||
const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit, showDetails }: ClockWeatherCornerProps) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const weather = weatherQuery.data;
|
||||
|
||||
if (!weather) return null;
|
||||
if (!weather) {
|
||||
return (
|
||||
<Box pos="absolute" top={4} left={4}>
|
||||
{isInitialWidgetQueryPending(weatherQuery) ? (
|
||||
<Loader size="xs" aria-label={tCommon("action.loading")} />
|
||||
) : (
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||
const unit = isFahrenheit ? "°F" : "°C";
|
||||
@@ -100,6 +114,7 @@ const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit, showDetails }:
|
||||
{isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
|
||||
</Text>
|
||||
)}
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
</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";
|
||||
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user