Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af13531adc | ||
|
|
7a5edb26c1 |
No files matched your search
@@ -40,7 +40,7 @@ homarr/
|
||||
│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries
|
||||
│ ├── core/ # Env validation, DB/Redis driver factories, logging
|
||||
│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions
|
||||
│ ├── widgets/ # Dashboard widget definitions, components, and lazy registry
|
||||
│ ├── widgets/ # All 39 dashboard widgets (definitions + components)
|
||||
│ ├── integrations/ # Integration classes (HTTP clients to external apps)
|
||||
│ ├── redis/ # Redis pub/sub channels, caching abstractions
|
||||
│ ├── translation/ # next-intl setup, locale configs, lang JSON files
|
||||
@@ -118,15 +118,11 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
|
||||
|
||||
### Widget System
|
||||
|
||||
Widget kinds live in `packages/definitions/src/widget.ts`. Integration selection and authorization metadata lives in
|
||||
`packages/definitions/src/widget-integration-map.ts`. Each widget module exports a typed `definition` and
|
||||
`componentLoader` created with `createWidgetDefinition(...).withDynamicImport(...)`.
|
||||
|
||||
Register widgets in all three explicit maps in `packages/widgets/src/registry.ts`: the type-only module map, literal
|
||||
module loaders, and literal component loaders. The two runtime loader maps are deliberately separate so
|
||||
`packages/widgets/src/manifest.ts` can request a widget's definition chunk and component chunk in parallel while
|
||||
keeping every lazy import statically discoverable by Next.js. The manifest owns promise caching and clears rejected
|
||||
entries so later loads can retry. Do not restore an eager root `widgetImports` map.
|
||||
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
|
||||
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
|
||||
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
|
||||
- Loads dynamically via `next/dynamic`
|
||||
- Fetches data via tRPC client hooks (backed by cron job caches)
|
||||
|
||||
### Integration System
|
||||
|
||||
|
||||
@@ -320,12 +320,8 @@ jobs:
|
||||
|
||||
publish-preview:
|
||||
name: Publish multi-platform preview image
|
||||
needs: [container, preview-amd64, preview-arm64]
|
||||
needs: [preview-amd64, preview-arm64]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.container.result == 'success' &&
|
||||
needs.preview-amd64.result == 'success' &&
|
||||
needs.preview-arm64.result == 'success' &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
@@ -379,6 +375,9 @@ jobs:
|
||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||
run: |
|
||||
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=(-t "${PREVIEW_IMAGE}:pr-6545")
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
|
||||
@@ -387,6 +386,9 @@ jobs:
|
||||
if: "!env.ACT"
|
||||
run: |
|
||||
tags=("${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=("pr-6545")
|
||||
fi
|
||||
for tag in "${tags[@]}"; do
|
||||
image="${PREVIEW_IMAGE}:${tag}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
@@ -433,14 +435,6 @@ jobs:
|
||||
core.info(`No package version found with tag "${tag}"`);
|
||||
return;
|
||||
}
|
||||
const targetTags = target.metadata?.container?.tags ?? [];
|
||||
const extraTags = targetTags.filter((targetTag) => targetTag !== tag);
|
||||
if (extraTags.length > 0) {
|
||||
core.setFailed(
|
||||
`Package version ${target.id} also carries tag(s) ${extraTags.map((targetTag) => `"${targetTag}"`).join(", ")}; refusing cleanup.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await github.rest.packages.deletePackageVersionForOrg({
|
||||
package_type: "container",
|
||||
package_name: packageName,
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
title: Widgets and integrations
|
||||
tags:
|
||||
- Developer
|
||||
- Technical documentation
|
||||
- Widgets
|
||||
- Integrations
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
Homarr keeps feature registration explicit and typed. Feature registration has no generator or generated registry.
|
||||
A new feature is available when its typed metadata and lazy loader are registered.
|
||||
|
||||
## Add a widget
|
||||
|
||||
Keep the UI, options, helpers, and tests together in `packages/widgets/src/<widget>/`. The usual folder only needs an
|
||||
`index.ts` definition and a lazily imported `component.tsx`; complex widgets can add local files as needed.
|
||||
|
||||
1. Add the stable kind to `packages/definitions/src/widget.ts` and its documentation slug to the direct typed map in
|
||||
`packages/definitions/src/docs/widget-doc-slugs.ts`.
|
||||
2. Export `definition` and `componentLoader` from `packages/widgets/src/<widget>/index.ts` with
|
||||
`createWidgetDefinition`. The local definition owns its icon, query keys, refresh interval, options, matchers, and
|
||||
errors. Keep the component behind `withDynamicImport` so adding a widget does not load it on every dashboard.
|
||||
3. For an integration-backed widget, add its integration kinds and selection rules once to
|
||||
`packages/definitions/src/widget-integration-map.ts`, then spread `getWidgetIntegrationConfig(kind)` into the local
|
||||
definition. This server-safe config is also the API authorization boundary, avoiding a package cycle without
|
||||
duplicating widget metadata.
|
||||
4. Register the widget in both explicit literal loader maps and the type-only module map in
|
||||
`packages/widgets/src/registry.ts`, then add its icon to `packages/ui/src/widget-icons.ts`. The module and component
|
||||
loaders must both use literal import paths. Keeping them separate lets a cold widget load request its definition
|
||||
and component chunks in parallel; the type-only map preserves exact option and component inference. Keep all three
|
||||
maps in the same order so drift is obvious in review.
|
||||
5. If the widget needs server data, add its tRPC router under `packages/api/src/router/widgets/` and add its explicit
|
||||
lazy import to `packages/api/src/router/widgets/index.ts`. Keep upstream requests and Redis response caching in
|
||||
`@homarr/request-handler`. Use the widget integration middleware for integration-backed procedures.
|
||||
6. Add canonical documentation in `apps/docs/docs/widgets/<slug>/`.
|
||||
|
||||
Weather is the small integration-free example. Downloads shows integration selection, polling, server caching,
|
||||
mutations, partial upstream failures, and a larger options surface.
|
||||
|
||||
## Add an integration
|
||||
|
||||
Keep the HTTP client and its capability-specific types in `packages/integrations/src/<integration>/`.
|
||||
|
||||
1. Add the typed definition to `packages/definitions/src/integration.ts`, including credential alternatives,
|
||||
categories, documentation slug, ports, Docker aliases, and onboarding metadata when relevant.
|
||||
2. Add an explicit lazy creator to `packages/integrations/src/base/creator.ts`. The literal import keeps the
|
||||
implementation out of the initial server bundle and makes the registration easy to find.
|
||||
3. Implement only the interfaces required by the widgets that use the service.
|
||||
4. Add canonical documentation in `apps/docs/docs/integrations/<slug>/`. Its `index.ts` must satisfy
|
||||
`IntegrationDefinition` so invalid metadata fails type checking without widening the object. Use a type-only import
|
||||
and `satisfies IntegrationDefinition`; do not cast the object.
|
||||
|
||||
Beszel is a compact example with several widgets sharing one integration client.
|
||||
|
||||
## Write user documentation
|
||||
|
||||
Write for technically capable self-hosters. Keep each page short and operational.
|
||||
|
||||
- Start with one sentence describing what the integration or widget adds to Homarr.
|
||||
- Include prerequisites, credentials, permissions, destructive actions, and non-obvious limits only when they matter.
|
||||
- Use numbered steps only when order matters. Prefer the exact UI labels a user must select.
|
||||
- Do not add marketing, implementation history, architecture internals, or explanations of basic self-hosting concepts.
|
||||
- End when the user can configure and verify the feature; do not repeat the same information in a summary.
|
||||
|
||||
The usual page needs only an introduction and a `Configuration` section. Add `Notes` or `Troubleshooting` only for
|
||||
real caveats.
|
||||
|
||||
## Data and errors
|
||||
|
||||
- TanStack Query owns browser caching and refresh behavior. Redis owns shared server response caching for bounded key
|
||||
spaces. Public handlers whose keys come from arbitrary URLs, domains, symbols, or coordinates stay in the bounded
|
||||
process-local cache.
|
||||
- Cache raw service responses, never authorization decisions or decrypted credentials. Integration-backed cache keys
|
||||
include keyed credential fingerprints and the complete integration identity, and are invalidated after mutations.
|
||||
The fingerprints change with credentials without exposing their values in Redis keys.
|
||||
- Give upstream requests a finite deadline and pass the request handler's `AbortSignal` into clients that support it.
|
||||
Admission is bounded per handler. Request deduplication and distributed lock ownership remain active until the
|
||||
upstream settles or a bounded cleanup grace expires, while unresolved upstream work stays separately capped.
|
||||
- Keep validation and authorization on the server. Client-side checks improve UX but do not replace tRPC guards.
|
||||
- Use the shared logger with safe identifiers, operation names, duration, and the original error cause. Never log
|
||||
secrets or full upstream payloads.
|
||||
- Multi-integration reads should return successful providers when one service fails and surface a clear error when all
|
||||
providers fail.
|
||||
|
||||
Registration files intentionally contain boring, explicit imports. This small amount of repetition preserves exact
|
||||
TypeScript inference, gives Next.js discoverable lazy chunks, and keeps Ctrl+click navigation reliable.
|
||||
|
||||
## Registry imports
|
||||
|
||||
Use `@homarr/widgets/manifest` to load registered widgets. `loadWidgetDefinition` is the metadata-only path;
|
||||
`loadWidgetComponent` loads only the UI module; and `loadWidgetResources` requests both paths in parallel. These
|
||||
loaders cache in-flight and successful promises and remove rejected promises so a later request can retry.
|
||||
|
||||
The former root `widgetImports` and `loadWidgetDynamic` exports are intentionally not compatibility aliases.
|
||||
`widgetImports` synchronously imported every widget module, and `loadWidgetDynamic` depended on that eager map.
|
||||
Restoring them at the package root would defeat lazy definition chunks or add client-only runtime dependencies to
|
||||
every root consumer. Migrate metadata consumers to `loadWidgetDefinition`, renderers to `loadWidgetResources`, and
|
||||
type-only registry consumers from the removed internal `WidgetImportRecord` helper to the root `WidgetImports` type.
|
||||
|
||||
`@homarr/widgets/catalog` remains as a deprecated compatibility subpath for `widgetCatalogIcons`. New imports should
|
||||
use `@homarr/ui/widget-icons`, which is the canonical server-safe icon catalog.
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { IntegrationDefinition } from "@site/src/types";
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const beszelIntegration = {
|
||||
export const beszelIntegration: IntegrationDefinition = {
|
||||
name: "Beszel",
|
||||
description:
|
||||
"Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
path: "../../integrations/beszel",
|
||||
} satisfies IntegrationDefinition;
|
||||
};
|
||||
@@ -113,13 +113,8 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
|
||||
- Set desktop and mobile home boards for the current user
|
||||
- Configure global board defaults such as public home boards and status behavior
|
||||
|
||||
Creating, duplicating, or reconfiguring a widget validates its integration selection on the server. The caller needs
|
||||
use access for each newly linked integration; unchanged links remain editable through normal board permissions.
|
||||
|
||||
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
|
||||
|
||||
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
## Docker action payload migration
|
||||
|
||||
@@ -133,21 +133,20 @@ On larger canvases, the complete board grows together like browser zoom.
|
||||
The complete canvas always fits the available width and never adds horizontal board scrolling.
|
||||
|
||||
- **Mobile** starts at `0px` and uses three columns on new boards.
|
||||
- **Base** is the main layout and starts at `768px` on new boards.
|
||||
- **Base** is the widest layout and starts at `768px` on new boards.
|
||||
|
||||
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed
|
||||
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any
|
||||
unused breakpoint above Mobile.
|
||||
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between
|
||||
their breakpoints.
|
||||
|
||||
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in
|
||||
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an
|
||||
available breakpoint, and you can then change the breakpoint, columns, and rails.
|
||||
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a
|
||||
free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails.
|
||||
|
||||
Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three
|
||||
columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
|
||||
layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest
|
||||
becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them.
|
||||
Duplicate breakpoints are normalized without discarding saved positions.
|
||||
layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain
|
||||
custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions.
|
||||
|
||||
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
|
||||
|
||||
@@ -183,8 +182,7 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
|
||||
#### Breakpoint
|
||||
|
||||
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
|
||||
`0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the
|
||||
eligible layout with the highest breakpoint, regardless of its role.
|
||||
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
|
||||
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
|
||||
fallback to select a layout.
|
||||
|
||||
@@ -273,8 +271,6 @@ Expanding it smoothly restores those bounds and moves following items out of the
|
||||
|
||||
### Background
|
||||
|
||||
When a background image is set, it always covers at least the full browser window height, even if the board's content is shorter (for example when a Container is collapsed).
|
||||
|
||||
#### Background image URL
|
||||
|
||||
Homarr can display a background image behind apps and widgets.
|
||||
|
||||
@@ -128,26 +128,6 @@ You can decide if app status is disabled on all boards.
|
||||
|
||||
You can set the default color scheme for users without their own color scheme.
|
||||
|
||||
## Branding
|
||||
|
||||
Branding settings let administrators adapt Homarr to the identity and visual style of their installation. Changes apply across the application and authentication pages after you save the settings.
|
||||
|
||||
### Identity
|
||||
|
||||
- **Application name** replaces the default Homarr name in page titles and branded surfaces.
|
||||
- **Logo** is used in the header and other application branding.
|
||||
- **Favicon** controls the browser tab icon. When it is empty, Homarr falls back to the configured logo.
|
||||
|
||||
### Colors and corners
|
||||
|
||||
Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements.
|
||||
|
||||
### Sign-in page
|
||||
|
||||
You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting.
|
||||
|
||||
Use an image URL that every user can load before signing in. A URL that requires an authenticated Homarr session will not work on the sign-in page.
|
||||
|
||||
## Culture
|
||||
|
||||
### Default language
|
||||
|
||||
@@ -67,8 +67,6 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai
|
||||
- **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page
|
||||
- **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni
|
||||
|
||||
Homarr passes the configured URL directly to the browser. The iframe sandbox and configured permissions still apply.
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={iframeWidget.configuration} />
|
||||
|
||||
@@ -16,7 +16,6 @@ import TabItem from "@theme/TabItem";
|
||||
import { mediaRequestListWidget } from ".";
|
||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
import { seerrIntegration } from "@site/docs/integrations/seerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
||||
|
||||
@@ -40,9 +39,6 @@ import mediaRequestList from "./img/list.png";
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: seerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -50,7 +46,6 @@ import mediaRequestList from "./img/list.png";
|
||||
|
||||
- Approve / decline a request by clicking on the thumbs up or down icon.
|
||||
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
|
||||
- When no requests exist yet, the widget offers the same media search action instead of reporting an integration error.
|
||||
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
@@ -16,7 +16,6 @@ import TabItem from "@theme/TabItem";
|
||||
import { mediaRequestStatsWidget } from ".";
|
||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
import { seerrIntegration } from "@site/docs/integrations/seerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
|
||||
|
||||
@@ -32,9 +31,6 @@ Provides an overview of media requests, including totals, availability, approval
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: seerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -47,7 +43,6 @@ import mediaRequestStats from "./img/stats.png";
|
||||
### Interactions
|
||||
|
||||
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
|
||||
- When no requests exist yet, the widget offers media search and explains when statistics will appear.
|
||||
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
|
||||
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
|
||||
including the latest result, historical averages, and a live chart of the last 12 hours.
|
||||
|
||||
The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
|
||||
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
|
||||
|
||||
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -2,17 +2,13 @@ import type { LoadContext, Plugin } from "@docusaurus/types";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { integrationDocSlugs } from "@homarr/definitions/docs/integration-doc-slugs";
|
||||
import { widgetDocSlugs } from "@homarr/definitions/docs/widget-doc-slugs";
|
||||
import { getIntegrationDocumentationSlug, integrationKinds } from "@homarr/definitions/integration";
|
||||
|
||||
const documentationEntries = [
|
||||
{
|
||||
label: "Integration",
|
||||
folder: "integrations",
|
||||
entries: integrationKinds.map((kind) => [kind, getIntegrationDocumentationSlug(kind)] as const),
|
||||
},
|
||||
{ label: "Widget", folder: "widgets", entries: Object.entries(widgetDocSlugs) },
|
||||
] as const;
|
||||
const slugMaps = {
|
||||
Integration: { slugs: integrationDocSlugs, folder: "integrations" },
|
||||
Widget: { slugs: widgetDocSlugs, folder: "widgets" },
|
||||
} as const;
|
||||
|
||||
export default function validateDocsCoveragePlugin(context: LoadContext): Plugin {
|
||||
return {
|
||||
@@ -21,8 +17,8 @@ export default function validateDocsCoveragePlugin(context: LoadContext): Plugin
|
||||
const docsDir = path.resolve(context.siteDir, "docs");
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const { label, folder, entries } of documentationEntries) {
|
||||
for (const [kind, slug] of entries) {
|
||||
for (const [label, { slugs, folder }] of Object.entries(slugMaps)) {
|
||||
for (const [kind, slug] of Object.entries(slugs)) {
|
||||
if (!slug) continue;
|
||||
if (!fs.existsSync(path.join(docsDir, folder, slug))) {
|
||||
missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TablerIcon } from "@tabler/icons-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { TablerIcon } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface IntegrationDefinition {
|
||||
name: string;
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
"@rexa-developer/tiks": "catalog:",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/query-sync-storage-persister": "catalog:",
|
||||
"@tanstack/react-devtools": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "catalog:",
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { dehydrate, QueryClient } from "@tanstack/react-query";
|
||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||
import { persistQueryClientRestore } from "@tanstack/react-query-persist-client";
|
||||
import { parse, stringify } from "superjson";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createSessionQueryPersistence,
|
||||
createSessionQueryPersister,
|
||||
getQueryPersistenceStorageKey,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxDataBytes,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
|
||||
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => {
|
||||
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
queryClient.setQueryData(queryKey, data, { updatedAt });
|
||||
const query = queryClient.getQueryCache().find({ queryKey });
|
||||
if (!query) throw new Error("Expected query to be present");
|
||||
return { query, queryClient };
|
||||
@@ -34,98 +37,314 @@ const createMemoryStorage = (): Storage => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("dashboard query persistence", () => {
|
||||
test("keeps usable dashboard data and skips volatile or unrelated queries", () => {
|
||||
const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 });
|
||||
describe("widget query persistence", () => {
|
||||
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
|
||||
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
|
||||
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
|
||||
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
||||
const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []);
|
||||
const customApi = createSuccessfulQuery([["widget", "customApi", "getData"], { type: "query" }], {
|
||||
apiKey: "secret",
|
||||
});
|
||||
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
|
||||
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
||||
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
|
||||
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
|
||||
});
|
||||
const pendingClient = new QueryClient();
|
||||
const pending = pendingClient.getQueryCache().build(pendingClient, {
|
||||
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
});
|
||||
const failed = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], []);
|
||||
failed.query.setState({ ...failed.query.state, error: new Error("refresh failed"), status: "error" });
|
||||
|
||||
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
|
||||
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
|
||||
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(beszel.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(customApi.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(failed.query)).toBe(true);
|
||||
});
|
||||
|
||||
test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => {
|
||||
vi.useFakeTimers();
|
||||
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
|
||||
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
|
||||
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
|
||||
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
|
||||
|
||||
const userAKey = getQueryPersistenceStorageKey("user-a");
|
||||
window.sessionStorage.setItem(userAKey, "a");
|
||||
createSessionQueryPersister("user-a").removeClient();
|
||||
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
|
||||
});
|
||||
|
||||
test("evicts every other scope so signing out cannot leave data behind", () => {
|
||||
const userAKey = getQueryPersistenceStorageKey("user-a");
|
||||
const anonymousKey = getQueryPersistenceStorageKey(null);
|
||||
window.sessionStorage.setItem(userAKey, "a");
|
||||
window.sessionStorage.setItem(anonymousKey, "anonymous");
|
||||
window.sessionStorage.setItem("unrelated", "kept");
|
||||
|
||||
createSessionQueryPersister("user-b");
|
||||
|
||||
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
|
||||
expect(window.sessionStorage.getItem(anonymousKey)).toBeNull();
|
||||
expect(window.sessionStorage.getItem("unrelated")).toBe("kept");
|
||||
});
|
||||
|
||||
test("never restores another session scope", async () => {
|
||||
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
||||
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
|
||||
const userAPersister = createSessionQueryPersister("user-a");
|
||||
userAPersister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient),
|
||||
});
|
||||
userAPersister.flush();
|
||||
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
|
||||
|
||||
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("stops persisting once the provider tears the cache down", async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const userA = createSessionQueryPersistence("user-a", storage);
|
||||
const userB = createSessionQueryPersistence("user-b", storage);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
||||
const persister = createSessionQueryPersister("user-a", storage);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
||||
temp: 21,
|
||||
});
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient),
|
||||
});
|
||||
persister.flush();
|
||||
persister.stop();
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: { mutations: [], queries: [] },
|
||||
});
|
||||
persister.flush();
|
||||
|
||||
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("removes the cache instead of storing an empty one", () => {
|
||||
const scope = "user-a";
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
const persister = createSessionQueryPersister(scope);
|
||||
window.sessionStorage.setItem(key, "stale");
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: { mutations: [], queries: [] },
|
||||
});
|
||||
persister.flush();
|
||||
|
||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
|
||||
vi.useFakeTimers();
|
||||
const scope = "user-a";
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
const persister = createSessionQueryPersister(scope);
|
||||
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
||||
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
||||
});
|
||||
const cachedQuery = queryClient.getQueryCache().find({
|
||||
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||
});
|
||||
if (!cachedQuery) throw new Error("Expected cached query to be present");
|
||||
cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" });
|
||||
queryClient.getMutationCache().build(
|
||||
queryClient,
|
||||
{ mutationKey: ["integration", "create"], mutationFn: async () => undefined },
|
||||
{
|
||||
context: undefined,
|
||||
data: undefined,
|
||||
error: null,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
isPaused: true,
|
||||
status: "pending",
|
||||
submittedAt: Date.now(),
|
||||
variables: { apiKey: "secret" },
|
||||
},
|
||||
);
|
||||
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
|
||||
expect(shouldPersistDashboardQuery(query)).toBe(true);
|
||||
const persistedClient: PersistedClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient, userA.dehydrateOptions),
|
||||
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
|
||||
};
|
||||
|
||||
expect(persistedClient.clientState.mutations).toHaveLength(0);
|
||||
|
||||
await userA.persister.persistClient(persistedClient);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
const restored = await userA.persister.restoreClient();
|
||||
expect(restored?.clientState.queries[0]?.state.data).toEqual({
|
||||
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
||||
});
|
||||
persister.persistClient(persistedClient);
|
||||
persister.flush();
|
||||
const restored = await persister.restoreClient();
|
||||
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
|
||||
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
|
||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||
expect(await userB.persister.restoreClient()).toBeUndefined();
|
||||
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
|
||||
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
|
||||
|
||||
await userA.persister.removeClient();
|
||||
expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull();
|
||||
window.sessionStorage.setItem(key, "not-valid-superjson");
|
||||
expect(persister.restoreClient()).toBeUndefined();
|
||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
test("treats unavailable session storage as an empty cache", async () => {
|
||||
const blockedStorage = createMemoryStorage();
|
||||
blockedStorage.getItem = () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
};
|
||||
blockedStorage.removeItem = () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
};
|
||||
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
|
||||
const scope = "user-a";
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
const { queryClient } = createSuccessfulQuery(
|
||||
[["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||
[{ title: "safe" }],
|
||||
);
|
||||
const validQuery = dehydrate(queryClient).queries[0];
|
||||
if (!validQuery) throw new Error("Expected a dehydrated query");
|
||||
|
||||
const persistence = createSessionQueryPersistence("blocked", blockedStorage);
|
||||
expect(await persistence.persister.restoreClient()).toBeUndefined();
|
||||
expect(() => persistence.persister.removeClient()).not.toThrow();
|
||||
window.sessionStorage.setItem(
|
||||
key,
|
||||
stringify({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: {
|
||||
mutations: [{ state: { status: "pending" } }],
|
||||
queries: [
|
||||
{
|
||||
...validQuery,
|
||||
state: {
|
||||
...validQuery.state,
|
||||
error: new Error("private refresh error"),
|
||||
fetchStatus: "fetching",
|
||||
status: "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
...validQuery,
|
||||
queryHash: "unrelated",
|
||||
queryKey: [["board", "getBoardByName"], { type: "query" }],
|
||||
},
|
||||
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
|
||||
{
|
||||
...validQuery,
|
||||
queryHash: "missing-timestamp",
|
||||
state: { ...validQuery.state, dataUpdatedAt: undefined },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const restored = await createSessionQueryPersister(scope).restoreClient();
|
||||
expect(restored?.clientState.mutations).toEqual([]);
|
||||
expect(restored?.clientState.queries).toHaveLength(1);
|
||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
|
||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["expired", 1, queryPersistenceBuster],
|
||||
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
|
||||
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
|
||||
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
|
||||
const scope = "user-a";
|
||||
const persister = createSessionQueryPersister(scope);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
||||
temp: 21,
|
||||
});
|
||||
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
|
||||
persister.flush();
|
||||
|
||||
const restoredClient = new QueryClient();
|
||||
await persistQueryClientRestore({
|
||||
queryClient: restoredClient,
|
||||
persister,
|
||||
maxAge: 5 * 60 * 1_000,
|
||||
buster: queryPersistenceBuster,
|
||||
});
|
||||
|
||||
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
|
||||
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
|
||||
});
|
||||
|
||||
test("quota retry writes the cache after evicting the oldest query", async () => {
|
||||
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
|
||||
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
|
||||
const storage = createMemoryStorage();
|
||||
const setItem = storage.setItem.bind(storage);
|
||||
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
|
||||
const client = parse<PersistedClient>(value);
|
||||
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
|
||||
setItem(key, value);
|
||||
});
|
||||
const persistedClient: PersistedClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
|
||||
},
|
||||
};
|
||||
const persister = createSessionQueryPersister("user-a", storage);
|
||||
|
||||
persister.persistClient(persistedClient);
|
||||
persister.flush();
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledTimes(2);
|
||||
const restored = await persister.restoreClient();
|
||||
expect(restored?.clientState.queries).toHaveLength(1);
|
||||
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
|
||||
});
|
||||
|
||||
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
|
||||
vi.useFakeTimers();
|
||||
const storage = createMemoryStorage();
|
||||
const setItem = vi.spyOn(storage, "setItem");
|
||||
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
||||
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
|
||||
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
|
||||
const persister = createSessionQueryPersister("user-a", storage);
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(first.queryClient),
|
||||
});
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(latest.queryClient),
|
||||
});
|
||||
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
persister.flush();
|
||||
expect(setItem).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(setItem).toHaveBeenCalledOnce();
|
||||
|
||||
const restored = await persister.restoreClient();
|
||||
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
|
||||
});
|
||||
|
||||
test("treats blocked storage operations as an empty cache", async () => {
|
||||
const blockedStorage = {
|
||||
getItem: () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
},
|
||||
} as unknown as Storage;
|
||||
const persister = createSessionQueryPersister("blocked", blockedStorage);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
|
||||
|
||||
expect(persister.restoreClient()).toBeUndefined();
|
||||
expect(() => {
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient),
|
||||
});
|
||||
persister.flush();
|
||||
}).not.toThrow();
|
||||
expect(() => persister.removeClient()).not.toThrow();
|
||||
});
|
||||
|
||||
test("treats an unavailable session storage getter as an empty cache", async () => {
|
||||
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
});
|
||||
|
||||
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
|
||||
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
||||
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
|
||||
import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
|
||||
import { parse, stringify } from "superjson";
|
||||
|
||||
import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||
|
||||
export const queryPersistenceBuster = "v6-dashboard-data";
|
||||
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
|
||||
export const queryPersistenceMaxDataBytes = 256 * 1024;
|
||||
export const queryPersistenceBuster = "v2-session-superjson";
|
||||
|
||||
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
||||
|
||||
@@ -15,33 +16,95 @@ export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
||||
|
||||
interface PersistableQuery {
|
||||
queryKey: QueryKey;
|
||||
state: { data: unknown; status: string };
|
||||
state: {
|
||||
data: unknown;
|
||||
dataUpdatedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const budgetVerdictCache = new WeakMap<object, boolean>();
|
||||
|
||||
const measureFitsBudget = (data: unknown) => {
|
||||
try {
|
||||
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const fitsStorageBudget = (data: unknown) => {
|
||||
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
|
||||
|
||||
const cached = budgetVerdictCache.get(data);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const verdict = measureFitsBudget(data);
|
||||
budgetVerdictCache.set(data, verdict);
|
||||
return verdict;
|
||||
};
|
||||
|
||||
const holdsDashboardData = (query: PersistableQuery) =>
|
||||
query.state.data !== undefined &&
|
||||
Number.isFinite(query.state.dataUpdatedAt) &&
|
||||
query.state.dataUpdatedAt > 0 &&
|
||||
isWidgetDataQueryKey(query.queryKey);
|
||||
|
||||
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
|
||||
query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
|
||||
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
|
||||
|
||||
const serializePersistedClient = (client: PersistedClient) =>
|
||||
stringify({
|
||||
...client,
|
||||
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
|
||||
|
||||
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
|
||||
...query,
|
||||
state: {
|
||||
...query.state,
|
||||
error: null,
|
||||
errorUpdateCount: 0,
|
||||
errorUpdatedAt: 0,
|
||||
fetchFailureCount: 0,
|
||||
fetchFailureReason: null,
|
||||
fetchStatus: "idle",
|
||||
isInvalidated: true,
|
||||
status: "success",
|
||||
},
|
||||
});
|
||||
|
||||
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
|
||||
...client,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
queries: client.clientState.queries.map(sanitizePersistedQuery),
|
||||
},
|
||||
});
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
|
||||
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
|
||||
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
|
||||
|
||||
return holdsDashboardData(value as unknown as PersistableQuery);
|
||||
};
|
||||
|
||||
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
|
||||
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
|
||||
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
|
||||
|
||||
return {
|
||||
timestamp: value.timestamp as number,
|
||||
buster: value.buster,
|
||||
clientState: {
|
||||
...client.clientState,
|
||||
queries: client.clientState.queries.map((query) => ({
|
||||
...query,
|
||||
state: {
|
||||
...query.state,
|
||||
error: null,
|
||||
errorUpdateCount: 0,
|
||||
errorUpdatedAt: 0,
|
||||
fetchFailureCount: 0,
|
||||
fetchFailureReason: null,
|
||||
status: "success",
|
||||
},
|
||||
})),
|
||||
mutations: [],
|
||||
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const getSessionStorage = () => {
|
||||
const getSessionStorage = (): Storage | undefined => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
try {
|
||||
@@ -51,47 +114,113 @@ const getSessionStorage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const protectStorage = (storage: Storage | undefined) => {
|
||||
if (!storage) return undefined;
|
||||
const removeStorageItem = (storage: Storage | undefined, key: string) => {
|
||||
try {
|
||||
storage?.removeItem(key);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
|
||||
if (!storage) return;
|
||||
|
||||
try {
|
||||
const foreignKeys: string[] = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const storedKey = storage.key(index);
|
||||
if (storedKey === null || storedKey === activeKey) continue;
|
||||
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
|
||||
}
|
||||
|
||||
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const createSessionQueryPersister = (
|
||||
scope: string | null,
|
||||
storage: Storage | undefined = getSessionStorage(),
|
||||
): SessionQueryPersister => {
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
removeForeignScopes(storage, key);
|
||||
let pendingClient: PersistedClient | undefined;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let stopped = false;
|
||||
|
||||
const persistNow = (client: PersistedClient) => {
|
||||
if (!storage) return;
|
||||
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
|
||||
let errorCount = 0;
|
||||
|
||||
if (candidate.clientState.queries.length === 0) {
|
||||
removeStorageItem(storage, key);
|
||||
return;
|
||||
}
|
||||
|
||||
while (candidate) {
|
||||
try {
|
||||
storage.setItem(key, stringify(candidate));
|
||||
return;
|
||||
} catch (error) {
|
||||
errorCount += 1;
|
||||
candidate = removeOldestQuery({
|
||||
persistedClient: candidate,
|
||||
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
|
||||
errorCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearPersistTimer = () => {
|
||||
if (persistTimer === undefined) return;
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
clearPersistTimer();
|
||||
const client = pendingClient;
|
||||
pendingClient = undefined;
|
||||
if (client) persistNow(client);
|
||||
};
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
try {
|
||||
return storage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
persistClient(client) {
|
||||
if (!storage || stopped) return;
|
||||
pendingClient = client;
|
||||
if (persistTimer !== undefined) return;
|
||||
persistTimer = setTimeout(flush, 0);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
storage.setItem(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
restoreClient() {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
const storedValue = storage?.getItem(key);
|
||||
if (!storedValue) return undefined;
|
||||
|
||||
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
|
||||
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
|
||||
removeStorageItem(storage, key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return client;
|
||||
} catch {
|
||||
removeStorageItem(storage, key);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
removeClient() {
|
||||
clearPersistTimer();
|
||||
pendingClient = undefined;
|
||||
removeStorageItem(storage, key);
|
||||
},
|
||||
flush,
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearPersistTimer();
|
||||
pendingClient = undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type SessionQueryPersistence = PersistQueryClientProviderProps["persistOptions"];
|
||||
|
||||
export const createSessionQueryPersistence = (
|
||||
scope: string | null,
|
||||
storage: Storage | undefined = getSessionStorage(),
|
||||
): SessionQueryPersistence => ({
|
||||
persister: createSyncStoragePersister({
|
||||
storage: protectStorage(storage),
|
||||
key: getQueryPersistenceStorageKey(scope),
|
||||
serialize: serializePersistedClient,
|
||||
deserialize: parse,
|
||||
retry: removeOldestQuery,
|
||||
}),
|
||||
maxAge: queryCacheDefaultGcTimeMs,
|
||||
buster: queryPersistenceBuster,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateMutation: () => false,
|
||||
shouldDehydrateQuery: shouldPersistDashboardQuery,
|
||||
},
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
@@ -25,8 +25,6 @@ import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
dashboardSupportingQueryPolicies,
|
||||
isTrpcForbiddenError,
|
||||
isWidgetDataQueryKey,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
@@ -38,8 +36,13 @@ import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
import { createSessionQueryPersistence } from "./query-persistence";
|
||||
import type { SessionQueryPersistence } from "./query-persistence";
|
||||
import {
|
||||
createSessionQueryPersister,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxAgeMs,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
import type { SessionQueryPersister } from "./query-persistence";
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const DevelopmentTools =
|
||||
@@ -72,11 +75,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
const { data: session } = useSession();
|
||||
const sessionQueryScope = getSessionQueryScope(session);
|
||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||
const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope));
|
||||
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
||||
const handleScopeChange = useCallback(() => {
|
||||
void queryPersistence.persister.removeClient();
|
||||
void queryPersister.removeClient();
|
||||
reloadPage();
|
||||
}, [queryPersistence]);
|
||||
}, [queryPersister]);
|
||||
|
||||
return (
|
||||
<SessionQueryScopeGuard
|
||||
@@ -84,7 +87,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={handleScopeChange}
|
||||
>
|
||||
<ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
|
||||
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
@@ -93,8 +96,8 @@ const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({
|
||||
children,
|
||||
queryPersistence,
|
||||
}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => {
|
||||
queryPersister,
|
||||
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
@@ -111,13 +114,6 @@ const ScopedTRPCReactProvider = ({
|
||||
);
|
||||
const [queryClient] = useState(() => {
|
||||
const client = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError(error, query) {
|
||||
if (!isTrpcForbiddenError(error) || !isWidgetDataQueryKey(query.queryKey)) return;
|
||||
void queryPersistence.persister.removeClient();
|
||||
query.setState({ data: undefined, dataUpdatedAt: 0, isInvalidated: true });
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
@@ -145,14 +141,10 @@ const ScopedTRPCReactProvider = ({
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
for (const queryDefaults of widgetQueryRefetchIntervals) {
|
||||
const policy: { refetchInterval?: number | false; staleTime?: number } = {};
|
||||
if (queryDefaults.intervalSeconds === null) policy.refetchInterval = false;
|
||||
if (typeof queryDefaults.intervalSeconds === "number") {
|
||||
policy.refetchInterval = queryDefaults.intervalSeconds * 1000;
|
||||
}
|
||||
if ("staleTimeSeconds" in queryDefaults) policy.staleTime = queryDefaults.staleTimeSeconds * 1000;
|
||||
client.setQueryDefaults(queryDefaults.queryKey, policy);
|
||||
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
|
||||
client.setQueryDefaults(queryKey, {
|
||||
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
|
||||
});
|
||||
}
|
||||
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
|
||||
client.setQueryDefaults(queryKey, policy);
|
||||
@@ -160,6 +152,15 @@ const ScopedTRPCReactProvider = ({
|
||||
return client;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const flush = () => queryPersister.flush();
|
||||
window.addEventListener("pagehide", flush);
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", flush);
|
||||
flush();
|
||||
queryPersister.stop();
|
||||
};
|
||||
}, [queryPersister]);
|
||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||
|
||||
const trpcClient = useMemo(() => {
|
||||
@@ -210,7 +211,15 @@ const ScopedTRPCReactProvider = ({
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: queryPersister,
|
||||
maxAge: queryPersistenceMaxAgeMs,
|
||||
buster: queryPersistenceBuster,
|
||||
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
|
||||
}}
|
||||
>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||
</PersistQueryClientProvider>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards
|
||||
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import classes from "./_layout-preview.module.css";
|
||||
|
||||
@@ -40,8 +40,6 @@ interface Props {
|
||||
saveSettingsAsync: () => Promise<FormValues | null>;
|
||||
}
|
||||
|
||||
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
|
||||
|
||||
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tLayout = useI18n("layout");
|
||||
@@ -103,9 +101,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
|
||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
||||
const displayedLayouts = form.values.layouts
|
||||
.map((layout, index) => ({ layout, index }))
|
||||
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||
@@ -139,9 +134,13 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{displayedLayouts.map(({ layout, index }) => {
|
||||
{form.values.layouts.map((layout, index) => {
|
||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||
const customBreakpoints = form.values.layouts
|
||||
.filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id)
|
||||
.map((candidate) => candidate.breakpoint);
|
||||
const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768;
|
||||
|
||||
return (
|
||||
<Fieldset
|
||||
@@ -191,8 +190,12 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
}
|
||||
disabled={layout.role === "mobile"}
|
||||
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
||||
min={layout.role === "mobile" ? 0 : 1}
|
||||
max={32767}
|
||||
min={
|
||||
layout.role === "base"
|
||||
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
|
||||
: 1
|
||||
}
|
||||
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -114,15 +114,12 @@ export default async function Layout(props: {
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const assistantAvailabilityPromise = sessionPromise.then(async (session): Promise<AssistantAvailability> => {
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
|
||||
if (!session) return "unauthenticated";
|
||||
|
||||
try {
|
||||
const availability = await api.assistant.getAvailability();
|
||||
return availability.enabled ? "enabled" : "unconfigured";
|
||||
} catch {
|
||||
return "error";
|
||||
}
|
||||
return api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||
.catch(() => "error");
|
||||
});
|
||||
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
|
||||
sessionPromise,
|
||||
|
||||
@@ -10,17 +10,24 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
interface CopyAiPromptButtonBaseProps {
|
||||
rawResponse?: string | null;
|
||||
request?: string | null;
|
||||
documentationUrl?: string | null;
|
||||
getDraft: () => CustomWidgetAiDraft;
|
||||
getDiagnostics: () => readonly CustomWidgetAiDiagnostic[];
|
||||
}
|
||||
|
||||
type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps &
|
||||
({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) &
|
||||
(
|
||||
| { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never }
|
||||
| { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] }
|
||||
);
|
||||
|
||||
export const CopyAiPromptButton = ({
|
||||
rawResponse,
|
||||
draft,
|
||||
getDraft,
|
||||
diagnostics,
|
||||
getDiagnostics,
|
||||
request,
|
||||
documentationUrl,
|
||||
@@ -28,14 +35,23 @@ export const CopyAiPromptButton = ({
|
||||
const t = useI18n("customWidget");
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
|
||||
const readDraft = () => {
|
||||
if (getDraft) return getDraft();
|
||||
return draft;
|
||||
};
|
||||
const readDiagnostics = () => {
|
||||
if (getDiagnostics) return getDiagnostics();
|
||||
return diagnostics;
|
||||
};
|
||||
|
||||
const handleAssistant = () => {
|
||||
const prompt = buildCustomWidgetAssistantPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
getDraft(),
|
||||
readDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
getDiagnostics(),
|
||||
readDiagnostics(),
|
||||
);
|
||||
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
|
||||
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
|
||||
@@ -46,10 +62,10 @@ export const CopyAiPromptButton = ({
|
||||
const value = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
getDraft(),
|
||||
readDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
getDiagnostics(),
|
||||
readDiagnostics(),
|
||||
);
|
||||
await navigator.clipboard.writeText(value);
|
||||
showSuccessNotification({
|
||||
|
||||
+1
-2
@@ -50,8 +50,7 @@ function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
const value: unknown = JSON.parse(next);
|
||||
const parsed = customWidgetDefinitionSchema.safeParse(value);
|
||||
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
|
||||
return;
|
||||
|
||||
@@ -76,8 +76,7 @@ export function parseSources(value: string): Array<CustomWidgetSource & { id: st
|
||||
|
||||
export function parseJson(value: string): unknown {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed;
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -129,9 +129,7 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
|
||||
className={classes.paneSwitcher}
|
||||
fullWidth
|
||||
value={props.mobilePane}
|
||||
onChange={(value) => {
|
||||
if (value === "configure" || value === "preview") props.setMobilePane(value);
|
||||
}}
|
||||
onChange={(value) => props.setMobilePane(value as MobilePane)}
|
||||
data={[
|
||||
{ value: "configure", label: w("configure") },
|
||||
{ value: "preview", label: w("section.preview") },
|
||||
|
||||
+1
-3
@@ -173,9 +173,7 @@ export function RequestBodyEditor({
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
let parsed: unknown;
|
||||
if (next.trim()) parsed = JSON.parse(next);
|
||||
onChange(parsed);
|
||||
onChange(next.trim() ? (JSON.parse(next) as unknown) : undefined);
|
||||
setError(undefined);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("invalidJson"));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack } from "@mantine/core";
|
||||
|
||||
import { mcpRouter } from "@homarr/api/mcp";
|
||||
import { openApiDocument } from "@homarr/api/open-api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -15,24 +16,40 @@ import { ScalarApiReference } from "./components/scalar-api-reference";
|
||||
|
||||
import type { McpToolGroup } from "./components/api-page-tabs";
|
||||
|
||||
let cachedToolGroups: McpToolGroup[] | null = null;
|
||||
|
||||
function getMcpToolGroups(): McpToolGroup[] {
|
||||
if (cachedToolGroups) return cachedToolGroups;
|
||||
|
||||
const tools = extractMcpTools();
|
||||
|
||||
const procedures = (mcpRouter as any)._def.procedures as Record<string, { _def?: { type?: string } }>;
|
||||
const procedureTypeMap = new Map<string, "query" | "mutation">();
|
||||
for (const [key, proc] of Object.entries(procedures)) {
|
||||
const type = proc?._def?.type;
|
||||
if (type === "query" || type === "mutation") {
|
||||
procedureTypeMap.set(key, type);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = new Map<string, McpToolGroup["tools"]>();
|
||||
for (const tool of tools) {
|
||||
const namespace = tool.pathInRouter[0] ?? "other";
|
||||
if (!groups.has(namespace)) {
|
||||
groups.set(namespace, []);
|
||||
}
|
||||
const procedureKey = tool.pathInRouter.join(".");
|
||||
groups.get(namespace)?.push({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
type: tool.type,
|
||||
type: procedureTypeMap.get(procedureKey) ?? "query",
|
||||
});
|
||||
}
|
||||
return Array.from(groups.entries()).map(([namespace, items]) => ({
|
||||
cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({
|
||||
namespace,
|
||||
tools: items,
|
||||
}));
|
||||
return cachedToolGroups;
|
||||
}
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -53,14 +70,12 @@ export default async function ApiPage() {
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
const [requestHeaders, apiKeys, t, toolGroups] = await Promise.all([
|
||||
headers(),
|
||||
api.apiKeys.getAll(),
|
||||
getI18n("management.page.tool.api.tab"),
|
||||
getMcpToolGroups(),
|
||||
]);
|
||||
const requestHeaders = await headers();
|
||||
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
|
||||
const document = openApiDocument(baseUrl);
|
||||
const apiKeys = await api.apiKeys.getAll();
|
||||
const t = await getI18n("management.page.tool.api.tab");
|
||||
const toolGroups = getMcpToolGroups();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+7
-3
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface ConfigMapsTableComponentProps {
|
||||
@@ -49,7 +47,13 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl
|
||||
|
||||
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialConfigMaps),
|
||||
{
|
||||
initialData: initialConfigMaps,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import KubernetesErrorPage from "../cluster-dashboard/error";
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -85,7 +84,13 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo
|
||||
|
||||
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialIngresses),
|
||||
{
|
||||
initialData: initialIngresses,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
interface QueryWithStatus {
|
||||
state: {
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
const getErrorRefetchInterval = (query: QueryWithStatus) => {
|
||||
if (query.state.status === "error") return 30_000;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const createKubernetesResourceQueryOptions = <TData>(initialData: TData) => ({
|
||||
initialData,
|
||||
refetchOnMount: "always" as const,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: getErrorRefetchInterval,
|
||||
});
|
||||
+7
-3
@@ -15,8 +15,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface NamespacesTableComponentProps {
|
||||
@@ -68,7 +66,13 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl
|
||||
|
||||
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialNamespaces),
|
||||
{
|
||||
initialData: initialNamespaces,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import KubernetesErrorPage from "../cluster-dashboard/error";
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -98,7 +97,13 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps)
|
||||
|
||||
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialNodes),
|
||||
{
|
||||
initialData: initialNodes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface PodsTableComponentProps {
|
||||
@@ -59,7 +57,13 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
|
||||
|
||||
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialPods),
|
||||
{
|
||||
initialData: initialPods,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface SecretsTableComponentProps {
|
||||
@@ -54,7 +52,13 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen
|
||||
|
||||
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialSecrets),
|
||||
{
|
||||
initialData: initialSecrets,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -14,8 +14,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface ServicesTableComponentProps {
|
||||
@@ -73,7 +71,13 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo
|
||||
|
||||
const { data } = clientApi.kubernetes.services.getServices.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialServices),
|
||||
{
|
||||
initialData: initialServices,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface VolumesTableComponentProps {
|
||||
@@ -78,7 +76,13 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen
|
||||
|
||||
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
|
||||
{ contextId },
|
||||
createKubernetesResourceQueryOptions(initialVolumes),
|
||||
{
|
||||
initialData: initialVolumes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { widgetKinds } from "@homarr/definitions";
|
||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
|
||||
@@ -327,6 +327,20 @@ const sumCompleteStepMetric = (
|
||||
return (values as number[]).reduce((sum, value) => sum + value, 0);
|
||||
};
|
||||
|
||||
const getProcedureTypeMap = () => {
|
||||
const procedures = (
|
||||
mcpRouter as unknown as {
|
||||
["_def"]: { procedures: Record<string, { ["_def"]?: { type?: string } }> };
|
||||
}
|
||||
)["_def"].procedures;
|
||||
return new Map(
|
||||
Object.entries(procedures).flatMap(([path, procedure]) => {
|
||||
const type = procedure["_def"]?.type;
|
||||
return type === "query" || type === "mutation" ? [[path, type] as const] : [];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const waitForDemoChunkAsync = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) => {
|
||||
@@ -371,8 +385,7 @@ export async function POST(request: Request) {
|
||||
const parsed = requestSchema.safeParse(
|
||||
(() => {
|
||||
try {
|
||||
const value: unknown = JSON.parse(requestBody);
|
||||
return value;
|
||||
return JSON.parse(requestBody) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -503,13 +516,14 @@ export async function POST(request: Request) {
|
||||
const canAuthorCustomWidgets = session.user.permissions.includes("admin");
|
||||
const customWidgetAuthoringContext = getCustomWidgetAuthoringContext(incomingMessages, canAuthorCustomWidgets);
|
||||
const caller = mcpRouter.createCaller(context);
|
||||
const procedureTypes = getProcedureTypeMap();
|
||||
const omittedCustomWidgetTools = new Set<string>(customWidgetAuthoringContext.omittedToolNames);
|
||||
const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
|
||||
const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget();
|
||||
|
||||
const homarrTools = Object.fromEntries(
|
||||
mcpTools.map((mcpTool) => {
|
||||
const requiresApproval = mcpTool.type === "mutation";
|
||||
const requiresApproval = procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation";
|
||||
return [
|
||||
mcpTool.name,
|
||||
tool({
|
||||
@@ -557,9 +571,11 @@ export async function POST(request: Request) {
|
||||
}),
|
||||
) satisfies ToolSet;
|
||||
const toolApproval = Object.fromEntries(
|
||||
mcpTools
|
||||
.filter((mcpTool) => mcpTool.type === "mutation")
|
||||
.map((mcpTool) => [mcpTool.name, "user-approval" as const]),
|
||||
mcpTools.flatMap((mcpTool) =>
|
||||
procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
|
||||
? [[mcpTool.name, "user-approval" as const]]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
||||
const frontendTools = Object.fromEntries(
|
||||
|
||||
@@ -46,21 +46,18 @@ vi.mock("../_extract-tools", () => ({
|
||||
{
|
||||
name: "board_getAllBoards",
|
||||
description: "List boards",
|
||||
type: "query",
|
||||
pathInRouter: ["board", "getAllBoards"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "customWidget_list",
|
||||
description: "List Custom Widgets",
|
||||
type: "query",
|
||||
pathInRouter: ["customWidget", "list"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "customWidget_workshopSearch",
|
||||
description: "Search Workshop",
|
||||
type: "query",
|
||||
pathInRouter: ["customWidget", "workshopSearch"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
@@ -80,10 +80,18 @@ function clearAuthFailures(ip: string | null) {
|
||||
authFailures.delete(ip ?? "unknown");
|
||||
}
|
||||
|
||||
const getVisibleTools = (tools: McpTool[], isAdmin: boolean) => {
|
||||
if (isAdmin) return tools;
|
||||
return tools.filter((tool) => !tool.name.startsWith("customWidget_"));
|
||||
};
|
||||
let toolsCache: McpTool[] | null = null;
|
||||
|
||||
function getTools() {
|
||||
if (!toolsCache) {
|
||||
toolsCache = extractMcpTools();
|
||||
logger.info(`Extracted ${toolsCache.length} MCP tools`);
|
||||
}
|
||||
return toolsCache;
|
||||
}
|
||||
|
||||
const getVisibleTools = (isAdmin: boolean) =>
|
||||
isAdmin ? getTools() : getTools().filter((tool) => !tool.name.startsWith("customWidget_"));
|
||||
|
||||
function registerCustomWidgetAuthoring(server: McpServer) {
|
||||
server.registerPrompt(
|
||||
@@ -328,7 +336,7 @@ export const handleMcpRequest = async (req: NextRequest) => {
|
||||
const isAdmin = session.user.permissions.includes("admin");
|
||||
const protocolHandler = createMcpProtocolHandler({
|
||||
caller,
|
||||
tools: getVisibleTools(extractMcpTools(), isAdmin),
|
||||
tools: getVisibleTools(isAdmin),
|
||||
version: getPackageVersion(),
|
||||
instructions: SERVER_INSTRUCTIONS,
|
||||
configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined,
|
||||
|
||||
@@ -50,7 +50,6 @@ const echoValidator = z.object({ message: z.string() });
|
||||
const echoTool: McpTool = {
|
||||
name: "echo",
|
||||
description: "Echo a message",
|
||||
type: "query",
|
||||
pathInRouter: ["echo"],
|
||||
inputValidator: echoValidator,
|
||||
inputSchema: z.toJSONSchema(echoValidator),
|
||||
|
||||
@@ -2,32 +2,25 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetIntegrationSupport } from "@homarr/definitions";
|
||||
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { getAssistantWidgetConfiguration } from "./assistant-widget-tool";
|
||||
|
||||
const settings = { enableStatusByDefault: true, forceDisableStatus: false };
|
||||
const widgetDefinitions = await loadAllWidgetDefinitions();
|
||||
|
||||
const getWidgetDefinition = (kind: WidgetKind) => {
|
||||
const definition = widgetDefinitions.get(kind);
|
||||
if (!definition) throw new Error(`No definition is registered for ${kind}`);
|
||||
return definition;
|
||||
};
|
||||
|
||||
describe("getAssistantWidgetConfiguration", () => {
|
||||
test("keeps server-side integration support synchronized with widget definitions", () => {
|
||||
for (const [kind, definition] of widgetDefinitions) {
|
||||
for (const [kind, widgetImport] of Object.entries(widgetImports)) {
|
||||
const definition = widgetImport.definition;
|
||||
const expected = "supportedIntegrations" in definition ? definition.supportedIntegrations : undefined;
|
||||
const supportedIntegrations = widgetIntegrationSupport[kind];
|
||||
|
||||
expect(supportedIntegrations, kind).toEqual(expected);
|
||||
expect(widgetIntegrationSupport[kind as WidgetKind], kind).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves generated notebook content and fills its hidden defaults", () => {
|
||||
const result = getAssistantWidgetConfiguration(
|
||||
getWidgetDefinition("notebook"),
|
||||
widgetImports.notebook.definition,
|
||||
{
|
||||
boardId: "board-1",
|
||||
boardName: "Home",
|
||||
@@ -49,7 +42,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
|
||||
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
||||
const result = getAssistantWidgetConfiguration(
|
||||
getWidgetDefinition("audioStats"),
|
||||
widgetImports.audioStats.definition,
|
||||
{
|
||||
boardId: "board-1",
|
||||
boardName: "Home",
|
||||
@@ -97,13 +90,13 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
|
||||
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
||||
const mediaServer = getAssistantWidgetConfiguration(
|
||||
getWidgetDefinition("mediaServer"),
|
||||
widgetImports.mediaServer.definition,
|
||||
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
||||
settings,
|
||||
[],
|
||||
);
|
||||
const calendar = getAssistantWidgetConfiguration(
|
||||
getWidgetDefinition("calendar"),
|
||||
widgetImports.calendar.definition,
|
||||
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
||||
settings,
|
||||
[],
|
||||
|
||||
@@ -4,24 +4,23 @@ import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { projectBoardLayout } from "@homarr/boards/layout-preview";
|
||||
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import classes from "./board-layout-thumbnail.module.css";
|
||||
|
||||
interface BoardLayoutThumbnailProps {
|
||||
preview: BoardPreviewData | null;
|
||||
label: string;
|
||||
previewRowLimit?: number;
|
||||
fitFullLayout?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const compactBoardLayoutThumbnailRows = 12;
|
||||
export const maxBoardLayoutThumbnailRows = 48;
|
||||
const maxThumbnailRows = 12;
|
||||
|
||||
export const BoardLayoutThumbnail = ({
|
||||
preview,
|
||||
label,
|
||||
previewRowLimit = compactBoardLayoutThumbnailRows,
|
||||
fitFullLayout = false,
|
||||
className,
|
||||
}: BoardLayoutThumbnailProps) => {
|
||||
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
||||
@@ -35,7 +34,7 @@ export const BoardLayoutThumbnail = ({
|
||||
}
|
||||
|
||||
const elements = projectBoardLayout(preview, layout, layout);
|
||||
const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows));
|
||||
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
|
||||
const roots = preview.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
@@ -95,21 +94,9 @@ export const BoardLayoutThumbnail = ({
|
||||
}}
|
||||
>
|
||||
{laneElements.map((element) => {
|
||||
const renderedWidth = Math.min(element.width, columnCount - element.xOffset);
|
||||
const renderedHeight = Math.min(element.height, rowCount - element.yOffset);
|
||||
const nestedItems =
|
||||
element.type === "section"
|
||||
? elements.filter(
|
||||
(candidate) =>
|
||||
candidate.type === "item" &&
|
||||
candidate.sectionId === element.id &&
|
||||
candidate.xOffset >= 0 &&
|
||||
candidate.xOffset < renderedWidth &&
|
||||
candidate.yOffset >= 0 &&
|
||||
candidate.yOffset < renderedHeight &&
|
||||
candidate.width > 0 &&
|
||||
candidate.height > 0,
|
||||
)
|
||||
? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id)
|
||||
: [];
|
||||
return (
|
||||
<span
|
||||
@@ -117,16 +104,16 @@ export const BoardLayoutThumbnail = ({
|
||||
aria-hidden
|
||||
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
|
||||
style={{
|
||||
gridColumn: `${element.xOffset + 1} / span ${renderedWidth}`,
|
||||
gridRow: `${element.yOffset + 1} / span ${renderedHeight}`,
|
||||
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
|
||||
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
|
||||
}}
|
||||
>
|
||||
{element.type === "section" ? (
|
||||
<span
|
||||
className={classes.containerContents}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${renderedWidth}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${renderedHeight}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{nestedItems.map((nestedItem) => (
|
||||
@@ -136,11 +123,11 @@ export const BoardLayoutThumbnail = ({
|
||||
style={{
|
||||
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
|
||||
nestedItem.width,
|
||||
renderedWidth - nestedItem.xOffset,
|
||||
element.width - nestedItem.xOffset,
|
||||
)}`,
|
||||
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
|
||||
nestedItem.height,
|
||||
renderedHeight - nestedItem.yOffset,
|
||||
element.height - nestedItem.yOffset,
|
||||
)}`,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -30,12 +30,11 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail";
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
|
||||
import classes from "./board-switcher.module.css";
|
||||
|
||||
export const boardSwitcherHotkey = "shift+c";
|
||||
const boardSwitcherPreview = { previewRowLimit: maxBoardLayoutThumbnailRows } as const;
|
||||
|
||||
export interface BoardSwitcherControls {
|
||||
open: () => void;
|
||||
@@ -62,12 +61,18 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
data: boards = [],
|
||||
isPending,
|
||||
isError,
|
||||
} = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
} = clientApi.board.getManageOverview.useQuery(
|
||||
{ fullPreview: true },
|
||||
{
|
||||
enabled: isOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const switcherBoards = useMemo(
|
||||
() => boards.filter((board) => board.id !== currentBoard?.id),
|
||||
() => [
|
||||
...boards.filter((board) => board.id !== currentBoard?.id),
|
||||
...boards.filter((board) => board.id === currentBoard?.id),
|
||||
],
|
||||
[boards, currentBoard?.id],
|
||||
);
|
||||
const filteredBoards = useMemo(() => {
|
||||
@@ -86,7 +91,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
setSearch("");
|
||||
setActiveIndex(0);
|
||||
}, []);
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview);
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
||||
|
||||
const spotlightAction = useMemo(
|
||||
() => ({
|
||||
@@ -336,7 +341,7 @@ const getBoardSwitcherResults = ({
|
||||
<BoardLayoutThumbnail
|
||||
preview={board.preview}
|
||||
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
||||
previewRowLimit={boardSwitcherPreview.previewRowLimit}
|
||||
fitFullLayout
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Section withBorder px="sm" py={6}>
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("item actions create-item", () => {
|
||||
.build();
|
||||
const baseLayout = board.layouts.find((layout) => layout.role === "base");
|
||||
|
||||
const result = createItemCallback({ kind: "downloads" })(board);
|
||||
const result = createItemCallback({ kind: "mediaMissing" })(board);
|
||||
|
||||
const createdItem = result.items.at(0);
|
||||
expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3);
|
||||
@@ -63,14 +63,14 @@ describe("item actions create-item", () => {
|
||||
{
|
||||
layoutId,
|
||||
height: 1,
|
||||
width: 2,
|
||||
width: 1,
|
||||
...emptyPosition,
|
||||
sectionId: firstSectionId,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 2 });
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
|
||||
});
|
||||
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
|
||||
// Arrange
|
||||
@@ -118,14 +118,14 @@ describe("item actions create-item", () => {
|
||||
expect(createdItem).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: itemKind,
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 2, sectionId: firstSectionId, layoutId }],
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||
}),
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||
layout.columnCount,
|
||||
undefined,
|
||||
{ height: 1, width: 2 },
|
||||
{ height: 1, width: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -165,7 +165,7 @@ describe("item actions create-item", () => {
|
||||
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
|
||||
|
||||
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
|
||||
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 2 }),
|
||||
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -262,7 +262,7 @@ describe("item actions create-item", () => {
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
width: 1,
|
||||
height: 2,
|
||||
height: 3,
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||
import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions";
|
||||
|
||||
export interface BoardRecipeRecommendation {
|
||||
widgetKind: WidgetKind;
|
||||
@@ -36,10 +36,10 @@ export const getBoardRecipeRecommendations = ({
|
||||
return widgetKinds
|
||||
.filter((widgetKind) => !existingKinds.has(widgetKind))
|
||||
.flatMap((widgetKind) => {
|
||||
const supportedIntegrations = widgetIntegrationSupport[widgetKind];
|
||||
if (!supportedIntegrations) return [];
|
||||
const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities];
|
||||
if (!capability) return [];
|
||||
|
||||
const matches = supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind));
|
||||
const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind));
|
||||
if (matches.length === 0) return [];
|
||||
|
||||
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
|
||||
@@ -51,10 +51,10 @@ export const getBoardRecipeRecommendations = ({
|
||||
widgetKind,
|
||||
integrationKind,
|
||||
isNewlyAvailable: Boolean(preferredMatch),
|
||||
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - supportedIntegrations.length,
|
||||
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length,
|
||||
},
|
||||
];
|
||||
})
|
||||
.toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
|
||||
.sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
|
||||
.slice(0, Math.max(0, limit));
|
||||
};
|
||||
@@ -1,7 +1,21 @@
|
||||
.refreshIndicator {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settingsButton {
|
||||
position: absolute;
|
||||
top: calc(var(--mantine-spacing-sm) / 2);
|
||||
right: calc(var(--mantine-spacing-sm) / 2);
|
||||
top: -34px;
|
||||
right: 14px;
|
||||
z-index: 201;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -52,6 +66,7 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settingsButton {
|
||||
right: 4px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
|
||||
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
@@ -19,6 +19,7 @@ import { WidgetError } from "@homarr/widgets/errors";
|
||||
import {
|
||||
createWidgetRuntimeState,
|
||||
getWidgetQueryKeys,
|
||||
getWidgetRuntimeQueries,
|
||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||
} from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||
@@ -39,6 +40,7 @@ import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
|
||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||
ssr: false,
|
||||
@@ -156,6 +158,24 @@ const LoadedBoardItemContent = ({
|
||||
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
||||
[definition, item, settings],
|
||||
);
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||
const isWidgetRefreshing =
|
||||
useIsFetching({
|
||||
predicate: (query) =>
|
||||
query.state.data !== undefined &&
|
||||
matchesWidgetItemQuery(
|
||||
query.queryKey,
|
||||
widgetQueryKeys,
|
||||
{
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
integrationIds: item.integrationIds,
|
||||
options: menuItem.options,
|
||||
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
||||
},
|
||||
definition.queryMatcher,
|
||||
),
|
||||
}) > 0;
|
||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||
@@ -323,6 +343,11 @@ const LoadedBoardItemContent = ({
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
{isWidgetRefreshing && (
|
||||
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
|
||||
<Loader size="xs" />
|
||||
</Box>
|
||||
)}
|
||||
</WidgetCardShell>
|
||||
);
|
||||
|
||||
@@ -336,7 +361,6 @@ const LoadedBoardItemContent = ({
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
sourceRef={sourceRef}
|
||||
disabled={isAdvanced}
|
||||
>
|
||||
<Box
|
||||
ref={sourceRef}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { InlineConfirmMenuItem } from "@homarr/ui";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
@@ -22,7 +21,7 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-
|
||||
interface BoardItemMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
previewDimensions: WidgetPreviewDimensions;
|
||||
previewDimensions: { width: number; height: number; scale?: number };
|
||||
resetErrorBoundary?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getLayoutRowCount } from "../layout";
|
||||
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
||||
import type { RegisteredGridEditor } from "../sections/grid/grid-editor-registry";
|
||||
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
|
||||
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||
@@ -31,7 +30,6 @@ interface InnerProps extends OpenItemMoveModalInput {
|
||||
board: Board;
|
||||
currentLayoutId: string;
|
||||
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
|
||||
registeredGridEditors: ReadonlyMap<string, Pick<RegisteredGridEditor, "placementMaxRowCount">>;
|
||||
}
|
||||
|
||||
interface MoveTarget {
|
||||
@@ -59,7 +57,8 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
const tLandmark = useI18n("board.landmark");
|
||||
const tContainer = useI18n("section.container");
|
||||
const tMoveResize = useI18n("item.moveResize");
|
||||
const { board, commitSectionGrids, currentLayoutId, entry, registeredGridEditors, sourceSectionId } = innerProps;
|
||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
||||
const registeredGridEditors = useRegisteredGridEditors();
|
||||
const minimumSize = useMemo(
|
||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||
[board, currentLayoutId, entry],
|
||||
@@ -236,7 +235,6 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
export const useOpenItemMoveModal = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const registeredGridEditors = useRegisteredGridEditors();
|
||||
const { commitSectionGrids } = useGridLayoutActions();
|
||||
const { openModal } = useModalAction(ItemMoveModal);
|
||||
|
||||
@@ -247,10 +245,9 @@ export const useOpenItemMoveModal = () => {
|
||||
board,
|
||||
currentLayoutId,
|
||||
commitSectionGrids,
|
||||
registeredGridEditors,
|
||||
});
|
||||
},
|
||||
[board, commitSectionGrids, currentLayoutId, openModal, registeredGridEditors],
|
||||
[board, commitSectionGrids, currentLayoutId, openModal],
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectableCard } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
@@ -25,7 +25,6 @@ import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definit
|
||||
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
|
||||
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation";
|
||||
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
@@ -40,11 +39,10 @@ type ToggleOption = [key: string, option: { type: string; skipContextMenu?: bool
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
previewDimensions: WidgetPreviewDimensions;
|
||||
previewDimensions: { width: number; height: number; scale?: number };
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
sourceRef: RefObject<HTMLElement | null>;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -54,7 +52,6 @@ export const WidgetContextMenu = ({
|
||||
previewDimensions,
|
||||
widgetRuntimeRef,
|
||||
sourceRef,
|
||||
disabled = false,
|
||||
children,
|
||||
}: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
@@ -164,7 +161,6 @@ export const WidgetContextMenu = ({
|
||||
}) ?? [];
|
||||
|
||||
const openEditModal = useCallback(() => {
|
||||
setMenuOpened(false);
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
@@ -233,7 +229,6 @@ export const WidgetContextMenu = ({
|
||||
|
||||
const handleOpenAdvancedFocus = useCallback(() => {
|
||||
if (!sourceRef.current) return;
|
||||
setMenuOpened(false);
|
||||
openAdvancedFocus(item.id, sourceRef.current, {
|
||||
restoreFocusTarget:
|
||||
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
|
||||
@@ -260,7 +255,7 @@ export const WidgetContextMenu = ({
|
||||
opened={menuOpened}
|
||||
onChange={setMenuOpened}
|
||||
>
|
||||
<Menu.ContextMenu disabled={disabled}>{children}</Menu.ContextMenu>
|
||||
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
||||
<WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
|
||||
{canOpenAdvancedFocus && (
|
||||
<>
|
||||
|
||||
@@ -49,14 +49,12 @@
|
||||
}
|
||||
|
||||
.widgetIcon {
|
||||
width: var(--mantine-font-size-sm);
|
||||
height: var(--mantine-font-size-sm);
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
@@ -96,7 +94,7 @@
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--chip-background);
|
||||
font-size: var(--mantine-font-size-xxs);
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { NormalizedWidgetQuery, WidgetQueryMatcherScope } from "@homarr/widgets";
|
||||
import { getWidgetQueryKeys } from "@homarr/widgets";
|
||||
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
|
||||
import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets";
|
||||
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
|
||||
const widgetDefinitions = await loadAllWidgetDefinitions();
|
||||
type WidgetKind = keyof typeof widgetImports;
|
||||
const allPhotosAlbumId = "all";
|
||||
|
||||
const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQueryMatcherScope => ({
|
||||
@@ -21,9 +19,7 @@ const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQu
|
||||
});
|
||||
|
||||
const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => {
|
||||
const definition = widgetDefinitions.get(kind);
|
||||
if (!definition) throw new Error(`No definition is registered for ${kind}`);
|
||||
|
||||
const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string };
|
||||
const queryKey: QueryKey = [path, { input, type: "query" }];
|
||||
return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
overflow-y: visible;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] {
|
||||
@@ -46,13 +45,4 @@
|
||||
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
|
||||
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
|
||||
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
|
||||
* above, just never ported over to radius. */
|
||||
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
.board-grid-entry:has([data-board-widget-header]):hover,
|
||||
.board-grid-entry:has([data-board-widget-header]):focus-within {
|
||||
z-index: calc(var(--homarr-z-index-board-header) - 1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-preview="true"] {
|
||||
|
||||
@@ -3,22 +3,6 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
|
||||
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
|
||||
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
|
||||
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
|
||||
while growing during a drag happening anywhere else on the board - any container (not just
|
||||
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
|
||||
inside that section's DOM tree, so the stray value cascades straight through and silently
|
||||
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
|
||||
its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
|
||||
unset, not merely inherited. Reset it here so every container can never inherit a stray
|
||||
value from anywhere above it, no matter what any ancestor's own drag state is doing.
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
|
||||
.viewport[data-section-kind="container"] {
|
||||
--board-grid-drag-height: initial;
|
||||
}
|
||||
|
||||
/* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always
|
||||
wins on specificity, not source order. GridEditor loads as a separate async chunk in edit
|
||||
mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity,
|
||||
@@ -27,14 +11,17 @@
|
||||
.viewport.scrollableViewport {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
/* The grid content inside has a fixed pixel width matching this viewport's full
|
||||
outer width (needed for the drag/resize math), so there's no room to reserve
|
||||
for a scrollbar without it eating into - or overlapping - the content on
|
||||
whichever side it renders. Hiding the native scrollbar track entirely (wheel/
|
||||
touch/drag scrolling still works) avoids that instead of trying to reserve
|
||||
space for it. */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
|
||||
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
|
||||
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
|
||||
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
|
||||
while growing during a drag happening anywhere else on the board - a nested scrollable
|
||||
container sits inside that section's DOM tree, so the stray value cascades straight
|
||||
through and silently defeats the `var(--board-grid-drag-height, ...px)` fallback in
|
||||
section-grid.tsx, which only kicks in when the property is entirely unset, not merely
|
||||
inherited. Reset it here so this element can never inherit a stray value from anywhere
|
||||
above it, no matter what any ancestor's own drag state is doing.
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
|
||||
--board-grid-drag-height: initial;
|
||||
}
|
||||
|
||||
|
||||
@@ -148,25 +148,8 @@ export const SectionGrid = ({
|
||||
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
|
||||
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
||||
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
|
||||
// A collapsible container's toggle bar (see the `containerToggle` Button in
|
||||
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
|
||||
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
|
||||
// by --mantine-scale to render at a constant physical size regardless of board zoom (see
|
||||
// scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above,
|
||||
// not like the grid's own un-compensated logical-pixel math, so it needs the same
|
||||
// effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on
|
||||
// a zoomed-out board and leave the header overlapping the content. Without reserving it at
|
||||
// all, the header bar covers the first row of the container's content instead of sitting
|
||||
// above it. Only the vertical axis is affected - the toggle spans the full width already.
|
||||
const collapsibleHeaderInset =
|
||||
section.kind === "container" && section.options.collapsible
|
||||
? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale
|
||||
: 0;
|
||||
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
|
||||
const viewportHeight = Math.max(
|
||||
1,
|
||||
getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset,
|
||||
);
|
||||
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset;
|
||||
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
|
||||
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
|
||||
// deliberately smaller by outerCardInset to match the container's actual visible card size.
|
||||
@@ -300,7 +283,6 @@ export const SectionGrid = ({
|
||||
{
|
||||
width: logicalWidth,
|
||||
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
|
||||
marginTop: collapsibleHeaderInset || undefined,
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
} as CSSProperties
|
||||
}
|
||||
@@ -335,9 +317,6 @@ export const SectionGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Matches the collapsible container toggle's h={24} in container-section.tsx.
|
||||
const CONTAINER_HEADER_HEIGHT = 24;
|
||||
|
||||
const INTERACTIVE_GRID_SELECTOR =
|
||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
|
||||
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header[data-visible="false"] {
|
||||
.header[data-desktop-visible="false"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -73,38 +73,39 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.userMenu {
|
||||
display: flex;
|
||||
padding-inline: rem(6px);
|
||||
}
|
||||
|
||||
.mobileContent {
|
||||
display: none;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-inline: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.mobileZone {
|
||||
.mobileIdentity,
|
||||
.mobileActions {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.mobileZone::-webkit-scrollbar {
|
||||
display: none;
|
||||
.mobileIdentity {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobileZone[data-zone="left"] {
|
||||
justify-content: flex-start;
|
||||
.mobileIdentity > :last-child {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobileZone[data-zone="right"] {
|
||||
justify-content: flex-end;
|
||||
max-width: 72vw;
|
||||
.mobileIdentity h2 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobileActions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.floatingControls {
|
||||
@@ -154,12 +155,21 @@
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
.header[data-desktop-visible="false"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktopContent {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileContent {
|
||||
display: grid;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.floatingControls,
|
||||
.floatingControlsCorner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
|
||||
import { IconBrandDocker, IconHome, IconReplace, IconRobot, IconSettings, IconSunMoon } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconHome,
|
||||
IconLayoutDashboard,
|
||||
IconReplace,
|
||||
IconRobot,
|
||||
IconSettings,
|
||||
IconSunMoon,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -73,10 +81,7 @@ export const ConfigurableHeader = ({
|
||||
return (
|
||||
<BoardSwitcher>
|
||||
{(boardSwitcher) => {
|
||||
const renderItems = (
|
||||
items: HeaderItem[],
|
||||
display: { search: "input" | "icon"; logo: "logo" | "logoAndText" },
|
||||
) =>
|
||||
const renderItems = (items: HeaderItem[]) =>
|
||||
items.map((item) => (
|
||||
<HeaderItem
|
||||
key={getHeaderItemKey(item)}
|
||||
@@ -90,8 +95,8 @@ export const ConfigurableHeader = ({
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
|
||||
searchDisplay={display.search}
|
||||
logoDisplay={display.logo}
|
||||
searchDisplay={headerPreferences.searchDisplay}
|
||||
logoDisplay={headerPreferences.logoDisplay}
|
||||
boardSwitcher={boardSwitcher}
|
||||
assistant={assistant}
|
||||
openDockerModal={openDockerModal}
|
||||
@@ -101,19 +106,13 @@ export const ConfigurableHeader = ({
|
||||
/>
|
||||
));
|
||||
|
||||
const desktopDisplay = {
|
||||
search: headerPreferences.searchDisplay,
|
||||
logo: headerPreferences.logoDisplay,
|
||||
};
|
||||
const mobileDisplay = { search: "icon", logo: "logo" } as const;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShellHeader
|
||||
maw="100vw"
|
||||
zIndex="var(--homarr-z-index-board-header)"
|
||||
className={classes.header}
|
||||
data-visible={headerPreferences.visible}
|
||||
data-desktop-visible={headerPreferences.visible}
|
||||
data-advanced-focus-background
|
||||
data-app-shell-header
|
||||
>
|
||||
@@ -121,26 +120,37 @@ export const ConfigurableHeader = ({
|
||||
<div className={classes.content}>
|
||||
<div className={classes.zone} data-zone="left">
|
||||
{hasNavigation ? <ClientBurger /> : null}
|
||||
{renderItems(headerPreferences.zones.left, desktopDisplay)}
|
||||
{renderItems(headerPreferences.zones.left)}
|
||||
</div>
|
||||
<div className={classes.zone} data-zone="center">
|
||||
{renderItems(headerPreferences.zones.center, desktopDisplay)}
|
||||
{renderItems(headerPreferences.zones.center)}
|
||||
</div>
|
||||
<div className={classes.zone} data-zone="right">
|
||||
{renderItems(headerPreferences.zones.right, desktopDisplay)}
|
||||
{renderItems(headerPreferences.zones.right)}
|
||||
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.mobileContent}>
|
||||
<Group className={classes.mobileZone} data-zone="left" gap="xs" wrap="nowrap">
|
||||
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap">
|
||||
{hasNavigation ? <ClientBurger /> : null}
|
||||
{renderItems(headerPreferences.zones.left, mobileDisplay)}
|
||||
<HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} />
|
||||
</Group>
|
||||
<Group className={classes.mobileZone} data-zone="right" gap="xs" wrap="nowrap">
|
||||
{renderItems(headerPreferences.zones.center, mobileDisplay)}
|
||||
{renderItems(headerPreferences.zones.right, mobileDisplay)}
|
||||
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
|
||||
<Group className={classes.mobileActions} gap="xs" wrap="nowrap">
|
||||
{actions}
|
||||
{boardEditAction}
|
||||
{boardSettingsAction}
|
||||
<TourTarget id="board-search">
|
||||
<MobileSearchButton alwaysVisible />
|
||||
</TourTarget>
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButtonClient
|
||||
avatar={avatar}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
boardSwitcher={boardSwitcher}
|
||||
/>
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</div>
|
||||
</AppShellHeader>
|
||||
@@ -288,9 +298,7 @@ const HeaderItem = ({
|
||||
return (
|
||||
<Tooltip label={boardUnavailableLabel}>
|
||||
<HeaderButton aria-label={boardUnavailableLabel} disabled>
|
||||
<Avatar size={22} radius="sm">
|
||||
?
|
||||
</Avatar>
|
||||
<IconLayoutDashboard size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -408,19 +416,14 @@ const HeaderItem = ({
|
||||
|
||||
return (
|
||||
<TourTarget id="board-user-menu">
|
||||
<div className={classes.userMenu}>
|
||||
<UserButtonClient
|
||||
avatar={avatar}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
boardSwitcher={boardSwitcher}
|
||||
/>
|
||||
</div>
|
||||
<UserButtonClient
|
||||
avatar={avatar}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
boardSwitcher={boardSwitcher}
|
||||
/>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const getBoardInitial = (name: string) => {
|
||||
const firstCharacter = Array.from(name.trim())[0] ?? "?";
|
||||
return Array.from(firstCharacter.toUpperCase())[0] ?? "?";
|
||||
};
|
||||
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?";
|
||||
@@ -54,13 +54,11 @@ export const LazySpotlight = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isMounted && !isPreloaded) return null;
|
||||
|
||||
return (
|
||||
return isMounted || isPreloaded ? (
|
||||
<>
|
||||
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
||||
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
|
||||
{isMounted && <Spotlight />}
|
||||
<Spotlight />
|
||||
</>
|
||||
);
|
||||
) : null;
|
||||
};
|
||||
@@ -22,17 +22,11 @@ export const ClientShell = ({
|
||||
const collapsed = useAtomValue(navigationCollapsedAtom);
|
||||
const backgroundProps = useOptionalBackgroundProps();
|
||||
const { headerPreferences } = useSettings();
|
||||
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : 0;
|
||||
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 };
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
{...backgroundProps}
|
||||
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
|
||||
// <main> doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
|
||||
// background image only covers as much height as the content needs, so collapsing a
|
||||
// container short enough leaves flat page background showing below it instead of the
|
||||
// background continuing to the bottom of the viewport.
|
||||
mih={backgroundProps.bg ? "100dvh" : undefined}
|
||||
header={hasHeader ? { height: headerHeight } : undefined}
|
||||
navbar={
|
||||
hasNavigation
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Badge, Indicator, Kbd, Loader, Menu, Text } from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconHome,
|
||||
IconLayoutDashboard,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconReplace,
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -49,8 +46,6 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
|
||||
const t = useI18n("common.userAvatar.menu");
|
||||
const tBoard = useI18n("board");
|
||||
const session = useSession();
|
||||
const board = useOptionalBoard();
|
||||
const boardPermissions = board && constructBoardPermissions(board, session.data ?? null);
|
||||
|
||||
const { logoutUrl } = useAuthContext();
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
@@ -72,15 +67,6 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
{board && boardPermissions?.hasChangeAccess && (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/boards/${encodeURIComponent(board.name)}/settings`}
|
||||
leftSection={<IconLayoutDashboard size="1rem" />}
|
||||
>
|
||||
{tBoard("action.settings")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconReplace size="1rem" />}
|
||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
|
||||
|
||||
@@ -163,6 +163,7 @@ describe("lazy widget application graph", () => {
|
||||
});
|
||||
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
|
||||
await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
|
||||
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible();
|
||||
|
||||
const refreshedDownloadsResponse = page.waitForResponse(
|
||||
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
||||
@@ -170,6 +171,7 @@ describe("lazy widget application graph", () => {
|
||||
releaseRefresh();
|
||||
releaseRefresh = undefined;
|
||||
await refreshedDownloadsResponse;
|
||||
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0);
|
||||
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(hydrationErrors).toEqual([]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
|
||||
import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export interface McpMeta {
|
||||
@@ -12,7 +12,6 @@ export interface McpMeta {
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description: string;
|
||||
type: "query" | "mutation";
|
||||
pathInRouter: string[];
|
||||
inputSchema: z.core.JSONSchema.JSONSchema;
|
||||
inputValidator: z.ZodObject;
|
||||
@@ -20,54 +19,13 @@ export interface McpTool {
|
||||
|
||||
const emptyInput = z.object({});
|
||||
|
||||
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
|
||||
|
||||
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
|
||||
|
||||
const getObjectInput = (input: z.ZodType) => {
|
||||
if (input instanceof z.ZodObject) return input;
|
||||
|
||||
if (input instanceof z.ZodOptional) {
|
||||
const unwrappedInput = input.unwrap();
|
||||
if (unwrappedInput instanceof z.ZodObject) return unwrappedInput;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
|
||||
if (!meta || typeof meta !== "object" || !("mcp" in meta)) return undefined;
|
||||
|
||||
const metadata = meta.mcp;
|
||||
if (!metadata || typeof metadata !== "object" || !("enabled" in metadata)) return undefined;
|
||||
if (typeof metadata.enabled !== "boolean") return undefined;
|
||||
|
||||
const result: NonNullable<McpMeta["mcp"]> = { enabled: metadata.enabled };
|
||||
if ("name" in metadata && typeof metadata.name === "string") result.name = metadata.name;
|
||||
if ("description" in metadata && typeof metadata.description === "string") {
|
||||
result.description = metadata.description;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const getProcedureInput = (inputs: z.ZodType[]) => {
|
||||
if (inputs.length === 0) return emptyInput;
|
||||
if (inputs.length === 1) {
|
||||
const input = inputs[0] ?? emptyInput;
|
||||
return getObjectInput(input) ?? input;
|
||||
}
|
||||
|
||||
const objectInputs = inputs.map(getObjectInput);
|
||||
if (objectInputs.every((input): input is z.ZodObject => input !== null)) return mergeInputs(objectInputs);
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
|
||||
const properties = schema.properties ?? {};
|
||||
const required = (schema.required ?? []).filter((key) => {
|
||||
const property = properties[key];
|
||||
return !property || typeof property !== "object" || !("default" in property);
|
||||
});
|
||||
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = ((schema.required as string[] | undefined) ?? []).filter(
|
||||
(key) => !("default" in (properties[key] ?? {})),
|
||||
);
|
||||
|
||||
return {
|
||||
...schema,
|
||||
@@ -83,15 +41,22 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
|
||||
const tools: McpTool[] = [];
|
||||
|
||||
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
|
||||
if (!isProcedure(procedure)) continue;
|
||||
const definition = procedure["_def"];
|
||||
if (definition.type === "subscription") continue;
|
||||
|
||||
const metadata = getMcpMetadata(definition.meta);
|
||||
const definition = procedure["_def"] as unknown as {
|
||||
inputs?: z.ZodType[];
|
||||
meta?: McpMeta;
|
||||
};
|
||||
const metadata = definition.meta?.mcp;
|
||||
if (!metadata?.enabled) continue;
|
||||
|
||||
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
|
||||
const procedureInput = getProcedureInput(inputs);
|
||||
const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => input !== undefined);
|
||||
const procedureInput =
|
||||
inputs.length === 0
|
||||
? emptyInput
|
||||
: inputs.length === 1
|
||||
? (inputs[0] ?? emptyInput)
|
||||
: inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)
|
||||
? mergeInputs(inputs)
|
||||
: null;
|
||||
|
||||
if (
|
||||
procedureInput === null ||
|
||||
@@ -104,7 +69,6 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
|
||||
tools.push({
|
||||
name: metadata.name ?? path.replaceAll(".", "_"),
|
||||
description: metadata.description ?? "",
|
||||
type: definition.type,
|
||||
pathInRouter: path.split("."),
|
||||
inputValidator,
|
||||
inputSchema: normalizeJsonSchema(
|
||||
|
||||
@@ -9,51 +9,26 @@ import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, inArray } from "@homarr/db";
|
||||
import { integrations } from "@homarr/db/schema";
|
||||
import type { IntegrationKind, WidgetIntegrationKind, WidgetKindWithIntegration } from "@homarr/definitions";
|
||||
import { getWidgetIntegrationConfig } from "@homarr/definitions";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { publicProcedure } from "../trpc";
|
||||
|
||||
export type IntegrationAction = "query" | "interact";
|
||||
|
||||
const logger = createLogger({ module: "integrationMiddleware" });
|
||||
|
||||
const getIntegrationKindForLog = (integration: object) => {
|
||||
if ("kind" in integration && typeof integration.kind === "string") return integration.kind;
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
interface IntegrationAccessMetadata {
|
||||
action: IntegrationAction;
|
||||
cardinality: "one" | "many";
|
||||
userId: string | undefined;
|
||||
requestedIntegrationIds: readonly string[];
|
||||
allowedIntegrationKinds: readonly IntegrationKind[];
|
||||
}
|
||||
|
||||
const throwUnexpectedIntegrationKind = (kind: IntegrationKind, allowedKinds: readonly IntegrationKind[]): never => {
|
||||
logger.error("Database returned an integration outside the requested kinds", {
|
||||
integrationKind: kind,
|
||||
allowedIntegrationKinds: allowedKinds,
|
||||
});
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Integration kind validation failed" });
|
||||
};
|
||||
|
||||
const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
/**
|
||||
* Creates a middleware that provides the integration in the context that is of the specified kinds
|
||||
* @param action query for showing data or interact for mutating data
|
||||
* @param kinds kinds of integrations that are supported
|
||||
* @returns middleware that can be used with trpc
|
||||
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
|
||||
* @throws TRPCError NOT_FOUND if the integration was not found
|
||||
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
|
||||
*/
|
||||
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
kinds: readonly TKind[],
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
) => {
|
||||
return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
|
||||
const accessMetadata: IntegrationAccessMetadata = {
|
||||
action,
|
||||
cardinality: "one",
|
||||
userId: ctx.session?.user.id,
|
||||
requestedIntegrationIds: [input.integrationId],
|
||||
allowedIntegrationKinds: kinds,
|
||||
};
|
||||
logger.debug("Resolving integration access", accessMetadata);
|
||||
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
@@ -70,7 +45,6 @@ const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
logger.warn("Integration access target was not found", accessMetadata);
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
|
||||
@@ -78,24 +52,22 @@ const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
}
|
||||
|
||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session);
|
||||
logger.debug("Integration access granted", {
|
||||
...accessMetadata,
|
||||
resolvedIntegrationKinds: [integration.kind],
|
||||
});
|
||||
|
||||
const narrowedKind = kinds.find((kind) => kind === integration.kind);
|
||||
if (!narrowedKind) {
|
||||
return throwUnexpectedIntegrationKind(integration.kind, kinds);
|
||||
}
|
||||
|
||||
const { secrets, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest } = integration;
|
||||
const {
|
||||
secrets,
|
||||
kind,
|
||||
items: _ignore1,
|
||||
groupPermissions: _ignore2,
|
||||
userPermissions: _ignore3,
|
||||
...rest
|
||||
} = integration;
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integration: {
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: narrowedKind,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
@@ -106,101 +78,6 @@ const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a middleware that provides the integration in the context that is of the specified kinds
|
||||
* @param action query for showing data or interact for mutating data
|
||||
* @param kinds kinds of integrations that are supported
|
||||
* @returns middleware that can be used with trpc
|
||||
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
|
||||
* @throws TRPCError NOT_FOUND if the integration was not found
|
||||
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
|
||||
*/
|
||||
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
) => {
|
||||
return createOneIntegrationMiddlewareForKinds(action, kinds);
|
||||
};
|
||||
|
||||
const createManyIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
kinds: readonly TKind[],
|
||||
) => {
|
||||
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
|
||||
const accessMetadata: IntegrationAccessMetadata = {
|
||||
action,
|
||||
cardinality: "many",
|
||||
userId: ctx.session?.user.id,
|
||||
requestedIntegrationIds: input.integrationIds,
|
||||
allowedIntegrationKinds: kinds,
|
||||
};
|
||||
logger.debug("Resolving integration access", accessMetadata);
|
||||
|
||||
const findIntegrations = async () => {
|
||||
if (input.integrationIds.length === 0) return [];
|
||||
|
||||
return await ctx.db.query.integrations.findMany({
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: true,
|
||||
},
|
||||
},
|
||||
userPermissions: true,
|
||||
groupPermissions: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
const dbIntegrations = await findIntegrations();
|
||||
|
||||
const offset = input.integrationIds.length - dbIntegrations.length;
|
||||
if (offset !== 0) {
|
||||
logger.warn("Integration access targets were not found", {
|
||||
...accessMetadata,
|
||||
missingIntegrationCount: offset,
|
||||
resolvedIntegrationIds: dbIntegrations.map(({ id }) => id),
|
||||
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
|
||||
});
|
||||
}
|
||||
|
||||
if (dbIntegrations.length >= 1) {
|
||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
|
||||
}
|
||||
|
||||
logger.debug("Integration access granted", {
|
||||
...accessMetadata,
|
||||
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
|
||||
});
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integrations: dbIntegrations.map(
|
||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => {
|
||||
const narrowedKind = kinds.find((allowedKind) => allowedKind === kind);
|
||||
if (!narrowedKind) return throwUnexpectedIntegrationKind(kind, kinds);
|
||||
return {
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: narrowedKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a middleware that provides the integrations in the context that are of the specified kinds and have the specified item
|
||||
* It also ensures that the user has permission to perform the specified action on the integrations
|
||||
@@ -215,27 +92,55 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
) => {
|
||||
return createManyIntegrationMiddlewareForKinds(action, kinds);
|
||||
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
|
||||
const dbIntegrations =
|
||||
input.integrationIds.length >= 1
|
||||
? await ctx.db.query.integrations.findMany({
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: true,
|
||||
},
|
||||
},
|
||||
userPermissions: true,
|
||||
groupPermissions: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const offset = input.integrationIds.length - dbIntegrations.length;
|
||||
if (offset !== 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
|
||||
});
|
||||
}
|
||||
|
||||
if (dbIntegrations.length >= 1) {
|
||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integrations: dbIntegrations.map(
|
||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const createOneWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
|
||||
createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
|
||||
action,
|
||||
getWidgetIntegrationConfig(widgetKind).supportedIntegrations,
|
||||
);
|
||||
|
||||
export const createManyWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
|
||||
createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
|
||||
action,
|
||||
getWidgetIntegrationConfig(widgetKind).supportedIntegrations,
|
||||
);
|
||||
|
||||
/**
|
||||
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
|
||||
* @param action action to perform
|
||||
@@ -247,39 +152,25 @@ export const createManyWidgetIntegrationMiddleware = <TWidgetKind extends Widget
|
||||
const throwIfActionIsNotAllowedAsync = async (
|
||||
action: IntegrationAction,
|
||||
db: Database,
|
||||
integrationRecords: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
|
||||
integrations: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
|
||||
session: Session | null,
|
||||
) => {
|
||||
if (action === "interact") {
|
||||
const haveAllInteractAccess = integrationRecords
|
||||
const haveAllInteractAccess = integrations
|
||||
.map((integration) => constructIntegrationPermissions(integration, session))
|
||||
.every(({ hasInteractAccess }) => hasInteractAccess);
|
||||
if (haveAllInteractAccess) return;
|
||||
|
||||
logger.warn("Integration access denied", {
|
||||
action,
|
||||
userId: session?.user.id,
|
||||
integrationIds: integrationRecords.map(({ id }) => id),
|
||||
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
|
||||
});
|
||||
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to interact with at least one of the specified integrations",
|
||||
});
|
||||
}
|
||||
|
||||
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrationRecords, session);
|
||||
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
|
||||
|
||||
if (hasQueryAccess) return;
|
||||
|
||||
logger.warn("Integration access denied", {
|
||||
action,
|
||||
userId: session?.user.id,
|
||||
integrationIds: integrationRecords.map(({ id }) => id),
|
||||
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
|
||||
});
|
||||
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to query at least one of the specified integration",
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
||||
|
||||
interface ForbiddenTrpcError {
|
||||
data: { code: "FORBIDDEN" };
|
||||
}
|
||||
|
||||
export const isTrpcForbiddenError = (error: unknown): error is ForbiddenTrpcError => {
|
||||
if (!isRecord(error) || !isRecord(error.data)) return false;
|
||||
return error.data.code === "FORBIDDEN";
|
||||
};
|
||||
|
||||
// RSC-prefetched data must survive the hydration pass without an immediate
|
||||
// duplicate fetch. Widget-specific intervals still control live refreshes.
|
||||
export const queryCacheDefaultStaleTimeMs = 30_000;
|
||||
@@ -19,7 +8,6 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
|
||||
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
||||
|
||||
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||
const persistenceExcludedWidgetPaths = new Set(["widget.customApi.getData", "widget.customApi.queryRequest"]);
|
||||
// Beszel system stats are large time-series payloads and were deliberately kept out of
|
||||
// the persisted cache in #6289; live stats come from an SSE subscription instead.
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||
@@ -78,11 +66,3 @@ export const isWidgetDataQueryKey = (queryKey: QueryKey) => {
|
||||
|
||||
return isWidgetDataTrpcPath(path.join("."));
|
||||
};
|
||||
|
||||
export const isPersistableDashboardQueryKey = (queryKey: QueryKey) => {
|
||||
const path = getTrpcPathFromQueryKey(queryKey);
|
||||
if (!path) return false;
|
||||
|
||||
const joinedPath = path.join(".");
|
||||
return isWidgetDataTrpcPath(joinedPath) && !persistenceExcludedWidgetPaths.has(joinedPath);
|
||||
};
|
||||
@@ -5,10 +5,9 @@ import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database, InferSelectModel } from "@homarr/db";
|
||||
import { asc, eq, inArray, like } from "@homarr/db";
|
||||
import { apps, integrations } from "@homarr/db/schema";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
import { selectAppSchema } from "@homarr/db/validationSchemas";
|
||||
import { getIconForName } from "@homarr/icons";
|
||||
import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis";
|
||||
import { appCreateManySchema, appEditSchema, appManageSchema } from "@homarr/validation/app";
|
||||
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
||||
|
||||
@@ -257,14 +256,6 @@ export const appRouter = createTRPCRouter({
|
||||
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
||||
})
|
||||
.where(eq(apps.id, input.id));
|
||||
|
||||
if (app.href !== input.href) {
|
||||
const linkedIntegrations = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true },
|
||||
where: eq(integrations.appId, input.id),
|
||||
});
|
||||
await Promise.all(linkedIntegrations.map(({ id }) => invalidateIntegrationResponseCacheAsync(id)));
|
||||
}
|
||||
}),
|
||||
delete: permissionRequiredProcedure
|
||||
.requiresPermission("app-full-all")
|
||||
@@ -280,12 +271,7 @@ export const appRouter = createTRPCRouter({
|
||||
})
|
||||
.input(byIdSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const linkedIntegrations = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true },
|
||||
where: eq(integrations.appId, input.id),
|
||||
});
|
||||
await ctx.db.delete(apps).where(eq(apps.id, input.id));
|
||||
await Promise.all(linkedIntegrations.map(({ id }) => invalidateIntegrationResponseCacheAsync(id)));
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+138
-180
@@ -20,7 +20,9 @@ import {
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrationItems,
|
||||
integrationUserPermissions,
|
||||
integrations,
|
||||
apps,
|
||||
itemLayouts,
|
||||
@@ -69,7 +71,7 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { throwIfIntegrationActionsForbiddenAsync } from "./integration/integration-access";
|
||||
import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access";
|
||||
import {
|
||||
throwIfCustomWidgetBoardDuplicationForbidden,
|
||||
throwIfCustomWidgetPlacementChangeForbidden,
|
||||
@@ -84,14 +86,9 @@ interface BoardItemPlacementRectangle {
|
||||
}
|
||||
|
||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||
const defaultManageOverviewPreviewRows = 12;
|
||||
const maxManageOverviewPreviewRows = 48;
|
||||
const maxManageOverviewPreviewRows = 12;
|
||||
const manageOverviewInputSchema = z
|
||||
.object({
|
||||
fullPreview: z.boolean().default(false),
|
||||
previewRowLimit: z.number().int().min(1).max(maxManageOverviewPreviewRows).optional(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
|
||||
.optional();
|
||||
|
||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||
@@ -129,75 +126,6 @@ const findFirstAvailableBoardItemPosition = (
|
||||
return null;
|
||||
};
|
||||
|
||||
interface WidgetConfiguration {
|
||||
id: string;
|
||||
kind: WidgetKind;
|
||||
integrationIds: readonly string[];
|
||||
}
|
||||
|
||||
const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) => {
|
||||
if (left.length !== right.length) return false;
|
||||
const sortedRight = right.toSorted();
|
||||
return left.toSorted().every((integrationId, index) => integrationId === sortedRight[index]);
|
||||
};
|
||||
|
||||
const validateWidgetConfigurationsAsync = async (
|
||||
ctx: Parameters<typeof throwIfIntegrationActionsForbiddenAsync>[0],
|
||||
submittedItems: readonly WidgetConfiguration[],
|
||||
storedItems: readonly WidgetConfiguration[] = [],
|
||||
) => {
|
||||
const storedItemsById = new Map(storedItems.map((item) => [item.id, item]));
|
||||
const changedItems = submittedItems.filter((item) => {
|
||||
const storedItem = storedItemsById.get(item.id);
|
||||
if (!storedItem || storedItem.kind !== item.kind) return true;
|
||||
return !haveSameIntegrationIds(item.integrationIds, storedItem.integrationIds);
|
||||
});
|
||||
if (changedItems.length === 0) return;
|
||||
|
||||
const selectedIntegrationIds = [...new Set(changedItems.flatMap(({ integrationIds }) => integrationIds))];
|
||||
let integrationRecords: { id: string; kind: IntegrationKind }[] = [];
|
||||
if (selectedIntegrationIds.length > 0) {
|
||||
integrationRecords = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true, kind: true },
|
||||
where: inArray(integrations.id, selectedIntegrationIds),
|
||||
});
|
||||
}
|
||||
const integrationRecordsById = new Map(integrationRecords.map((integration) => [integration.id, integration]));
|
||||
const invalidIntegrationIds = selectedIntegrationIds.filter(
|
||||
(integrationId) => !integrationRecordsById.has(integrationId),
|
||||
);
|
||||
if (invalidIntegrationIds.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration not found",
|
||||
});
|
||||
}
|
||||
|
||||
const addedOrReconfiguredIntegrationIds = [
|
||||
...new Set(
|
||||
changedItems.flatMap((item) => {
|
||||
const storedItem = storedItemsById.get(item.id);
|
||||
if (!storedItem || storedItem.kind !== item.kind) return item.integrationIds;
|
||||
return item.integrationIds.filter((integrationId) => !storedItem.integrationIds.includes(integrationId));
|
||||
}),
|
||||
),
|
||||
];
|
||||
await throwIfIntegrationActionsForbiddenAsync(ctx, addedOrReconfiguredIntegrationIds, "use");
|
||||
|
||||
for (const item of changedItems) {
|
||||
const selectedIntegrationKinds = item.integrationIds.flatMap(
|
||||
(integrationId) => integrationRecordsById.get(integrationId)?.kind ?? [],
|
||||
);
|
||||
const integrationIssue = getWidgetIntegrationIssue(item.kind, selectedIntegrationKinds);
|
||||
if (!integrationIssue) continue;
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: getWidgetIntegrationIssueMessage(item.kind, integrationIssue),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const searchAccessibleBoardsAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
query: string,
|
||||
@@ -236,7 +164,11 @@ const searchAccessibleBoardsAsync = async (
|
||||
like(boards.name, `%${query}%`),
|
||||
ctx.session?.user.permissions.includes("board-view-all")
|
||||
? undefined
|
||||
: or(eq(boards.isPublic, true), eq(boards.creatorId, ctx.session?.user.id ?? ""), inArray(boards.id, boardIds)),
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||
inArray(boards.id, boardIds),
|
||||
),
|
||||
),
|
||||
limit,
|
||||
orderBy: asc(boards.name),
|
||||
@@ -379,8 +311,6 @@ export const boardRouter = createTRPCRouter({
|
||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
const canViewAllBoards = getCanViewAllBoards(groupMemberships);
|
||||
let previewRowLimit = input?.previewRowLimit ?? defaultManageOverviewPreviewRows;
|
||||
if (input?.fullPreview) previewRowLimit = maxManageOverviewPreviewRows;
|
||||
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
@@ -458,7 +388,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(itemLayouts.sectionId, rootSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
lt(itemLayouts.yOffset, previewRowLimit),
|
||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
@@ -483,7 +413,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
||||
gte(sectionLayouts.xOffset, 0),
|
||||
gte(sectionLayouts.yOffset, 0),
|
||||
lt(sectionLayouts.yOffset, previewRowLimit),
|
||||
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(sectionLayouts.width, 0),
|
||||
gt(sectionLayouts.height, 0),
|
||||
),
|
||||
@@ -518,7 +448,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
lt(itemLayouts.yOffset, previewRowLimit),
|
||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
@@ -722,7 +652,7 @@ export const boardRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Every widget-integration configuration must be valid. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
|
||||
"Duplicate an existing board into a new board. Requires board-create permission and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
|
||||
},
|
||||
})
|
||||
.input(boardDuplicateSchema)
|
||||
@@ -759,14 +689,6 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
|
||||
throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems);
|
||||
await validateWidgetConfigurationsAsync(
|
||||
ctx,
|
||||
boardItems.map((item) => ({
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
integrationIds: item.integrations.map(({ integrationId }) => integrationId),
|
||||
})),
|
||||
);
|
||||
|
||||
const newBoardId = createId();
|
||||
|
||||
@@ -847,19 +769,17 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections
|
||||
.flatMap((section) =>
|
||||
section.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof sectionLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
parentSectionId: layoutSection.parentSectionId
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionMap.get(layoutSection.parentSectionId)!
|
||||
: layoutSection.parentSectionId,
|
||||
}),
|
||||
),
|
||||
section.layouts.map((layoutSection): InferInsertModel<typeof sectionLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
parentSectionId: layoutSection.parentSectionId
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionMap.get(layoutSection.parentSectionId)!
|
||||
: layoutSection.parentSectionId,
|
||||
})),
|
||||
)
|
||||
.concat(
|
||||
(generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
|
||||
@@ -876,13 +796,11 @@ export const boardRouter = createTRPCRouter({
|
||||
);
|
||||
const sectionCollapseStatesToInsert: InferInsertModel<typeof sectionCollapseStates>[] = boardSections.flatMap(
|
||||
(section) =>
|
||||
section.collapseStates.map(
|
||||
(collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
|
||||
...collapseState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(collapseState.sectionId)!,
|
||||
}),
|
||||
),
|
||||
section.collapseStates.map((collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
|
||||
...collapseState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(collapseState.sectionId)!,
|
||||
})),
|
||||
);
|
||||
|
||||
const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()]));
|
||||
@@ -897,17 +815,15 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems
|
||||
.flatMap((item) =>
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(layoutSection.itemId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
}),
|
||||
),
|
||||
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(layoutSection.itemId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
})),
|
||||
)
|
||||
.concat(
|
||||
(generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
|
||||
@@ -921,12 +837,34 @@ export const boardRouter = createTRPCRouter({
|
||||
})),
|
||||
);
|
||||
|
||||
// Creates a list with all integration ids the user has access to
|
||||
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
|
||||
const integrationIdsWithAccess = hasAccessForAll
|
||||
? []
|
||||
: await ctx.db
|
||||
.selectDistinct({
|
||||
id: integrationGroupPermissions.integrationId,
|
||||
})
|
||||
.from(integrationGroupPermissions)
|
||||
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
|
||||
.where(eq(groupMembers.userId, ctx.session.user.id))
|
||||
.union(
|
||||
ctx.db
|
||||
.selectDistinct({ id: integrationUserPermissions.integrationId })
|
||||
.from(integrationUserPermissions)
|
||||
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
|
||||
)
|
||||
.then((result) => result.map((row) => row.id));
|
||||
|
||||
const itemIntegrationsToInsert = boardItems.flatMap((item) =>
|
||||
item.integrations.map((integration) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(item.id)!,
|
||||
integrationId: integration.integrationId,
|
||||
})),
|
||||
item.integrations
|
||||
// Restrict integrations to only those the user has access to
|
||||
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
|
||||
.map((integration) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(item.id)!,
|
||||
integrationId: integration.integrationId,
|
||||
})),
|
||||
);
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
@@ -1569,7 +1507,6 @@ export const boardRouter = createTRPCRouter({
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||
|
||||
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
await validateWidgetConfigurationsAsync(ctx, input.items, dbBoard.items);
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: ctx.session.user.permissions.includes("admin"),
|
||||
submittedItems: input.items,
|
||||
@@ -1579,9 +1516,10 @@ export const boardRouter = createTRPCRouter({
|
||||
for (const item of input.items) {
|
||||
if (item.kind !== "timetable") continue;
|
||||
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
|
||||
let previousOptions: Record<string, unknown> | undefined;
|
||||
if (previousItem?.kind === "timetable") previousOptions = previousItem.options;
|
||||
await validateTimetableOptionsChangeAsync(item.options, previousOptions);
|
||||
await validateTimetableOptionsChangeAsync(
|
||||
item.options,
|
||||
previousItem?.kind === "timetable" ? previousItem.options : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
@@ -1605,17 +1543,15 @@ export const boardRouter = createTRPCRouter({
|
||||
const sectionLayoutsToInsert = addedSections
|
||||
.filter((section) => section.kind === "container")
|
||||
.flatMap((section) =>
|
||||
section.layouts.map(
|
||||
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
}),
|
||||
),
|
||||
section.layouts.map((sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
})),
|
||||
);
|
||||
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
@@ -1637,17 +1573,15 @@ export const boardRouter = createTRPCRouter({
|
||||
);
|
||||
await transaction.insert(schema.itemLayouts).values(
|
||||
addedItems.flatMap((item) =>
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
}),
|
||||
),
|
||||
item.layouts.map((layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1805,17 +1739,15 @@ export const boardRouter = createTRPCRouter({
|
||||
const sectionLayoutsToInsert = addedSections
|
||||
.filter((section) => section.kind === "container")
|
||||
.flatMap((section) =>
|
||||
section.layouts.map(
|
||||
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
}),
|
||||
),
|
||||
section.layouts.map((sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
})),
|
||||
);
|
||||
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
@@ -1842,17 +1774,15 @@ export const boardRouter = createTRPCRouter({
|
||||
.insert(itemLayouts)
|
||||
.values(
|
||||
addedItems.flatMap((item) =>
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
}),
|
||||
),
|
||||
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
@@ -2155,10 +2085,6 @@ export const boardRouter = createTRPCRouter({
|
||||
.output(z.object({ itemId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
|
||||
await validateWidgetConfigurationsAsync(ctx, [
|
||||
{ id: "", kind: input.kind, integrationIds: input.integrationIds },
|
||||
]);
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: ctx.session.user.permissions.includes("admin"),
|
||||
submittedItems: [{ id: "", kind: input.kind, options: input.options }],
|
||||
@@ -2169,6 +2095,35 @@ export const boardRouter = createTRPCRouter({
|
||||
await validateTimetableOptionsChangeAsync(input.options);
|
||||
}
|
||||
|
||||
let selectedIntegrationKinds: IntegrationKind[] = [];
|
||||
if (input.integrationIds.length > 0) {
|
||||
const existing = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true, kind: true },
|
||||
where: inArray(integrations.id, input.integrationIds),
|
||||
});
|
||||
const validIds = new Set(existing.map((row) => row.id));
|
||||
const invalid = input.integrationIds.filter((id) => !validIds.has(id));
|
||||
if (invalid.length > 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
|
||||
}
|
||||
|
||||
selectedIntegrationKinds = existing.map((integration) => integration.kind);
|
||||
|
||||
await Promise.all(
|
||||
input.integrationIds.map((integrationId) =>
|
||||
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const integrationIssue = getWidgetIntegrationIssue(input.kind, selectedIntegrationKinds);
|
||||
if (integrationIssue) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: getWidgetIntegrationIssueMessage(input.kind, integrationIssue),
|
||||
});
|
||||
}
|
||||
|
||||
return await serializeBoardItemPlacementAsync(input.boardId, async () => {
|
||||
const board = await ctx.db.query.boards.findFirst({
|
||||
where: eq(boards.id, input.boardId),
|
||||
@@ -2814,11 +2769,14 @@ export const boardLayoutsNeedRepair = (
|
||||
) => {
|
||||
const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
|
||||
const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
|
||||
const baseLayout = baseLayouts.at(0);
|
||||
|
||||
return (
|
||||
mobileLayouts.length !== 1 ||
|
||||
baseLayouts.length !== 1 ||
|
||||
mobileLayouts.at(0)?.breakpoint !== 0 ||
|
||||
!baseLayout ||
|
||||
boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) ||
|
||||
new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
|
||||
);
|
||||
};
|
||||
|
||||
@@ -84,13 +84,15 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
const appAmbiguous =
|
||||
!linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1));
|
||||
const ambiguous = integrationAmbiguous || appAmbiguous;
|
||||
const state = getReconciliationState({
|
||||
integrationMoved,
|
||||
linked,
|
||||
represented: Boolean(integration || app),
|
||||
recognized: Boolean(match),
|
||||
});
|
||||
const nextAction = getCandidateNextAction(state, integrationAmbiguous, appAmbiguous);
|
||||
const state: DockerReconciliationState = integrationMoved
|
||||
? "moved"
|
||||
: linked
|
||||
? "linked"
|
||||
: integration || app
|
||||
? "represented"
|
||||
: match
|
||||
? "newRecognized"
|
||||
: "newApp";
|
||||
|
||||
return {
|
||||
candidateKey: container.resourceId,
|
||||
@@ -101,7 +103,11 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
match,
|
||||
urlCandidates,
|
||||
state,
|
||||
nextAction,
|
||||
nextAction: integrationAmbiguous
|
||||
? ("reviewIntegration" as const)
|
||||
: appAmbiguous
|
||||
? ("viewRepresentation" as const)
|
||||
: nextActionForState(state),
|
||||
representation: {
|
||||
integration: integration
|
||||
? {
|
||||
@@ -141,100 +147,87 @@ export const getDockerServiceHealthAsync = async (db: Database) => {
|
||||
status: endpoint.status,
|
||||
nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const),
|
||||
})),
|
||||
services: reconciliation.candidates.map((candidate) => {
|
||||
const integration = candidate.representation.integration;
|
||||
const app = candidate.representation.app;
|
||||
const itemCount = integration?.itemCount;
|
||||
|
||||
return {
|
||||
key: candidate.candidateKey,
|
||||
identity: {
|
||||
endpointId: candidate.endpointId,
|
||||
nativeId: candidate.nativeId,
|
||||
name: candidate.container.name,
|
||||
integration,
|
||||
app,
|
||||
services: reconciliation.candidates.map((candidate) => ({
|
||||
key: candidate.candidateKey,
|
||||
identity: {
|
||||
endpointId: candidate.endpointId,
|
||||
nativeId: candidate.nativeId,
|
||||
name: candidate.container.name,
|
||||
integration: candidate.representation.integration,
|
||||
app: candidate.representation.app,
|
||||
},
|
||||
layers: [
|
||||
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
|
||||
{
|
||||
layer: "integrationConfiguration" as const,
|
||||
status: integrationHealthStatus(
|
||||
candidate.state,
|
||||
candidate.match?.kind,
|
||||
Boolean(candidate.representation.integration),
|
||||
),
|
||||
nextAction:
|
||||
candidate.nextAction === "reviewIntegration"
|
||||
? ("reviewIntegration" as const)
|
||||
: candidate.state === "newRecognized"
|
||||
? ("setupIntegration" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
layers: [
|
||||
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
|
||||
{
|
||||
layer: "integrationConfiguration" as const,
|
||||
status: integrationHealthStatus(candidate.state, candidate.match?.kind, Boolean(integration)),
|
||||
nextAction: getIntegrationConfigurationNextAction(candidate.state, candidate.nextAction),
|
||||
},
|
||||
{
|
||||
layer: "authentication" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: getAuthenticationNextAction(
|
||||
Boolean(integration),
|
||||
Boolean(candidate.match),
|
||||
candidate.nextAction,
|
||||
),
|
||||
},
|
||||
{
|
||||
layer: "apiRequest" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: integration ? ("openIntegrationDiagnostics" as const) : ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "appRepresentation" as const,
|
||||
status: app ? ("linked" as const) : ("missing" as const),
|
||||
nextAction: getAppRepresentationNextAction(
|
||||
Boolean(app),
|
||||
candidate.representation.signals.ambiguous,
|
||||
candidate.nextAction,
|
||||
),
|
||||
},
|
||||
{
|
||||
layer: "widgetConfiguration" as const,
|
||||
status: getWidgetConfigurationStatus(itemCount),
|
||||
nextAction: itemCount === 0 ? ("addWidget" as const) : ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "widgetQuery" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: itemCount !== undefined && itemCount > 0 ? ("openBoard" as const) : ("none" as const),
|
||||
},
|
||||
],
|
||||
status: candidate.state,
|
||||
nextAction: candidate.nextAction,
|
||||
};
|
||||
}),
|
||||
{
|
||||
layer: "authentication" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: candidate.representation.integration
|
||||
? ("testConnection" as const)
|
||||
: candidate.nextAction === "reviewIntegration"
|
||||
? ("reviewIntegration" as const)
|
||||
: candidate.match
|
||||
? ("setupIntegration" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "apiRequest" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: candidate.representation.integration
|
||||
? ("openIntegrationDiagnostics" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "appRepresentation" as const,
|
||||
status: candidate.representation.app ? ("linked" as const) : ("missing" as const),
|
||||
nextAction: candidate.representation.app
|
||||
? ("none" as const)
|
||||
: candidate.representation.signals.ambiguous && candidate.nextAction === "viewRepresentation"
|
||||
? ("viewRepresentation" as const)
|
||||
: ("createApp" as const),
|
||||
},
|
||||
{
|
||||
layer: "widgetConfiguration" as const,
|
||||
status: candidate.representation.integration
|
||||
? candidate.representation.integration.itemCount > 0
|
||||
? ("linked" as const)
|
||||
: ("unused" as const)
|
||||
: ("notApplicable" as const),
|
||||
nextAction:
|
||||
candidate.representation.integration && candidate.representation.integration.itemCount === 0
|
||||
? ("addWidget" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "widgetQuery" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction:
|
||||
candidate.representation.integration && candidate.representation.integration.itemCount > 0
|
||||
? ("openBoard" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
],
|
||||
status: candidate.state,
|
||||
nextAction: candidate.nextAction,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeName = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
interface ReconciliationStateInput {
|
||||
integrationMoved: boolean;
|
||||
linked: boolean;
|
||||
represented: boolean;
|
||||
recognized: boolean;
|
||||
}
|
||||
|
||||
const getReconciliationState = ({
|
||||
integrationMoved,
|
||||
linked,
|
||||
represented,
|
||||
recognized,
|
||||
}: ReconciliationStateInput): DockerReconciliationState => {
|
||||
if (integrationMoved) return "moved";
|
||||
if (linked) return "linked";
|
||||
if (represented) return "represented";
|
||||
if (recognized) return "newRecognized";
|
||||
return "newApp";
|
||||
};
|
||||
|
||||
const getCandidateNextAction = (
|
||||
state: DockerReconciliationState,
|
||||
integrationAmbiguous: boolean,
|
||||
appAmbiguous: boolean,
|
||||
) => {
|
||||
if (integrationAmbiguous) return "reviewIntegration" as const;
|
||||
if (appAmbiguous) return "viewRepresentation" as const;
|
||||
return nextActionForState(state);
|
||||
};
|
||||
|
||||
const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => {
|
||||
if (state === "newRecognized") return "setupIntegration";
|
||||
if (state === "newApp") return "createApp";
|
||||
@@ -242,42 +235,6 @@ const nextActionForState = (state: DockerReconciliationState): DockerReconciliat
|
||||
return "viewRepresentation";
|
||||
};
|
||||
|
||||
const getIntegrationConfigurationNextAction = (
|
||||
state: DockerReconciliationState,
|
||||
nextAction: DockerReconciliationNextAction,
|
||||
) => {
|
||||
if (nextAction === "reviewIntegration") return "reviewIntegration" as const;
|
||||
if (state === "newRecognized") return "setupIntegration" as const;
|
||||
return "none" as const;
|
||||
};
|
||||
|
||||
const getAuthenticationNextAction = (
|
||||
hasIntegration: boolean,
|
||||
hasMatch: boolean,
|
||||
nextAction: DockerReconciliationNextAction,
|
||||
) => {
|
||||
if (hasIntegration) return "testConnection" as const;
|
||||
if (nextAction === "reviewIntegration") return "reviewIntegration" as const;
|
||||
if (hasMatch) return "setupIntegration" as const;
|
||||
return "none" as const;
|
||||
};
|
||||
|
||||
const getAppRepresentationNextAction = (
|
||||
hasApp: boolean,
|
||||
ambiguous: boolean,
|
||||
nextAction: DockerReconciliationNextAction,
|
||||
) => {
|
||||
if (hasApp) return "none" as const;
|
||||
if (ambiguous && nextAction === "viewRepresentation") return "viewRepresentation" as const;
|
||||
return "createApp" as const;
|
||||
};
|
||||
|
||||
const getWidgetConfigurationStatus = (itemCount: number | undefined) => {
|
||||
if (itemCount === undefined) return "notApplicable" as const;
|
||||
if (itemCount > 0) return "linked" as const;
|
||||
return "unused" as const;
|
||||
};
|
||||
|
||||
const integrationHealthStatus = (
|
||||
state: DockerReconciliationState,
|
||||
kind: IntegrationKind | undefined,
|
||||
@@ -285,6 +242,5 @@ const integrationHealthStatus = (
|
||||
) => {
|
||||
if (!kind) return "notApplicable" as const;
|
||||
if (state === "moved") return "changed" as const;
|
||||
if (hasIntegration) return "configured" as const;
|
||||
return "missing" as const;
|
||||
return hasIntegration ? ("configured" as const) : ("missing" as const);
|
||||
};
|
||||
@@ -245,8 +245,8 @@ type DockerContainerAction = "start" | "stop" | "restart" | "remove";
|
||||
export const performDockerContainerActionsAsync = async (
|
||||
targets: DockerContainerTarget[],
|
||||
action: DockerContainerAction,
|
||||
) => {
|
||||
const results = await Promise.all(
|
||||
) =>
|
||||
await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const capability = action === "remove" ? "remove" : "lifecycle";
|
||||
@@ -271,13 +271,6 @@ export const performDockerContainerActionsAsync = async (
|
||||
}),
|
||||
);
|
||||
|
||||
if (results.some(({ success }) => success)) {
|
||||
dockerContainersRequestHandler.invalidateCache();
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const assertDockerEndpointCapability = (
|
||||
endpointId: string,
|
||||
capability: "inventory" | "logs" | "lifecycle" | "remove",
|
||||
|
||||
@@ -1,59 +1,12 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import type { IntegrationPermissionsProps } from "@homarr/auth/shared";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import type { IntegrationPermission } from "@homarr/definitions";
|
||||
|
||||
const hasIntegrationPermission = (
|
||||
integration: IntegrationPermissionsProps,
|
||||
session: Session | null,
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
|
||||
if (hasFullAccess) return true;
|
||||
if (permission === "interact") return hasInteractAccess;
|
||||
if (permission === "use") return hasUseAccess || hasInteractAccess;
|
||||
return false;
|
||||
};
|
||||
|
||||
const throwIfActionsForbiddenAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
integrationWhere: SQL<unknown>,
|
||||
expectedCount: number,
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
const { db, session } = ctx;
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
||||
});
|
||||
const integrationRecords = await db.query.integrations.findMany({
|
||||
where: integrationWhere,
|
||||
columns: { id: true },
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map(({ groupId }) => groupId).concat(""),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
integrationRecords.length !== expectedCount ||
|
||||
!integrationRecords.every((integration) => hasIntegrationPermission(integration, session, permission))
|
||||
) {
|
||||
notAllowed();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws NOT_FOUND if user is not allowed to perform action on integration
|
||||
* @param ctx trpc router context
|
||||
@@ -65,23 +18,47 @@ export const throwIfActionForbiddenAsync = async (
|
||||
integrationWhere: SQL<unknown>,
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
await throwIfActionsForbiddenAsync(ctx, integrationWhere, 1, permission);
|
||||
};
|
||||
const { db, session } = ctx;
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
||||
});
|
||||
const integration = await db.query.integrations.findFirst({
|
||||
where: integrationWhere,
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const throwIfIntegrationActionsForbiddenAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
integrationIds: readonly string[],
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
const uniqueIntegrationIds = [...new Set(integrationIds)];
|
||||
if (uniqueIntegrationIds.length === 0) return;
|
||||
if (!integration) {
|
||||
notAllowed();
|
||||
}
|
||||
|
||||
await throwIfActionsForbiddenAsync(
|
||||
ctx,
|
||||
inArray(integrations.id, uniqueIntegrationIds),
|
||||
uniqueIntegrationIds.length,
|
||||
permission,
|
||||
);
|
||||
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
|
||||
|
||||
if (hasFullAccess) {
|
||||
return; // As full access is required and user has full access, allow
|
||||
}
|
||||
|
||||
if (["interact", "use"].includes(permission) && hasInteractAccess) {
|
||||
return; // As interact access is required and user has interact access, allow
|
||||
}
|
||||
|
||||
if (permission === "use" && hasUseAccess) {
|
||||
return; // As use access is required and user has use access, allow
|
||||
}
|
||||
|
||||
notAllowed();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,10 +27,8 @@ import {
|
||||
integrationKinds,
|
||||
integrationSecretKindObject,
|
||||
} from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
|
||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
integrationCreateSchema,
|
||||
@@ -158,21 +156,9 @@ export const integrationRouter = createTRPCRouter({
|
||||
})
|
||||
.input(byIdSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integrationWhere = eq(integrations.id, input.id);
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, integrationWhere, "full").catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
logger.warn("Integration lookup refused", {
|
||||
integrationId: input.id,
|
||||
errorCode: error.code,
|
||||
reason: "integration-not-found-or-forbidden",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: integrationWhere,
|
||||
where: eq(integrations.id, input.id),
|
||||
with: {
|
||||
secrets: {
|
||||
columns: {
|
||||
@@ -193,11 +179,6 @@ export const integrationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
logger.warn("Integration lookup refused", {
|
||||
integrationId: input.id,
|
||||
errorCode: "NOT_FOUND",
|
||||
reason: "integration-not-found",
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration not found",
|
||||
@@ -714,17 +695,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
.input(mediaRequestRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
const result = await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
try {
|
||||
mediaRequestListRequestHandler.invalidateCache();
|
||||
await mediaRequestStatsRequestHandler.invalidateCacheAsync([ctx.integration.id]);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to invalidate media request caches after a successful request", {
|
||||
integrationId: ctx.integration.id,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
|
||||
const logger = createLogger({ module: "integrationTestConnection" });
|
||||
|
||||
|
||||
@@ -218,7 +218,6 @@ export const onboardRouter = createTRPCRouter({
|
||||
const discoveredApps: DiscoveredApp[] = [];
|
||||
|
||||
for (const service of discovery.services) {
|
||||
const widgetKind = service.widgetKind;
|
||||
if (service.integrationKind) {
|
||||
discoveredIntegrations.push({
|
||||
sourceId: service.sourceId,
|
||||
@@ -232,7 +231,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
group: service.group,
|
||||
boardName: service.boardName,
|
||||
host: service.host,
|
||||
widgetKind,
|
||||
widgetKind: service.widgetKind,
|
||||
description: service.description,
|
||||
pingUrl: service.pingUrl,
|
||||
});
|
||||
@@ -250,7 +249,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
description: service.description,
|
||||
pingUrl: service.pingUrl,
|
||||
host: service.host,
|
||||
widgetKind,
|
||||
widgetKind: service.widgetKind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,14 +41,6 @@ const defaultSession = {
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const integrationUseSession = {
|
||||
...defaultSession,
|
||||
user: {
|
||||
...defaultSession.user,
|
||||
permissions: ["integration-use-all"],
|
||||
},
|
||||
} satisfies Session;
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
|
||||
@@ -706,8 +698,7 @@ describe("duplicateBoard should preserve protected layout invariants", () => {
|
||||
},
|
||||
};
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { boardId, integrationId } = await createFullBoardAsync(db, "source");
|
||||
await db.delete(integrationItems).where(eq(integrationItems.integrationId, integrationId));
|
||||
const { boardId } = await createFullBoardAsync(db, "source");
|
||||
|
||||
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
|
||||
|
||||
@@ -1204,7 +1195,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should remove integration reference when not present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const anotherIntegration = {
|
||||
id: createId(),
|
||||
kind: "adGuardHome",
|
||||
@@ -1228,8 +1219,8 @@ describe("saveBoard should save full board", () => {
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
kind: "dnsHoleSummary",
|
||||
options: { usePiHoleColors: true, layout: "grid" },
|
||||
kind: "clock",
|
||||
options: { is24HourFormat: true },
|
||||
integrationIds: [anotherIntegration.id],
|
||||
layouts: [
|
||||
{
|
||||
@@ -1535,7 +1526,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should add integration reference when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const integration = {
|
||||
id: createId(),
|
||||
kind: "plex",
|
||||
@@ -1559,8 +1550,8 @@ describe("saveBoard should save full board", () => {
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
kind: "mediaServer",
|
||||
options: { showOnlyPlaying: true, showBitrate: true, showLocation: true },
|
||||
kind: "clock",
|
||||
options: { is24HourFormat: true },
|
||||
integrationIds: [integration.id],
|
||||
layouts: [
|
||||
{
|
||||
|
||||
@@ -14,7 +14,6 @@ import { dockerRouter } from "../../docker/docker-router";
|
||||
const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn());
|
||||
const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true));
|
||||
const invalidateDockerCacheMock = vi.hoisted(() => vi.fn());
|
||||
const resetDockerSingletonMock = vi.hoisted(() => vi.fn());
|
||||
const getDockerDataAsyncMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
() => Promise<{
|
||||
@@ -63,7 +62,7 @@ vi.mock("@homarr/docker", () => ({
|
||||
},
|
||||
},
|
||||
],
|
||||
reset: resetDockerSingletonMock,
|
||||
reset: () => undefined,
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/request-handler/docker", () => ({
|
||||
@@ -502,7 +501,6 @@ test("invalidates the shared Docker inventory on explicit refresh", async () =>
|
||||
await createAdminCaller().refreshInventory();
|
||||
|
||||
expect(invalidateDockerCacheMock).toHaveBeenCalledOnce();
|
||||
expect(resetDockerSingletonMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("projects persisted service layers without inventing runtime health", async () => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as homarrDefinitions from "@homarr/definitions";
|
||||
import type * as homarrIntegrations from "@homarr/integrations";
|
||||
import * as homarrIntegrationFactory from "@homarr/integrations/factory";
|
||||
import * as homarrIntegrations from "@homarr/integrations";
|
||||
|
||||
import { testConnectionAsync } from "../../integration/integration-test-connection";
|
||||
|
||||
@@ -19,7 +18,7 @@ vi.mock("@homarr/common/server", async (importActual) => {
|
||||
describe("testConnectionAsync should run test connection of integration", () => {
|
||||
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -62,7 +61,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -112,7 +111,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -162,7 +161,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -216,7 +215,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
|
||||
@@ -280,7 +280,7 @@ describe("onboard.completeSetup", () => {
|
||||
expect(mediaMissing).toBeDefined();
|
||||
expect(
|
||||
await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }),
|
||||
).toMatchObject({ width: 2, height: 2 });
|
||||
).toMatchObject({ width: 4, height: 3 });
|
||||
});
|
||||
|
||||
it("requires an exact board id when more than one board exists", async () => {
|
||||
|
||||
@@ -3,12 +3,15 @@ import { z } from "zod/v4";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "@homarr/integrations/anchor";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import {
|
||||
anchorNotesListInputSchema,
|
||||
anchorNoteUpdateInputSchema,
|
||||
createIntegrationAsync,
|
||||
mockWidgetData,
|
||||
} from "@homarr/integrations";
|
||||
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const noteIdInput = z.object({
|
||||
@@ -48,7 +51,7 @@ const normalizeAnchorContent = (content: string | undefined) => {
|
||||
|
||||
export const anchorNotesRouter = createTRPCRouter({
|
||||
listNotes: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
|
||||
.input(anchorNotesListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") {
|
||||
@@ -70,7 +73,7 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
return data;
|
||||
}),
|
||||
getNote: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
|
||||
.input(noteIdInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") {
|
||||
@@ -88,7 +91,7 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
return data;
|
||||
}),
|
||||
updateNote: protectedProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote"))
|
||||
.concat(createOneIntegrationMiddleware("interact", "anchor", "mock"))
|
||||
.input(anchorNoteUpdateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const normalizedContent = normalizeAnchorContent(input.content);
|
||||
@@ -112,8 +115,6 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
...(input.title !== undefined ? { title: input.title } : {}),
|
||||
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
|
||||
});
|
||||
anchorNotesListRequestHandler.invalidateCache();
|
||||
await anchorNoteRequestHandler.invalidateCacheAsync([ctx.integration.id]);
|
||||
|
||||
return updatedNote;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const archiveTeamWarriorRouter = createTRPCRouter({
|
||||
getStatus: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") {
|
||||
return { status: mockWidgetData.archiveTeamWarrior, updatedAt: new Date(mockWidgetData.timestamp) };
|
||||
|
||||
@@ -2,12 +2,12 @@ import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobooks
|
||||
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const audioStatsRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "audioStats"))
|
||||
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const { kind } = ctx.integration;
|
||||
if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const bazarrRouter = createTRPCRouter({
|
||||
@@ -13,7 +13,7 @@ export const bazarrRouter = createTRPCRouter({
|
||||
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
|
||||
},
|
||||
})
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "bazarr"))
|
||||
.concat(createOneIntegrationMiddleware("query", "bazarr", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.bazarr;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { LiveStatsEvent } from "@homarr/integrations/types";
|
||||
import {
|
||||
beszelAlertsRequestHandler,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@homarr/request-handler/beszel";
|
||||
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { BoundedAsyncQueue } from "./bounded-async-queue";
|
||||
|
||||
@@ -27,7 +27,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemGrid"))
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
|
||||
@@ -71,7 +71,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
"Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)",
|
||||
},
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelAlerts"))
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
includeHistory: z.boolean().default(true),
|
||||
@@ -145,7 +145,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
|
||||
},
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
@@ -196,7 +196,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { radarrReleaseTypes } from "@homarr/integrations/types";
|
||||
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -24,7 +25,7 @@ export const calendarRouter = createTRPCRouter({
|
||||
showUnmonitored: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "calendar"))
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
@@ -43,7 +44,7 @@ export const calendarRouter = createTRPCRouter({
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
error: undefined,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -17,7 +17,7 @@ interface CoolifyInstanceResult {
|
||||
|
||||
export const coolifyRouter = createTRPCRouter({
|
||||
getInstancesInfo: publicProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "coolify"))
|
||||
.concat(createManyIntegrationMiddleware("query", "coolify", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||
|
||||
import {
|
||||
createManyWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -34,7 +32,7 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
"Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "dnsHoleSummary"))
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>(
|
||||
ctx.integrations,
|
||||
@@ -68,11 +66,10 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
"Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
|
||||
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.mutation(async ({ ctx: { integration } }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.enableAsync();
|
||||
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
}),
|
||||
|
||||
disable: protectedProcedure
|
||||
@@ -88,10 +85,9 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
duration: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
|
||||
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.disableAsync(input.duration);
|
||||
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
}),
|
||||
});
|
||||
@@ -1,15 +1,19 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||
import { downloadClientItemSchema } from "@homarr/integrations/downloads";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
|
||||
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient"));
|
||||
|
||||
interface DownloadClientQueryResultBase {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
@@ -38,14 +42,6 @@ type DownloadClientQueryResult = DownloadClientQueryResultBase &
|
||||
}
|
||||
);
|
||||
|
||||
const runDownloadMutationsAsync = async (mutations: Promise<void>[], integrationIds: string[]) => {
|
||||
const results = await Promise.allSettled(mutations);
|
||||
await downloadClientRequestHandler.invalidateCacheAsync(integrationIds);
|
||||
|
||||
const failure = results.find((result) => result.status === "rejected");
|
||||
if (failure) throw failure.reason;
|
||||
};
|
||||
|
||||
export const downloadsRouter = createTRPCRouter({
|
||||
getJobsAndStatuses: publicProcedure
|
||||
.meta({
|
||||
@@ -55,7 +51,7 @@ export const downloadsRouter = createTRPCRouter({
|
||||
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
|
||||
},
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "downloads"))
|
||||
.concat(createDownloadClientIntegrationMiddleware("query"))
|
||||
.input(z.object({ limitPerIntegration: z.number().default(50) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>(
|
||||
@@ -90,27 +86,27 @@ export const downloadsRouter = createTRPCRouter({
|
||||
"Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await runDownloadMutationsAsync(
|
||||
await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.pauseQueueAsync();
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
pauseItem: protectedProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.input(z.object({ item: downloadClientItemSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await runDownloadMutationsAsync(
|
||||
await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.pauseItemAsync(input.item);
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
resume: protectedProcedure
|
||||
.meta({
|
||||
@@ -120,38 +116,38 @@ export const downloadsRouter = createTRPCRouter({
|
||||
"Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await runDownloadMutationsAsync(
|
||||
await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.resumeQueueAsync();
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
resumeItem: protectedProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.input(z.object({ item: downloadClientItemSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await runDownloadMutationsAsync(
|
||||
await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.resumeItemAsync(input.item);
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
deleteItem: protectedProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await runDownloadMutationsAsync(
|
||||
await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import {
|
||||
firewallCpuRequestHandler,
|
||||
@@ -6,37 +8,36 @@ import {
|
||||
firewallVersionRequestHandler,
|
||||
} from "@homarr/request-handler/firewall";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall");
|
||||
const firewallMiddleware = createManyIntegrationMiddleware(
|
||||
"query",
|
||||
...getIntegrationKindsByCategory("firewall"),
|
||||
"mock",
|
||||
);
|
||||
|
||||
type FirewallIntegration = Parameters<typeof firewallCpuRequestHandler.handler>[0];
|
||||
|
||||
interface FirewallHandler<TData> {
|
||||
handler: (
|
||||
integration: FirewallIntegration,
|
||||
input: Record<string, never>,
|
||||
) => { getDataAsync: () => Promise<{ data: TData; timestamp: Date }> };
|
||||
interface FirewallResult<TSummary> {
|
||||
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
|
||||
summary: TSummary;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface FirewallQueryResult<TData> {
|
||||
integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: FirewallIntegration["kind"] | "mock";
|
||||
updatedAt: Date;
|
||||
};
|
||||
summary: TData;
|
||||
error: string | undefined;
|
||||
}
|
||||
|
||||
const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: () => TData, getMockData: () => TData) =>
|
||||
const queryFirewall = <TSummary>(
|
||||
handler: {
|
||||
handler: (
|
||||
integration: any,
|
||||
input: Record<string, never>,
|
||||
) => { getDataAsync: () => Promise<{ data: TSummary; timestamp: Date }> };
|
||||
},
|
||||
createFallback: () => TSummary,
|
||||
getMockData: () => TSummary,
|
||||
) =>
|
||||
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
|
||||
settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration): Promise<FirewallQueryResult<TData>> => {
|
||||
async (integration): Promise<FirewallResult<TSummary>> => {
|
||||
if (integration.kind === "mock") {
|
||||
return {
|
||||
integration: {
|
||||
@@ -57,7 +58,7 @@ const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: (
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error): FirewallQueryResult<TData> => ({
|
||||
fallback: (integration, error): FirewallResult<TSummary> => ({
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
|
||||
summary: createFallback(),
|
||||
error: toPublicIntegrationError(error),
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { SystemHealthMonitoring } from "@homarr/integrations";
|
||||
import { healthMonitoringClusterIntegrationKinds, healthMonitoringSystemIntegrationKinds } from "@homarr/definitions";
|
||||
import { SynologyIntegration } from "@homarr/integrations";
|
||||
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const healthMonitoringIntegrationKinds = [
|
||||
"openmediavault",
|
||||
"dashDot",
|
||||
"truenas",
|
||||
"unraid",
|
||||
"glances",
|
||||
"synology",
|
||||
"mock",
|
||||
] as const;
|
||||
|
||||
interface SystemHealthQueryResult {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
@@ -24,7 +34,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringSystemIntegrationKinds))
|
||||
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
|
||||
ctx.integrations,
|
||||
@@ -59,6 +69,10 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
.concat(createOneIntegrationMiddleware("query", "synology"))
|
||||
.query(async ({ ctx }) => {
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
if (!(integrationInstance instanceof SynologyIntegration)) {
|
||||
throw new Error("Expected Synology integration");
|
||||
}
|
||||
|
||||
return await integrationInstance.listStorageVolumesAsync();
|
||||
}),
|
||||
getClusterHealthStatus: publicProcedure
|
||||
@@ -69,7 +83,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
"Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", ...healthMonitoringClusterIntegrationKinds))
|
||||
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import {
|
||||
immichAlbumRequestHandler,
|
||||
@@ -7,21 +8,23 @@ import {
|
||||
immichStatsRequestHandler,
|
||||
} from "@homarr/request-handler/immich";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"), "mock");
|
||||
|
||||
export const immichRouter = createTRPCRouter({
|
||||
getServerStats: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "immich-serverStats"))
|
||||
.query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
|
||||
const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
|
||||
const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
|
||||
getAlbum: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
|
||||
.concat(createImmichIntegrationMiddleware("query"))
|
||||
.input(
|
||||
z.object({
|
||||
albumId: z.string().optional(),
|
||||
@@ -45,12 +48,9 @@ export const immichRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(500).optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
|
||||
.concat(createImmichIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") {
|
||||
if (input.limit === undefined) return [...mockWidgetData.immichAlbums];
|
||||
return mockWidgetData.immichAlbums.slice(0, input.limit);
|
||||
}
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit);
|
||||
const innerHandler = immichAlbumsRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "immich" },
|
||||
{
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager"));
|
||||
|
||||
export const indexerManagerRouter = createTRPCRouter({
|
||||
getIndexersStatus: publicProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "indexerManager"))
|
||||
.concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
@@ -21,7 +26,7 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
indexers,
|
||||
error: undefined,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -36,9 +41,9 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
testAllIndexers: protectedProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "indexerManager"))
|
||||
.concat(createIndexerManagerIntegrationMiddleware("interact"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
const results = await Promise.allSettled(
|
||||
await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.testAllAsync().catch((err) => {
|
||||
@@ -50,9 +55,5 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
);
|
||||
await indexerManagerRequestHandler.invalidateCacheAsync(ctx.integrations.map(({ id }) => id));
|
||||
|
||||
const firstFailure = results.find((result) => result.status === "rejected");
|
||||
if (firstFailure?.status === "rejected") throw firstFailure.reason;
|
||||
}),
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -15,7 +16,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
|
||||
"Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)",
|
||||
},
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaMissing"))
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
|
||||
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(
|
||||
@@ -27,7 +28,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
...data,
|
||||
error: undefined,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { getMediaReleaseResponse } from "./media-release-result";
|
||||
|
||||
export const mediaReleaseRouter = createTRPCRouter({
|
||||
getMediaReleases: publicProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaReleases"))
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
|
||||
.query(async ({ ctx }) => {
|
||||
return await getMediaReleaseResponse(ctx.integrations, async (integration) => {
|
||||
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
|
||||
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import {
|
||||
@@ -10,10 +11,7 @@ import {
|
||||
} from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
|
||||
import {
|
||||
createManyWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -32,7 +30,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
|
||||
},
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestList"))
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.input(mediaRequestListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results: IntegrationQueryResult<MediaRequest[]>[] = await settleIntegrationQueries(
|
||||
@@ -42,7 +40,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
data,
|
||||
error: undefined,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -87,7 +85,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
"Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestStats"))
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results: IntegrationQueryResult<{
|
||||
stats: MediaRequestStats["stats"] | null;
|
||||
@@ -98,8 +96,8 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
data,
|
||||
error: undefined,
|
||||
data: { ...data, stats: data.stats as typeof data.stats | null },
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -136,7 +134,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
"Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')",
|
||||
},
|
||||
})
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "mediaRequests-requestList"))
|
||||
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.input(
|
||||
z.object({
|
||||
requestId: z.number(),
|
||||
@@ -152,8 +150,6 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
} as const;
|
||||
|
||||
await answerActions[input.answer](input.requestId);
|
||||
mediaRequestListRequestHandler.invalidateCache();
|
||||
await mediaRequestStatsRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -1,11 +1,16 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaService"));
|
||||
|
||||
export const mediaServerRouter = createTRPCRouter({
|
||||
getCurrentStreams: publicProcedure
|
||||
.meta({
|
||||
@@ -15,7 +20,7 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
|
||||
},
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaServer"))
|
||||
.concat(createMediaServerIntegrationMiddleware("query"))
|
||||
.input(z.object({ showOnlyPlaying: z.boolean() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(
|
||||
@@ -29,7 +34,7 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
integrationName: integration.name,
|
||||
integrationKind: integration.kind,
|
||||
sessions: data,
|
||||
error: undefined,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
|
||||
import { paginatedSchema } from "@homarr/validation/common";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
|
||||
|
||||
export const mediaTranscodingRouter = createTRPCRouter({
|
||||
getDataAsync: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "mediaTranscoding"))
|
||||
.concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.input(paginatedSchema.pick({ page: true, pageSize: true }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
||||
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -21,7 +22,7 @@ interface NetworkControllerQueryResult {
|
||||
|
||||
export const networkControllerRouter = createTRPCRouter({
|
||||
summary: publicProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "networkControllerSummary"))
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications"));
|
||||
|
||||
export const notificationsRouter = createTRPCRouter({
|
||||
getNotifications: publicProcedure
|
||||
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "notifications"))
|
||||
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
@@ -22,7 +27,7 @@ export const notificationsRouter = createTRPCRouter({
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
data,
|
||||
error: undefined,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const paperlessNgxRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx"))
|
||||
.concat(createOneIntegrationMiddleware("query", "paperlessNgx", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx;
|
||||
const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {});
|
||||
|
||||
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user