enhance release v2 experience

This commit is contained in:
ajnart committed 2026-08-14 02:18:46 +02:00
1 parent 9b4970583e
commit ef2ed6c602
152 files changed
+7236 -764

No files matched your search

@@ -60,6 +60,70 @@ default data; use `pnpm db:seed` to seed it explicitly later.
Workshop contributors can set up PocketBase and Docusaurus with the dedicated
[Workshop development guide](../../workshop/development).
### Scaffold a native feature
Run `pnpm generate:feature` and choose `native-integration`, `widget`, or `integration-widget`. The generator creates
implementation, focused test, documentation, and English translation skeletons and updates the current explicit
registries. It stops before writing if a target exists, a registry anchor is ambiguous, or a supported integration is
unknown.
The generated code is a starting contract, not a finished feature. Replace the placeholder request, UI, tests, secrets,
categories, configuration, error states, and documentation with behavior specific to the service. Then run:
```sh
pnpm check:feature-contracts
pnpm test:feature-platform
```
The contract check reports the exact registry, docs path, translation, support mapping, or package export that needs
repair. It intentionally follows today's explicit registries; a future canonical feature descriptor can replace those
patches without changing the contributor-facing generator names.
Integration-capable widgets declare their server-safe relationship in `nativeFeatureCapabilities`. Homarr derives the
widget-to-integration map, reverse integration-to-widget map, optional-connection set, and server selection limits from
that descriptor. Widget definitions still mirror the supported list for client-side type inference; the focused widget
manifest test and `check:feature-contracts` prevent drift until those definition types can consume the descriptor
directly.
New integration skeletons also include response fixtures using `simulateResponseContractAsync`. Replace the generated
minimal schema and fixtures with representative upstream payloads: at least one successful response, important optional
or version-specific shapes, and a malformed response that must be rejected. The simulator uses the real parser without
network access and reports every fixture mismatch, so parser contracts can be reviewed before credentials are available.
### Review native feature states
In a development server, administrators can open `/manage/tools/feature-workbench` to inspect the shared `CatalogItem`
and integration response-contract fixtures. The workbench uses the application Mantine theme and the same segmented
fixture controls as the custom-widget preview. It covers ready, setup-required, standalone, connection-error, selected,
loading, disabled, parser-success, and parser-failure states without requiring a live service.
Use the workbench after changing shared catalog presentation or generated parser contracts, then run the focused test:
```sh
pnpm exec vitest run "apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.spec.tsx"
```
The workbench is deliberately development-only and is not linked from management navigation. It does not yet exercise
real integration branding, long translated labels, right-to-left text, high-contrast screenshots, touch interaction,
small viewport clipping, live network latency, retry transitions, or service-specific response schemas. Those remain
manual review items until screenshot and browser-automation coverage is added.
### Check one native feature
Run `pnpm check:feature <kind>` before submitting an integration or widget change. The command resolves the kind through
`integrationDefs`, `widgetKinds`, the widget manifest, creator registry, documentation slug maps, and capability
descriptor. When an integration and widget share a kind, such as `wud`, both sides are checked together.
```sh
pnpm check:feature wud --plan
pnpm check:feature wud
```
`--plan` (or `--dry-run`) prints the deterministic affected files and commands without executing them. The normal mode
runs feature contracts, nearby fixture and unit tests, affected package typechecks, and lint and formatting for only the
resolved files. Unknown kinds fail before any command runs. Live-service, container, and visual checks are printed as
optional and are never executed implicitly because they may require credentials, images, or a running development app.
### Useful commands
- `pnpm dev` — Next.js on port 3000.
@@ -76,6 +140,8 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated
- `pnpm db:seed` — seed the default database data explicitly.
- `pnpm db:studio` — inspect the database.
- `pnpm format` / `pnpm format:fix` — check or apply oxfmt.
- `pnpm generate:feature` — scaffold a native integration, widget, or paired feature.
- `pnpm check:feature-contracts` — validate native feature registries, docs, translations, support maps, and exports.
- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint.
- `pnpm typecheck` — typecheck all workspaces.
- `pnpm test` — unit and contract suites.
@@ -58,11 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
## Docker
| Environment Variable | Description | Possible values | Default |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
| Environment Variable | Description | Possible values | Default |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
| `DOCKER_ENDPOINTS` | JSON array of named, capability-scoped Docker or Podman endpoints. Supports socket, explicitly insecure TCP, and TLS transports. See the [Docker and Podman integration guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
| `DOCKER_HOSTNAMES` | Legacy comma-separated list of plaintext TCP hostnames. Ignored when `DOCKER_ENDPOINTS` is set | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Legacy comma-separated list of plaintext TCP ports. Ignored when `DOCKER_ENDPOINTS` is set | For example `2375,2376` | - |
| `DOCKER_SOCKET_PATHS` | Legacy comma-separated list of mounted Docker-compatible socket paths. Ignored when `DOCKER_ENDPOINTS` is set | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
## Database
@@ -122,9 +122,77 @@ services:
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
```
For named endpoints, transport security, or least-privilege actions, prefer `DOCKER_ENDPOINTS`. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
```yaml
services:
homarr:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./docker-ca.pem:/run/secrets/docker-ca.pem:ro
environment:
DOCKER_ENDPOINTS: >-
[
{
"id": "local",
"name": "Local Docker",
"kind": "docker",
"transport": { "type": "socket", "path": "/var/run/docker.sock" },
"capabilities": ["inventory", "logs", "lifecycle", "remove"]
},
{
"id": "remote-read-only",
"name": "Production inventory",
"kind": "docker",
"transport": {
"type": "tls",
"host": "docker.example.com",
"port": 2376,
"caPath": "/run/secrets/docker-ca.pem"
},
"capabilities": ["inventory", "logs"]
}
]
```
The stable `id` keeps containers from different endpoints distinct. `name` is shown in the interface, and `kind` can be `docker` or `podman`. Every endpoint must include the `inventory` capability; optionally add `logs`, `lifecycle`, and `remove`. Homarr hides or disables actions the endpoint does not allow and also enforces the same capabilities on the server.
Supported transports are:
- `socket`: an absolute in-container `path`
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
:::warning[Plaintext TCP]
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy, or TLS. The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`.
:::
When `DOCKER_ENDPOINTS` is set, it takes precedence over the legacy `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS` variables.
### Managing your Docker containers within Homarr
Homarr allows you to interact with Docker containers running on your system.
The **Service setup** section compares the visible containers with your existing Homarr apps and integrations. It keeps every service distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
Homarr can show a service as:
- **Ready to connect** when its image or name matches a supported integration
- **New service** when it can be added as an app but no integration is recognized
- **Already represented** or **Linked** when an app or integration already matches
- **Address changed** when a service appears to match an integration but its reachable address has changed
Suggested addresses are ranked and explain where they came from. A published address or Docker endpoint host is normally usable from a browser; a container-name address is server-only and requires Homarr to share the container network. Wildcard addresses such as `0.0.0.0` are never offered as browser destinations. You can edit every suggestion before continuing.
:::info[Review before changing]
Service discovery is read-only. Homarr never creates or changes an app or integration automatically; use the action on an individual suggestion to review and confirm its setup.
:::
Use the inbox filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same panel. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
The health badges are an evidence-based projection across Docker, integration configuration, app representation, and persisted widget links. Authentication, API request success, and widget query execution are labeled **Not observed** because Homarr does not currently retain trustworthy last-run results for these layers. Use the suggested integration test or board action to diagnose them live; **Not observed** is never presented as healthy.
If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation.
Each container row has an action menu with the following options:
- **View logs** - opens a dedicated logs page with a live terminal
@@ -14,6 +14,8 @@ import { kubernetesIntegration } from ".";
Homarr provides a user-friendly Kubernetes dashboard that offers an overview of your cluster’s resources in a clear and structured interface. It allows users to view key Kubernetes objects, including `Pods`, `Services`, `Ingresses`, `Nodes`, `ConfigMaps`, `Secrets`, `Namespaces`, and `Volumes`, as well as basic metrics when the Kubernetes Metrics Server is enabled.
Docker service discovery does not convert Kubernetes resources into Homarr apps or integrations. Kubernetes remains a separate, read-only operational view, so a cluster resource is never changed or linked automatically.
:::warning[Read only]
This integration is read-only, meaning Homarr does not modify or manage cluster resources but serves as an informational tool to enhance visibility into your Kubernetes environment.
:::
@@ -25,7 +27,7 @@ This integration is read-only, meaning Homarr does not modify or manage cluster
Before deploying Homarr in Kubernetes, ensure the following prerequisites are met:
- Kubernetes (Version >= 1.24.0)
- Metrics Server ([Install from here](https://github.com/kubernetes-sigs/metrics-server))
- Metrics Server ([install from here](https://github.com/kubernetes-sigs/metrics-server)) is optional. Without it, Homarr still shows cluster inventory, node status, and reserved capacity, and identifies live CPU and memory usage as unavailable.
- RBAC Enabled (Required for Homarr Helm chart)
### Configuring RBAC in Helm Chart
@@ -45,6 +47,25 @@ Enabling RBAC in the Helm chart creates the necessary Kubernetes Role-Based Acce
- ClusterRole & ClusterRoleBinding: Extends the same permissions cluster-wide for additional flexibility.
:::
### Multiple Kubernetes contexts
When Homarr runs outside a cluster, set `KUBECONFIG` explicitly so Homarr loads the contexts from the referenced kubeconfig file or files. Mount the kubeconfig read-only and set the standard `KUBECONFIG` environment variable to its path inside the Homarr container:
```yaml
services:
homarr:
volumes:
- ./kubeconfig:/app/config/kubeconfig:ro
environment:
KUBECONFIG: /app/config/kubeconfig
```
The Kubernetes management pages include a context selector. The selected context is stored in the page URL, so each React Query cache entry and every API request remains context-specific. Resources with the same namespace and name in two contexts therefore remain separate.
Context status is checked independently. If one context is unavailable, the selector still lists healthy contexts. A context is shown as **Metrics unavailable** when its Kubernetes API is reachable but Metrics Server is not; inventory remains available without live CPU and memory usage.
When Homarr runs inside Kubernetes without `KUBECONFIG`, it keeps using the in-cluster service account as the single default context.
### Homarr Dashboard Overview
Once deployed, Homarr provides a dashboard displaying Kubernetes metrics and resource information. The main page includes:
@@ -159,6 +159,11 @@ The visible corner controls provide a 44-pixel resize target, and edge controls
Resize handles stop at the tile's minimum allowed size while the pointer is still moving.
Exit edit mode to save the resulting positions.
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
Spotlight. It can add a widget, app, integration, Container, board, import, Workshop item, or Custom Widget when the
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
destinations before the widget is created, so the common flow does not require a second move step.
Pointer users can drag anywhere on a widget tile. Containers use the grab button at their top-left corner.
Resize tiles from any edge or corner handle.
On a touch screen, press and hold the tile before moving it; resize handles support direct touch input.
@@ -59,6 +59,15 @@ It is not allowed to create an integration without passing the test.
This is to ensure that you minimize the amount of broken applications on your Homarr instance.
The test and create button will perform a network request to the application and authenticate if required.
When you connect a service from a board, Homarr keeps you in the same setup flow. After the connection succeeds, it
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
widget returns to its settings with the new integration already selected.
When the connection unlocks useful widgets that are not on the current board yet, the confirmation also shows up to
three explainable recommendations. A recommendation is based only on the services you can use and the widget kinds
already present on that board. You can open the existing widget picker prefiltered to the recommendation, or dismiss
recommendations for that service on that board.
## Managing permissions
On the edit page of an integration, you can manage which users / groups can
@@ -8,6 +8,8 @@ import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardEmptyState } from "~/components/board/board-empty-state";
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
@@ -64,6 +66,8 @@ export const ClientBoard = () => {
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
@@ -1,18 +1,15 @@
"use client";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { IconPlus } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -20,60 +17,41 @@ export default function BoardEditActions() {
}
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
const { openModal } = useModalAction(UniversalCreateModal);
const t = useI18n();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const handledInitialAdd = useRef(false);
const handleSelectItem = () => {
openItemSelectModal({ boardId: board.id });
};
useEffect(() => {
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
handledInitialAdd.current = true;
addWidget();
const handleSelectApp = () => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({});
};
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("add");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [addWidget, board.id, pathname, router, searchParams]);
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{t("app.action.add")}
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{t("section.container.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<HeaderButton
aria-label={t("board.action.addContent")}
onClick={() =>
openModal({
boardActions: {
widget: addWidget,
app: addApp,
integration: canConnectService ? connectService : undefined,
container: addContainer,
},
})
}
>
<IconPlus stroke={1.5} />
</HeaderButton>
);
};
@@ -54,7 +54,12 @@ export default async function ManageBoardsPage() {
floatingPrimaryAction={canCreateBoards}
>
{boards.length === 0 && (
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
<NoResults
icon={IconLayoutDashboard}
title={t("noResults.title")}
description={t("noResults.description")}
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
/>
)}
{boards.length > 0 && (
<TourTarget id="manage-boards-list">
@@ -1,6 +1,8 @@
import type { RouterOutputs } from "@homarr/api";
export type AnyMappedTestConnectionError = Exclude<RouterOutputs["integration"]["create"], undefined>["error"];
type IntegrationCreateErrorResult = Extract<RouterOutputs["integration"]["create"], { error: unknown }>;
export type AnyMappedTestConnectionError = IntegrationCreateErrorResult["error"];
export type MappedTestConnectionCertificateError = Extract<AnyMappedTestConnectionError, { type: "certificate" }>;
export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"];
export type MappedError = Exclude<AnyMappedTestConnectionError["cause"], undefined>;
@@ -19,6 +19,7 @@ import {
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -49,10 +50,12 @@ interface NewIntegrationFormProps {
kind: IntegrationKind;
initialUrl?: string;
initialName?: string;
onSuccess: () => void;
onSuccess: (result?: CreatedIntegrationResult) => void;
onCancel?: () => void;
}
export type CreatedIntegrationResult = Extract<RouterOutputs["integration"]["create"], { integration: unknown }>;
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
z.object({
hasApp: z.boolean(),
@@ -101,7 +104,9 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => {
const onMutationSuccess = (
data: CreatedIntegrationResult | { error?: AnyMappedTestConnectionError } | undefined | void,
) => {
if (data && "error" in data && data.error) {
setError(data.error);
showErrorNotification({
@@ -116,7 +121,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
message: t("integration.page.create.notification.success.message"),
});
onSuccess();
onSuccess(data && "integration" in data ? data : undefined);
};
const onMutationError = () => {
@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox } from "./docker-reconciliation-inbox";
const candidates = [
{ candidateKey: "home:new", state: "newRecognized" as const },
{ candidateKey: "remote:moved", state: "moved" as const },
{ candidateKey: "home:linked", state: "linked" as const },
{ candidateKey: "home:represented", state: "represented" as const },
];
describe("Docker reconciliation inbox", () => {
test("separates attention items from represented services", () => {
expect(
filterDockerReconciliationInbox(candidates, "attention", []).map(({ candidateKey }) => candidateKey),
).toEqual(["home:new", "remote:moved"]);
expect(
filterDockerReconciliationInbox(candidates, "represented", []).map(({ candidateKey }) => candidateKey),
).toEqual(["home:linked", "home:represented"]);
});
test("dismisses only the endpoint-qualified candidate key and remains idempotent", () => {
const dismissed = dismissDockerReconciliationCandidate([], "home:new");
expect(
filterDockerReconciliationInbox(candidates, "all", dismissed).map(({ candidateKey }) => candidateKey),
).not.toContain("home:new");
expect(filterDockerReconciliationInbox(candidates, "all", dismissed)).toContainEqual({
candidateKey: "remote:moved",
state: "moved",
});
expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed);
});
});
@@ -0,0 +1,25 @@
export type DockerReconciliationInboxFilter = "attention" | "represented" | "all";
interface DockerReconciliationInboxCandidate {
candidateKey: string;
state: "newRecognized" | "newApp" | "represented" | "linked" | "moved";
}
const attentionStates = new Set<DockerReconciliationInboxCandidate["state"]>(["newRecognized", "newApp", "moved"]);
export const filterDockerReconciliationInbox = <Candidate extends DockerReconciliationInboxCandidate>(
candidates: Candidate[],
filter: DockerReconciliationInboxFilter,
dismissedCandidateKeys: string[],
) => {
const dismissed = new Set(dismissedCandidateKeys);
return candidates.filter((candidate) => {
if (dismissed.has(candidate.candidateKey)) return false;
if (filter === "attention") return attentionStates.has(candidate.state);
if (filter === "represented") return candidate.state === "represented" || candidate.state === "linked";
return true;
});
};
export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) =>
dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey];
@@ -0,0 +1,277 @@
"use client";
import { useState } from "react";
import {
Alert,
Avatar,
Badge,
Button,
Card,
Group,
SegmentedControl,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconRefresh } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox";
import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox } from "./docker-reconciliation-inbox";
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
export const DockerReconciliation = () => {
const t = useScopedI18n("docker.reconciliation");
const reconciliation = clientApi.docker.reconcileServices.useQuery();
const health = clientApi.docker.getServiceHealth.useQuery();
const [filter, setFilter] = useState<DockerReconciliationInboxFilter>("attention");
const [dismissedCandidateKeys, setDismissedCandidateKeys] = useLocalStorage<string[]>({
key: "homarr-docker-reconciliation-dismissed",
defaultValue: [],
});
if (!reconciliation.data) return null;
const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable");
const candidates = filterDockerReconciliationInbox(reconciliation.data.candidates, filter, dismissedCandidateKeys);
const attentionCount = health.data
? health.data.services.filter(
({ key, status }) =>
["newRecognized", "newApp", "moved"].includes(status) && !dismissedCandidateKeys.includes(key),
).length
: 0;
const isRefreshing = reconciliation.isFetching || health.isFetching;
return (
<Card withBorder>
<Stack>
<Group justify="space-between" align="start">
<div>
<Group gap="xs">
<IconPlugConnected size={20} />
<Text fw={600}>{t("title")}</Text>
</Group>
<Text c="dimmed" size="sm">
{t("description")}
</Text>
</div>
{health.data && <Badge variant="light">{t("attention", { count: String(attentionCount) })}</Badge>}
</Group>
<Group justify="space-between">
<SegmentedControl
value={filter}
onChange={(value) => setFilter(value as DockerReconciliationInboxFilter)}
data={[
{ value: "attention", label: t("filter.attention") },
{ value: "represented", label: t("filter.represented") },
{ value: "all", label: t("filter.all") },
]}
/>
<Group gap="xs">
{dismissedCandidateKeys.length > 0 && (
<Button variant="subtle" size="xs" onClick={() => setDismissedCandidateKeys([])}>
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
</Button>
)}
<Button
variant="light"
size="xs"
loading={isRefreshing}
leftSection={<IconRefresh size={14} />}
onClick={() => void Promise.all([reconciliation.refetch(), health.refetch()])}
>
{t("action.refresh")}
</Button>
</Group>
</Group>
{unavailableEndpoints.length > 0 && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("endpointUnavailable", { names: unavailableEndpoints.map(({ name }) => name).join(", ") })}
</Alert>
)}
{candidates.length === 0 ? (
<Text c="dimmed" size="sm">
{reconciliation.data.candidates.length === 0 ? t("empty") : t("emptyFilter")}
</Text>
) : (
<SimpleGrid cols={{ base: 1, lg: 2 }}>
{candidates.map((candidate) => (
<DockerReconciliationCandidate
key={candidate.candidateKey}
candidate={candidate}
health={health.data?.services.find(({ key }) => key === candidate.candidateKey)}
onDismiss={() =>
setDismissedCandidateKeys((current) =>
dismissDockerReconciliationCandidate(current, candidate.candidateKey),
)
}
/>
))}
</SimpleGrid>
)}
</Stack>
</Card>
);
};
const DockerReconciliationCandidate = ({
candidate,
health,
onDismiss,
}: {
candidate: ReconciliationCandidate;
health: ServiceHealth | undefined;
onDismiss: () => void;
}) => {
const t = useScopedI18n("docker.reconciliation");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const initialUrlCandidate =
candidate.urlCandidates.find(
({ url, scopes }) => url.length > 0 && (candidate.match ? true : scopes.includes("browser")),
) ?? candidate.urlCandidates.find(({ source }) => source === "manual");
const [selectedCandidateId, setSelectedCandidateId] = useState(initialUrlCandidate?.id ?? null);
const [url, setUrl] = useState(initialUrlCandidate?.url ?? "");
const selectedCandidate = candidate.urlCandidates.find(({ id }) => id === selectedCandidateId);
const target = getCandidateTarget(candidate, url);
return (
<Card withBorder padding="md">
<Stack gap="sm">
<Group justify="space-between" align="start" wrap="nowrap">
<Group wrap="nowrap">
<Avatar src={candidate.container.iconUrl} radius="sm">
{candidate.container.name.at(0)?.toUpperCase()}
</Avatar>
<div>
<Text fw={600} lineClamp={1}>
{candidate.container.name}
</Text>
<Text c="dimmed" size="xs">
{candidate.endpointName}
</Text>
</div>
</Group>
<Badge color={stateColor(candidate.state)} variant="light">
{t(`state.${candidate.state}`)}
</Badge>
</Group>
{candidate.match && (
<Group gap="xs">
<Badge variant="outline">{candidate.match.kind}</Badge>
<Text c="dimmed" size="xs">
{t("match", { confidence: t(`confidence.${candidate.match.confidence}`) })}
</Text>
</Group>
)}
{candidate.representation.signals.ambiguous && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("ambiguous")}
</Alert>
)}
{health && (
<div>
<Text c="dimmed" size="xs" mb={4}>
{t("health.title")}
</Text>
<Group gap={4}>
{health.layers.map((layer) => (
<Badge key={layer.layer} color={healthStatusColor(layer.status)} variant="dot" size="sm">
{t(`health.layer.${layer.layer}`)}: {t(`health.status.${layer.status}`)}
</Badge>
))}
</Group>
</div>
)}
<Select
label={t("url.label")}
description={selectedCandidate ? t(`reason.${selectedCandidate.reason}`) : t("reason.manualHostRequired")}
value={selectedCandidateId}
data={candidate.urlCandidates.map((item) => ({
value: item.id,
label: item.url || t("url.manual"),
}))}
onChange={(value) => {
setSelectedCandidateId(value);
setUrl(candidate.urlCandidates.find(({ id }) => id === value)?.url ?? "");
}}
/>
<TextInput
value={url}
placeholder="https://service.example.com"
onChange={(event) => setUrl(event.currentTarget.value)}
/>
<Group justify="space-between">
<Button variant="subtle" color="gray" leftSection={<IconEyeOff size={16} />} onClick={onDismiss}>
{t("action.dismiss")}
</Button>
{target.kind === "createApp" ? (
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() =>
openModal({ selectedContainers: [candidate.container], initialUrls: url ? [url] : undefined })
}
>
{t("action.createApp")}
</Button>
) : (
<Button component={Link} href={target.href} variant="light" rightSection={<IconArrowRight size={16} />}>
{t(`action.${target.kind}`)}
</Button>
)}
</Group>
</Stack>
</Card>
);
};
const getCandidateTarget = (candidate: ReconciliationCandidate, url: string) => {
if (candidate.state === "newRecognized" && candidate.match) {
const params = new URLSearchParams({ kind: candidate.match.kind, name: candidate.container.name });
if (url) params.set("url", url);
return { kind: "setupIntegration" as const, href: `/manage/integrations/new?${params.toString()}` };
}
if (candidate.state === "newApp") return { kind: "createApp" as const };
if (candidate.representation.integration) {
return {
kind: candidate.state === "moved" ? ("reviewIntegration" as const) : ("viewRepresentation" as const),
href: `/manage/integrations/edit/${candidate.representation.integration.id}`,
};
}
if (candidate.representation.app) {
return { kind: "viewRepresentation" as const, href: `/manage/apps/edit/${candidate.representation.app.id}` };
}
return { kind: "createApp" as const };
};
const stateColor = (state: ReconciliationCandidate["state"]) => {
if (state === "linked" || state === "represented") return "green";
if (state === "moved") return "yellow";
return "blue";
};
const healthStatusColor = (status: ServiceHealth["layers"][number]["status"]) => {
if (["available", "configured", "linked"].includes(status)) return "green";
if (["missing", "changed", "unused"].includes(status)) return "yellow";
return "gray";
};
@@ -3,8 +3,9 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { MantineColor } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
import { ActionIcon, Alert, Avatar, Badge, Box, Button, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconCategoryPlus,
IconDots,
IconFileText,
@@ -20,10 +21,10 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import type { ContainerState, DockerEndpointCapability } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { AddDockerAppToHomarr, useDockerContainerRemovalConfirmation } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -32,9 +33,41 @@ import { OverflowBadge } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
type DockerEndpoint = RouterOutputs["docker"]["getContainers"]["endpoints"][number];
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`;
const getContainerTarget = ({ endpointId, id }: DockerContainer) => ({ endpointId, id });
const showContainerActionResult = (
action: "start" | "stop" | "restart" | "remove",
containers: DockerContainer[],
results: RouterOutputs["docker"]["startAll"],
t: TranslationFunction,
) => {
const failures = results.filter((result) => !result.success);
if (failures.length === 0) {
showSuccessNotification({
title: t(`docker.action.${action}.notification.success.title`),
message: t(`docker.action.${action}.notification.success.message`),
});
return;
}
const failedNames = failures.map(
({ target }) =>
containers.find(({ endpointId, id }) => endpointId === target.endpointId && id === target.id)?.name ?? target.id,
);
showErrorNotification({
title: t("docker.action.result.failure.title"),
message: t("docker.action.result.failure.message", {
failed: String(failures.length),
total: String(results.length),
names: failedNames.join(", "),
}),
});
};
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
{
@@ -179,9 +212,11 @@ export function DockerTable({ initialData }: DockerTableProps) {
}, [refetch, tDocker]);
const containers = data?.containers ?? [];
const unavailableEndpoints = data?.endpoints.filter(({ status }) => status === "unavailable") ?? [];
const table = useTranslatedMantineReactTable({
data: containers,
getRowId: (row) => row.resourceId,
state: { isLoading: isFetching },
enableDensityToggle: false,
enableColumnActions: false,
@@ -207,7 +242,12 @@ export function DockerTable({ initialData }: DockerTableProps) {
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => (
<ContainerRowMenu
container={row.original}
endpoint={data?.endpoints.find(({ id }) => id === row.original.endpointId)}
/>
),
renderTopToolbarCustomActions: () => (
<Button
variant="default"
@@ -229,7 +269,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
totalCount: String(table.getRowCount()),
})}
</Text>
<ContainerActionBar selectedContainers={dockerContainers} />
<ContainerActionBar selectedContainers={dockerContainers} endpoints={data?.endpoints ?? []} />
</Group>
);
},
@@ -238,10 +278,18 @@ export function DockerTable({ initialData }: DockerTableProps) {
});
return (
<>
<Stack gap="xs">
{unavailableEndpoints.length > 0 && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{tDocker("error.endpointUnavailable", {
count: String(unavailableEndpoints.length),
names: unavailableEndpoints.map(({ name }) => name).join(", "),
})}
</Alert>
)}
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
<MantineReactTable table={table} />
</>
</Stack>
);
}
@@ -252,22 +300,21 @@ const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon:
{ action: "remove", icon: IconTrash, color: "red" },
];
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer; endpoint?: DockerEndpoint }) => {
const t = useScopedI18n("docker.action");
const tAll = useI18n();
const router = useRouter();
const utils = clientApi.useUtils();
const { openModal } = useModalAction(AddDockerAppToHomarr);
const confirmRemoval = useDockerContainerRemovalConfirmation();
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
clientApi.docker[`${action}All`].useMutation({
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`${action}.notification.success.title`),
message: t(`${action}.notification.success.message`),
});
onSuccess(results) {
showContainerActionResult(action, [container], results, tAll);
},
onError() {
showErrorNotification({
@@ -294,6 +341,7 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
<Menu.Dropdown>
<Menu.Item
leftSection={<IconFileText size="1rem" />}
disabled={!endpointHasCapability(endpoint, "logs")}
onClick={() => router.push(createContainerLogsPath(container))}
>
{t("logs.label")}
@@ -304,7 +352,12 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
key={action}
leftSection={<Icon size="1rem" />}
color={color}
onClick={() => mutations[action].mutate({ ids: [container.id] })}
disabled={!endpointHasCapability(endpoint, action === "remove" ? "remove" : "lifecycle")}
onClick={() => {
const runAction = () => mutations[action].mutate({ targets: [getContainerTarget(container)] });
if (action === "remove") confirmRemoval([container], runAction);
else runAction();
}}
>
{t(`${action}.label`)}
</Menu.Item>
@@ -324,18 +377,24 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
interface ContainerActionBarProps {
selectedContainers: DockerContainer[];
endpoints: DockerEndpoint[];
}
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
const ContainerActionBar = ({ selectedContainers, endpoints }: ContainerActionBarProps) => {
const t = useScopedI18n("docker.action");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const selectedIds = selectedContainers.map((container) => container.id);
return (
<Group gap="xs">
{containerActions.map(({ action, icon, color }) => (
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
<ContainerActionBarButton
key={action}
icon={icon}
color={color}
action={action}
selectedContainers={selectedContainers}
endpoints={endpoints}
/>
))}
<Button
leftSection={<IconCategoryPlus />}
@@ -353,12 +412,22 @@ interface ContainerActionBarButtonProps {
icon: TablerIcon;
color: MantineColor;
action: "start" | "stop" | "restart" | "remove";
selectedIds: string[];
selectedContainers: DockerContainer[];
endpoints: DockerEndpoint[];
}
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
const t = useScopedI18n("docker.action");
const tAll = useI18n();
const utils = clientApi.useUtils();
const confirmRemoval = useDockerContainerRemovalConfirmation();
const requiredCapability = props.action === "remove" ? "remove" : "lifecycle";
const isAllowed = props.selectedContainers.every((container) =>
endpointHasCapability(
props.endpoints.find(({ id }) => id === container.endpointId),
requiredCapability,
),
);
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation({
async onSettled() {
@@ -367,23 +436,28 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
});
const handleClickAsync = async () => {
await mutateAsync(
{ ids: props.selectedIds },
{
onSuccess() {
showSuccessNotification({
title: t(`${props.action}.notification.success.title`),
message: t(`${props.action}.notification.success.message`),
});
const runActionAsync = async () => {
await mutateAsync(
{ targets: props.selectedContainers.map(getContainerTarget) },
{
onSuccess(results) {
showContainerActionResult(props.action, props.selectedContainers, results, tAll);
},
onError() {
showErrorNotification({
title: t(`${props.action}.notification.error.title`),
message: t(`${props.action}.notification.error.message`),
});
},
},
onError() {
showErrorNotification({
title: t(`${props.action}.notification.error.title`),
message: t(`${props.action}.notification.error.message`),
});
},
},
);
);
};
if (props.action === "remove") {
confirmRemoval(props.selectedContainers, runActionAsync);
return;
}
await runActionAsync();
};
return (
@@ -392,6 +466,7 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
color={props.color}
onClick={handleClickAsync}
loading={isPending}
disabled={!isAllowed}
variant="light"
>
{t(`${props.action}.label`)}
@@ -399,6 +474,9 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
);
};
const endpointHasCapability = (endpoint: DockerEndpoint | undefined, capability: DockerEndpointCapability) =>
endpoint && "capabilities" in endpoint ? endpoint.capabilities.includes(capability) : true;
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
@@ -13,7 +13,7 @@ import { ClientSideDockerLogsTerminal } from "./client";
interface DockerContainerLogsPageProps {
params: Promise<{ containerId: string }>;
searchParams: Promise<{ name?: string }>;
searchParams: Promise<{ endpointId?: string; name?: string }>;
}
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
@@ -23,10 +23,11 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
}
const { containerId } = await params;
const { name } = await searchParams;
const { endpointId, name } = await searchParams;
if (!endpointId) notFound();
try {
await api.docker.logs({ id: containerId, tail: 1 });
await api.docker.logs({ endpointId, id: containerId, tail: 1 });
} catch {
notFound();
}
@@ -40,7 +41,7 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
/>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideDockerLogsTerminal containerId={containerId} />
<ClientSideDockerLogsTerminal endpointId={endpointId} containerId={containerId} />
</Box>
</>
);
@@ -11,10 +11,11 @@ import { clientApi } from "@homarr/api/client";
import classes from "./terminal.module.css";
interface DockerLogsTerminalProps {
endpointId: string;
containerId: string;
}
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
export const DockerLogsTerminal = ({ endpointId, containerId }: DockerLogsTerminalProps) => {
const ref = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const pendingLogsRef = useRef<string[]>([]);
@@ -31,7 +32,7 @@ export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) =>
}, []);
clientApi.docker.subscribeLogs.useSubscription(
{ id: containerId, tail: 200 },
{ endpointId, id: containerId, tail: 200 },
{
onData: writeToTerminal,
onError(err) {
@@ -7,6 +7,7 @@ import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { DockerReconciliation } from "./docker-reconciliation";
import { DockerTable } from "./docker-table";
export default async function DockerPage() {
@@ -15,7 +16,7 @@ export default async function DockerPage() {
notFound();
}
const { containers, timestamp } = await api.docker.getContainers();
const { containers, endpoints, timestamp } = await api.docker.getContainers();
const tDocker = await getScopedI18n("docker");
return (
@@ -23,7 +24,8 @@ export default async function DockerPage() {
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerTable initialData={{ containers, timestamp }} />
<DockerReconciliation />
<DockerTable initialData={{ containers, endpoints, timestamp }} />
</Stack>
</>
);
@@ -0,0 +1,72 @@
export type CatalogFixtureId = "ready" | "needsSetup" | "noConnection" | "error";
export type CatalogInteractionId = "default" | "selected" | "loading" | "disabled";
export type ResponseFixtureId = "loading" | "success" | "failure";
export interface CatalogStateFixture {
id: CatalogFixtureId;
label: string;
status: string;
description: string;
color: "green" | "yellow" | "gray" | "red";
}
export const catalogStateFixtures: readonly CatalogStateFixture[] = [
{
id: "ready",
label: "Ready service",
status: "Ready",
description: "Configured and available to three widgets.",
color: "green",
},
{
id: "needsSetup",
label: "Service needing setup",
status: "Needs setup",
description: "Credentials or a service URL are still required.",
color: "yellow",
},
{
id: "noConnection",
label: "Standalone feature",
status: "No connection required",
description: "This feature works without a configured integration.",
color: "gray",
},
{
id: "error",
label: "Unreachable service",
status: "Connection failed",
description: "The saved connection could not be reached.",
color: "red",
},
];
export const catalogInteractionFixtures: readonly { id: CatalogInteractionId; label: string }[] = [
{ id: "default", label: "Default" },
{ id: "selected", label: "Selected" },
{ id: "loading", label: "Loading" },
{ id: "disabled", label: "Disabled" },
];
export const responseStateFixtures = [
{
id: "loading",
label: "Loading",
title: "Checking response",
description: "The parser is waiting for the integration response.",
},
{
id: "success",
label: "Success",
title: "Response accepted",
description: "The upstream payload matched the integration contract.",
payload: { status: "ok", service: "fixture-service", version: "2.0" },
},
{
id: "failure",
label: "Failure",
title: "Response rejected",
description: "The upstream payload failed schema validation and stays isolated from the widget.",
payload: { status: 503, message: "Service unavailable" },
},
] as const;
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { FeatureStateWorkbench } from "./_feature-state-workbench";
import { catalogStateFixtures, responseStateFixtures } from "./_feature-state-fixtures";
import { getResponseContractFixtureResultsAsync } from "./_response-contract-fixtures";
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
const clickControl = async (label: string) => {
const control = [...host.querySelectorAll<HTMLElement>("label")].find((element) =>
element.textContent?.includes(label),
);
await act(async () => control?.click());
};
describe("FeatureStateWorkbench", () => {
test("keeps stable catalog and response fixtures covered by the parser contract", async () => {
expect(catalogStateFixtures.map((fixture) => fixture.id)).toEqual(["ready", "needsSetup", "noConnection", "error"]);
expect(responseStateFixtures.map((fixture) => fixture.id)).toEqual(["loading", "success", "failure"]);
expect(await getResponseContractFixtureResultsAsync()).toEqual([
{ name: "success", passed: true, message: undefined },
{ name: "failure", passed: true, message: undefined },
]);
});
test("renders setup, busy, disabled, and rejected states with native semantics", async () => {
const responseResults = await getResponseContractFixtureResultsAsync();
await act(async () =>
root.render(
<MantineProvider>
<FeatureStateWorkbench responseResults={responseResults} />
</MantineProvider>,
),
);
await clickControl("Needs setup");
let catalogButton = host.querySelector<HTMLButtonElement>('button[aria-label^="Service needing setup"]');
expect(catalogButton?.getAttribute("aria-label")).toBe("Service needing setup, Needs setup");
await clickControl("Loading");
catalogButton = host.querySelector<HTMLButtonElement>('button[aria-label^="Service needing setup"]');
expect(catalogButton?.disabled).toBe(true);
expect(catalogButton?.getAttribute("aria-busy")).toBe("true");
expect(host.querySelector('[aria-label="Loading catalog item"]')).not.toBeNull();
await clickControl("Disabled");
expect(host.querySelector<HTMLButtonElement>('button[aria-label^="Service needing setup"]')?.disabled).toBe(true);
await clickControl("Failure");
expect(host.querySelector('[role="alert"]')?.textContent).toContain("Response rejected");
expect(host.querySelector('[role="alert"]')?.textContent).toContain("Fixture contract passed");
});
});
@@ -0,0 +1,186 @@
"use client";
import { useState } from "react";
import {
Alert,
Badge,
Code,
Group,
Paper,
SegmentedControl,
SimpleGrid,
Skeleton,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconAlertCircle, IconCircleCheck, IconPlugConnected, IconPlugConnectedX } from "@tabler/icons-react";
import { CatalogItem } from "@homarr/ui";
import { catalogInteractionFixtures, catalogStateFixtures, responseStateFixtures } from "./_feature-state-fixtures";
import type { CatalogFixtureId, CatalogInteractionId, ResponseFixtureId } from "./_feature-state-fixtures";
import type { ResponseContractFixtureResult } from "./_response-contract-fixtures";
interface FeatureStateWorkbenchProps {
responseResults: readonly ResponseContractFixtureResult[];
}
export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbenchProps) {
const [catalogFixtureId, setCatalogFixtureId] = useState<CatalogFixtureId>("ready");
const [interactionId, setInteractionId] = useState<CatalogInteractionId>("default");
const [responseFixtureId, setResponseFixtureId] = useState<ResponseFixtureId>("loading");
const catalogFixture = catalogStateFixtures.find((fixture) => fixture.id === catalogFixtureId);
const responseFixture = responseStateFixtures.find((fixture) => fixture.id === responseFixtureId);
if (!catalogFixture || !responseFixture) return null;
const responseResult = responseResults.find((result) => result.name === responseFixtureId);
const busy = interactionId === "loading";
const disabled = interactionId === "disabled";
const selected = interactionId === "selected";
return (
<Stack gap="xl" maw={960}>
<Paper component="section" withBorder p="lg" aria-labelledby="catalog-workbench-title">
<Stack gap="lg">
<div>
<Title id="catalog-workbench-title" order={2} size="h3">
Catalog item states
</Title>
<Text c="dimmed" size="sm" maw={680}>
Compare the shared selection surface with stable service and interaction fixtures.
</Text>
</div>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Stack gap="xs">
<Text size="sm" fw={600} id="catalog-fixture-label">
Service state
</Text>
<SegmentedControl
aria-labelledby="catalog-fixture-label"
value={catalogFixtureId}
onChange={(value) => setCatalogFixtureId(value as CatalogFixtureId)}
data={catalogStateFixtures.map((fixture) => ({ value: fixture.id, label: fixture.status }))}
/>
</Stack>
<Stack gap="xs">
<Text size="sm" fw={600} id="catalog-interaction-label">
Interaction state
</Text>
<SegmentedControl
aria-labelledby="catalog-interaction-label"
value={interactionId}
onChange={(value) => setInteractionId(value as CatalogInteractionId)}
data={catalogInteractionFixtures.map((fixture) => ({ value: fixture.id, label: fixture.label }))}
/>
</Stack>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<CatalogItem
height={140}
label={catalogFixture.label}
status={busy ? "Loading" : catalogFixture.status}
selected={selected}
busy={busy}
disabled={disabled}
onSelect={() => setInteractionId(selected ? "default" : "selected")}
>
{busy ? (
<Stack gap="sm" aria-label="Loading catalog item">
<Skeleton height={24} width="65%" />
<Skeleton height={16} />
<Skeleton height={20} width="40%" />
</Stack>
) : (
<Stack justify="space-between" h="100%" gap="sm">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color={catalogFixture.color} aria-hidden>
{catalogFixture.id === "noConnection" ? (
<IconPlugConnectedX size={18} />
) : (
<IconPlugConnected size={18} />
)}
</ThemeIcon>
<Stack gap={2}>
<Text fw={600} size="sm">
{catalogFixture.label}
</Text>
<Text size="xs" c="dimmed">
{catalogFixture.description}
</Text>
</Stack>
</Group>
<Badge color={catalogFixture.color} variant="light" size="sm" w="fit-content">
{catalogFixture.status}
</Badge>
</Stack>
)}
</CatalogItem>
<Stack gap="xs" justify="center">
<Text size="sm" fw={600}>
Semantic output
</Text>
<Code
block
>{`aria-label="${catalogFixture.label}, ${busy ? "Loading" : catalogFixture.status}"\naria-pressed=${selected}\ndisabled=${busy || disabled}\naria-busy=${busy}`}</Code>
<Text size="xs" c="dimmed">
Tab to the item to inspect its visible focus ring. Select it with Enter or Space.
</Text>
</Stack>
</SimpleGrid>
</Stack>
</Paper>
<Paper component="section" withBorder p="lg" aria-labelledby="response-workbench-title">
<Stack gap="lg">
<div>
<Title id="response-workbench-title" order={2} size="h3">
Integration response contract
</Title>
<Text c="dimmed" size="sm" maw={680}>
Inspect waiting, accepted, and rejected response fixtures using the same parser contract as generated
integrations.
</Text>
</div>
<SegmentedControl
aria-label="Response fixture"
value={responseFixtureId}
onChange={(value) => setResponseFixtureId(value as ResponseFixtureId)}
data={responseStateFixtures.map((fixture) => ({ value: fixture.id, label: fixture.label }))}
/>
{responseFixture.id === "loading" ? (
<Stack gap="sm" role="status" aria-label="Checking response">
<Group justify="space-between">
<Skeleton height={22} width={180} />
<Skeleton height={22} width={72} />
</Group>
<Skeleton height={72} />
</Stack>
) : (
<Alert
color={responseFixture.id === "success" ? "green" : "red"}
icon={responseFixture.id === "success" ? <IconCircleCheck /> : <IconAlertCircle />}
title={responseFixture.title}
role={responseFixture.id === "failure" ? "alert" : "status"}
>
<Stack gap="sm">
<Text size="sm">{responseFixture.description}</Text>
<Code block>{JSON.stringify(responseFixture.payload, null, 2)}</Code>
<Badge color={responseResult?.passed ? "green" : "red"} variant="light" w="fit-content">
{responseResult?.passed
? "Fixture contract passed"
: (responseResult?.message ?? "Fixture contract failed")}
</Badge>
</Stack>
</Alert>
)}
</Stack>
</Paper>
</Stack>
);
}
@@ -0,0 +1,30 @@
import { z } from "zod/v4";
import { simulateResponseContractAsync } from "@homarr/integrations/response-contract";
import { responseStateFixtures } from "./_feature-state-fixtures";
const fixtureResponseSchema = z.object({
status: z.literal("ok"),
service: z.string(),
version: z.string(),
});
export const parseWorkbenchResponseAsync = async (response: { json: () => Promise<unknown> }) =>
fixtureResponseSchema.parse(await response.json());
export const getResponseContractFixtureResultsAsync = () =>
simulateResponseContractAsync(parseWorkbenchResponseAsync, [
{
name: "success",
payload: responseStateFixtures.find((fixture) => fixture.id === "success")?.payload,
expected: { status: "ok", service: "fixture-service", version: "2.0" },
},
{
name: "failure",
payload: responseStateFixtures.find((fixture) => fixture.id === "failure")?.payload,
rejects: true,
},
]);
export type ResponseContractFixtureResult = Awaited<ReturnType<typeof getResponseContractFixtureResultsAsync>>[number];
@@ -0,0 +1,27 @@
import { notFound } from "next/navigation";
import { Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { FeatureStateWorkbench } from "./_feature-state-workbench";
import { getResponseContractFixtureResultsAsync } from "./_response-contract-fixtures";
export default async function FeatureWorkbenchPage() {
if (process.env.NODE_ENV === "production") notFound();
const session = await auth();
if (!session?.user.permissions.includes("admin")) notFound();
const responseResults = await getResponseContractFixtureResultsAsync();
return (
<Stack gap="xl">
<div>
<Title>Native feature state workbench</Title>
<Text c="dimmed" maw={720}>
A development-only fixture surface for reviewing shared catalog and integration response states without live
services.
</Text>
</div>
<FeatureStateWorkbench responseResults={responseResults} />
</Stack>
);
}
@@ -1,6 +1,7 @@
"use client";
import { SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
import { Alert, SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
@@ -11,21 +12,26 @@ import KubernetesErrorPage from "~/app/[locale]/manage/tools/kubernetes/cluster-
import { HeaderCard } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card";
import { ResourceGauge } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge";
import { ResourceTile } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile";
import { useSelectedKubernetesContextId } from "../kubernetes-context-selector";
export function ClusterDashboard() {
const t = useI18n();
const contextId = useSelectedKubernetesContextId();
const {
data: clusterData,
isLoading: isClusterLoading,
isError: isClusterError,
} = clientApi.kubernetes.cluster.getCluster.useQuery();
} = clientApi.kubernetes.cluster.getCluster.useQuery({ contextId: contextId ?? "" }, { enabled: Boolean(contextId) });
const {
data: resourceCountsData,
isLoading: isResourceCountsLoading,
isError: isResourceCountsError,
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery();
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery(
{ contextId: contextId ?? "" },
{ enabled: Boolean(contextId) },
);
return (
<Stack bg="var(--mantine-color-body)">
@@ -46,6 +52,12 @@ export function ClusterDashboard() {
<Title>{t("kubernetes.cluster.capacity.title")}</Title>
{clusterData?.metricsAvailable === false && (
<Alert color="blue" variant="light" icon={<IconInfoCircle size={18} />}>
{t("kubernetes.cluster.capacity.metricsUnavailable")}
</Alert>
)}
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{isClusterError
? Array.from({ length: 3 }).map(() => <KubernetesErrorPage key={createId()} />)
@@ -63,9 +75,11 @@ export function ClusterDashboard() {
? Array.from({ length: 8 }).map(() => <KubernetesErrorPage key={createId()} />)
: isResourceCountsLoading
? Array.from({ length: 8 }).map(() => <Skeleton key={createId()} height={100} />)
: resourceCountsData?.map((clusterResourceCount) => (
: contextId &&
resourceCountsData?.map((clusterResourceCount) => (
<ResourceTile
count={clusterResourceCount.count}
contextId={contextId}
label={clusterResourceCount.label as KubernetesLabelResourceType}
key={clusterResourceCount.label}
/>
@@ -11,13 +11,18 @@ import classes from "./resource-tile.module.css";
interface ResourceTileProps {
count: number;
contextId: string;
label: KubernetesLabelResourceType;
}
export function ResourceTile(props: ResourceTileProps) {
const t = useI18n();
return (
<Card component={Link} href={`/manage/tools/kubernetes/${props.label}`} className={classes.cardContainer}>
<Card
component={Link}
href={`/manage/tools/kubernetes/${props.label}?context=${encodeURIComponent(props.contextId)}`}
className={classes.cardContainer}
>
<Group justify="space-between" wrap="nowrap">
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
<Group gap="xs">
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface ConfigMapsTableComponentProps {
contextId: string;
initialConfigMaps: RouterOutputs["kubernetes"]["configMaps"]["getConfigMaps"];
}
@@ -39,15 +40,18 @@ const createColumns = (
},
];
export function ConfigmapsTable(initialData: ConfigMapsTableComponentProps) {
export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTableComponentProps) {
const tConfigMaps = useScopedI18n("kubernetes.configmaps");
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(undefined, {
initialData: initialData.initialConfigMaps,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
{ contextId },
{
initialData: initialConfigMaps,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -8,14 +8,24 @@ import { getScopedI18n } from "@homarr/translation/server";
import { ConfigmapsTable } from "~/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function ConfigMapsPage() {
export default async function ConfigMapsPage({
searchParams,
}: {
searchParams: Promise<KubernetesContextSearchParams>;
}) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const configMaps = await api.kubernetes.configMaps.getConfigMaps();
const context = await getSelectedKubernetesContextAsync(searchParams);
const configMaps =
context.status === "unavailable"
? []
: await api.kubernetes.configMaps.getConfigMaps({ contextId: context.contextId });
const tConfigMaps = await getScopedI18n("kubernetes.configmaps");
return (
@@ -23,7 +33,7 @@ export default async function ConfigMapsPage() {
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tConfigMaps("label")}</Title>
<ConfigmapsTable initialConfigMaps={configMaps} />
<ConfigmapsTable contextId={context.contextId} initialConfigMaps={configMaps} />
</Stack>
</>
);
@@ -19,6 +19,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface IngressesTableComponentProps {
contextId: string;
initialIngresses: RouterOutputs["kubernetes"]["ingresses"]["getIngresses"];
}
@@ -72,15 +73,18 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.ingresses">): MR
},
];
export function IngressesTable(initialData: IngressesTableComponentProps) {
export function IngressesTable({ contextId, initialIngresses }: IngressesTableComponentProps) {
const tIngresses = useScopedI18n("kubernetes.ingresses");
const { data } = clientApi.kubernetes.ingresses.getIngresses.useQuery(undefined, {
initialData: initialData.initialIngresses,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
{ contextId },
{
initialData: initialIngresses,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -8,21 +8,31 @@ import { getScopedI18n } from "@homarr/translation/server";
import { IngressesTable } from "~/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function NamespacesPage() {
export default async function NamespacesPage({
searchParams,
}: {
searchParams: Promise<KubernetesContextSearchParams>;
}) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const ingresses = await api.kubernetes.ingresses.getIngresses();
const context = await getSelectedKubernetesContextAsync(searchParams);
const ingresses =
context.status === "unavailable"
? []
: await api.kubernetes.ingresses.getIngresses({ contextId: context.contextId });
const tIngresses = await getScopedI18n("kubernetes.ingresses");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tIngresses("label")}</Title>
<IngressesTable initialIngresses={ingresses} />
<IngressesTable contextId={context.contextId} initialIngresses={ingresses} />
</Stack>
</>
);
@@ -0,0 +1,93 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Alert, Badge, Group, Select } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
export const KubernetesContextSelector = () => {
const t = useScopedI18n("kubernetes.context");
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const { data } = clientApi.kubernetes.contexts.getContexts.useQuery();
const [storedContextId, setStoredContextId] = useLocalStorage<string | null>({
key: "homarr-kubernetes-context",
defaultValue: null,
});
const requestedContextId = searchParams.get("context");
const contextId =
data?.contexts.find((context) => context.contextId === requestedContextId)?.contextId ??
data?.contexts.find((context) => context.contextId === storedContextId)?.contextId ??
data?.contexts.find((context) => context.contextId === data.defaultContextId)?.contextId ??
data?.contexts[0]?.contextId ??
null;
const context = data?.contexts.find((item) => item.contextId === contextId);
useEffect(() => {
if (!contextId || requestedContextId === contextId) return;
const next = new URLSearchParams(searchParams);
next.set("context", contextId);
router.replace(`${pathname}?${next.toString()}`);
}, [contextId, pathname, requestedContextId, router, searchParams]);
if (!data || !contextId) return null;
const selectContext = (value: string | null) => {
if (!value) return;
setStoredContextId(value);
const next = new URLSearchParams(searchParams);
next.set("context", value);
router.replace(`${pathname}?${next.toString()}`);
};
return (
<>
<Group justify="end">
<Select
label={t("label")}
value={contextId}
data={data.contexts.map((item) => ({ value: item.contextId, label: item.name }))}
onChange={selectContext}
allowDeselect={false}
/>
{context && (
<Badge color={contextStatusColor(context.status)} variant="light">
{t(`status.${context.status}`)}
</Badge>
)}
</Group>
{context?.status === "unavailable" && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("unavailable")}
</Alert>
)}
{context?.status === "degraded" && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("metricsUnavailable")}
</Alert>
)}
</>
);
};
export const useSelectedKubernetesContextId = () => {
const searchParams = useSearchParams();
const { data } = clientApi.kubernetes.contexts.getContexts.useQuery();
const requested = searchParams.get("context");
if (data?.contexts.some(({ contextId }) => contextId === requested)) return requested;
return (
data?.contexts.find(({ contextId }) => contextId === data.defaultContextId)?.contextId ??
data?.contexts[0]?.contextId
);
};
const contextStatusColor = (status: "available" | "degraded" | "unavailable") => {
if (status === "available") return "green";
if (status === "degraded") return "yellow";
return "red";
};
@@ -0,0 +1,16 @@
import { api } from "@homarr/api/server";
export interface KubernetesContextSearchParams {
context?: string | string[];
}
export const getSelectedKubernetesContextAsync = async (searchParams: Promise<KubernetesContextSearchParams>) => {
const [{ context }, result] = await Promise.all([searchParams, api.kubernetes.contexts.getContexts()]);
const requestedContextId = Array.isArray(context) ? context[0] : context;
const selected =
result.contexts.find(({ contextId }) => contextId === requestedContextId) ??
result.contexts.find(({ contextId }) => contextId === result.defaultContextId) ??
result.contexts[0];
if (!selected) throw new Error("No Kubernetes context is configured");
return selected;
};
@@ -0,0 +1,15 @@
import { Suspense } from "react";
import { Stack } from "@mantine/core";
import { KubernetesContextSelector } from "./kubernetes-context-selector";
export default function KubernetesLayout({ children }: { children: React.ReactNode }) {
return (
<Stack>
<Suspense fallback={null}>
<KubernetesContextSelector />
</Suspense>
{children}
</Stack>
);
}
@@ -18,6 +18,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface NamespacesTableComponentProps {
contextId: string;
initialNamespaces: RouterOutputs["kubernetes"]["namespaces"]["getNamespaces"];
}
@@ -56,15 +57,18 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.namespaces">): M
},
];
export function NamespacesTable(initialData: NamespacesTableComponentProps) {
export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTableComponentProps) {
const tNamespaces = useScopedI18n("kubernetes.namespaces");
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(undefined, {
initialData: initialData.initialNamespaces,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
{ contextId },
{
initialData: initialNamespaces,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -8,21 +8,31 @@ import { getScopedI18n } from "@homarr/translation/server";
import { NamespacesTable } from "~/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function NamespacesPage() {
export default async function NamespacesPage({
searchParams,
}: {
searchParams: Promise<KubernetesContextSearchParams>;
}) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const namespaces = await api.kubernetes.namespaces.getNamespaces();
const context = await getSelectedKubernetesContextAsync(searchParams);
const namespaces =
context.status === "unavailable"
? []
: await api.kubernetes.namespaces.getNamespaces({ contextId: context.contextId });
const tNamespaces = await getScopedI18n("kubernetes.namespaces");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tNamespaces("label")}</Title>
<NamespacesTable initialNamespaces={namespaces} />
<NamespacesTable contextId={context.contextId} initialNamespaces={namespaces} />
</Stack>
</>
);
@@ -18,6 +18,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface NodesListComponentProps {
contextId: string;
initialNodes: RouterOutputs["kubernetes"]["nodes"]["getNodes"];
}
@@ -49,14 +50,14 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co
accessorKey: "allocatableCpuPercentage",
header: t("field.cpu.label"),
Cell({ cell }) {
return getRingProgress(cell.row.original.allocatableCpuPercentage);
return getRingProgress(cell.row.original.allocatableCpuPercentage, t("field.metricsUnavailable"));
},
},
{
accessorKey: "allocatableRamPercentage",
header: t("field.memory.label"),
Cell({ cell }) {
return getRingProgress(cell.row.original.allocatableRamPercentage);
return getRingProgress(cell.row.original.allocatableRamPercentage, t("field.metricsUnavailable"));
},
},
{
@@ -82,15 +83,18 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co
},
];
export function NodesTable(initialData: NodesListComponentProps) {
export function NodesTable({ contextId, initialNodes }: NodesListComponentProps) {
const tNodes = useScopedI18n("kubernetes.nodes");
const { data } = clientApi.kubernetes.nodes.getNodes.useQuery(undefined, {
initialData: initialData.initialNodes,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.nodes.getNodes.useQuery(
{ contextId },
{
initialData: initialNodes,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -115,7 +119,11 @@ export function NodesTable(initialData: NodesListComponentProps) {
return <MantineReactTable table={table} />;
}
function getRingProgress(value: number) {
function getRingProgress(value: number | null, unavailableLabel: string) {
if (value === null) {
return <Text c="dimmed">{unavailableLabel}</Text>;
}
return (
<RingProgress
size={70}
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { NodesTable } from "~/app/[locale]/manage/tools/kubernetes/nodes/nodes-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function NodesPage() {
export default async function NodesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const nodes = await api.kubernetes.nodes.getNodes();
const context = await getSelectedKubernetesContextAsync(searchParams);
const nodes =
context.status === "unavailable" ? [] : await api.kubernetes.nodes.getNodes({ contextId: context.contextId });
const tNodes = await getScopedI18n("kubernetes.nodes");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tNodes("label")}</Title>
<NodesTable initialNodes={nodes} />
<NodesTable contextId={context.contextId} initialNodes={nodes} />
</Stack>
</>
);
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { PodsTable } from "~/app/[locale]/manage/tools/kubernetes/pods/pods-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function PodsPage() {
export default async function PodsPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const pods = await api.kubernetes.pods.getPods();
const context = await getSelectedKubernetesContextAsync(searchParams);
const pods =
context.status === "unavailable" ? [] : await api.kubernetes.pods.getPods({ contextId: context.contextId });
const tPods = await getScopedI18n("kubernetes.pods");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tPods("label")}</Title>
<PodsTable initialPods={pods} />
<PodsTable contextId={context.contextId} initialPods={pods} />
</Stack>
</>
);
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface PodsTableComponentProps {
contextId: string;
initialPods: RouterOutputs["kubernetes"]["pods"]["getPods"];
}
@@ -47,15 +48,18 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.pods">): MRT_Col
},
];
export function PodsTable(initialData: PodsTableComponentProps) {
export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
const tPods = useScopedI18n("kubernetes.pods");
const { data } = clientApi.kubernetes.pods.getPods.useQuery(undefined, {
initialData: initialData.initialPods,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
{ contextId },
{
initialData: initialPods,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { SecretsTable } from "~/app/[locale]/manage/tools/kubernetes/secrets/secrets-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function SecretsPage() {
export default async function SecretsPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const secrets = await api.kubernetes.secrets.getSecrets();
const context = await getSelectedKubernetesContextAsync(searchParams);
const secrets =
context.status === "unavailable" ? [] : await api.kubernetes.secrets.getSecrets({ contextId: context.contextId });
const tSecrets = await getScopedI18n("kubernetes.secrets");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tSecrets("label")}</Title>
<SecretsTable initialSecrets={secrets} />
<SecretsTable contextId={context.contextId} initialSecrets={secrets} />
</Stack>
</>
);
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface SecretsTableComponentProps {
contextId: string;
initialSecrets: RouterOutputs["kubernetes"]["secrets"]["getSecrets"];
}
@@ -42,15 +43,18 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.secrets">): MRT_
},
];
export function SecretsTable(initialData: SecretsTableComponentProps) {
export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponentProps) {
const tSecrets = useScopedI18n("kubernetes.secrets");
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(undefined, {
initialData: initialData.initialSecrets,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
{ contextId },
{
initialData: initialSecrets,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { ServicesTable } from "~/app/[locale]/manage/tools/kubernetes/services/services-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function ServicesPage() {
export default async function ServicesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const services = await api.kubernetes.services.getServices();
const context = await getSelectedKubernetesContextAsync(searchParams);
const services =
context.status === "unavailable" ? [] : await api.kubernetes.services.getServices({ contextId: context.contextId });
const tServices = await getScopedI18n("kubernetes.services");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tServices("label")}</Title>
<ServicesTable initialServices={services} />
<ServicesTable contextId={context.contextId} initialServices={services} />
</Stack>
</>
);
@@ -17,6 +17,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface ServicesTableComponentProps {
contextId: string;
initialServices: RouterOutputs["kubernetes"]["services"]["getServices"];
}
@@ -61,15 +62,18 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.services">): MRT
},
];
export function ServicesTable(initialData: ServicesTableComponentProps) {
export function ServicesTable({ contextId, initialServices }: ServicesTableComponentProps) {
const tServices = useScopedI18n("kubernetes.services");
const { data } = clientApi.kubernetes.services.getServices.useQuery(undefined, {
initialData: initialData.initialServices,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.services.getServices.useQuery(
{ contextId },
{
initialData: initialServices,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { VolumesTable } from "~/app/[locale]/manage/tools/kubernetes/volumes/volumes-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function VolumesPage() {
export default async function VolumesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const volumes = await api.kubernetes.volumes.getVolumes();
const context = await getSelectedKubernetesContextAsync(searchParams);
const volumes =
context.status === "unavailable" ? [] : await api.kubernetes.volumes.getVolumes({ contextId: context.contextId });
const tVolumes = await getScopedI18n("kubernetes.volumes");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tVolumes("label")}</Title>
<VolumesTable initialVolumes={volumes} />
<VolumesTable contextId={context.contextId} initialVolumes={volumes} />
</Stack>
</>
);
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface VolumesTableComponentProps {
contextId: string;
initialVolumes: RouterOutputs["kubernetes"]["volumes"]["getVolumes"];
}
@@ -66,15 +67,18 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.volumes">): MRT_
},
];
export function VolumesTable(initialData: VolumesTableComponentProps) {
export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponentProps) {
const tVolumes = useScopedI18n("kubernetes.volumes");
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(undefined, {
initialData: initialData.initialVolumes,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
{ contextId },
{
initialData: initialVolumes,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -6,6 +6,7 @@ import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@ma
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import type { IntegrationKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
@@ -20,6 +21,7 @@ import { useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-tool-contracts";
import { IntegrationSelectModal } from "../integration/integration-select-modal";
interface AssistantIntegration extends IntegrationSelectOption {
permissions: { hasUseAccess: boolean };
@@ -66,6 +68,7 @@ export const AssistantConfigureWidgetTool = ({
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
const t = useScopedI18n("common.assistant.configureWidget");
const fullT = useI18n();
const { data: session } = useSession();
const settings = useSettings();
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
@@ -80,10 +83,11 @@ export const AssistantConfigureWidgetTool = ({
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
const missingRequiredIntegration =
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const autoConfirming = useAssistantAutomaticAction({
toolCallId,
ready: result === undefined && configuration !== null,
ready: result === undefined && configuration !== null && !missingRequiredIntegration,
completed: result !== undefined,
confirm: () => {
if (!args || !configuration) return;
@@ -154,6 +158,26 @@ export const AssistantConfigureWidgetTool = ({
if (!configuration) return null;
const openConfigurationModal = (
value = configuration.value,
integrationData: IntegrationSelectOption[] = configuration.integrationData,
) =>
openWidgetModal(
{
kind: args.kind,
definition: widgetImports[args.kind].definition,
value,
integrationData,
integrationSupport: configuration.integrationSupport,
settings,
onSuccessfulEdit: ({ options, integrationIds }) =>
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
},
);
if (autoConfirming) {
return (
<Box className={classes.appTool}>
@@ -168,9 +192,32 @@ export const AssistantConfigureWidgetTool = ({
<Stack gap="sm">
<Text size="sm">{t("noIntegrationDescription")}</Text>
<Group gap="xs">
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
{t("manageIntegrations")}
</Button>
{session?.user.permissions.includes("integration-create") ? (
<Button
size="compact-sm"
variant="light"
onClick={() =>
openIntegrationModal({
allowedKinds:
definition && "supportedIntegrations" in definition
? (definition.supportedIntegrations as readonly IntegrationKind[])
: [],
onSuccess: (created) => {
if (!created) return;
openConfigurationModal({ ...configuration.value, integrationIds: [created.integration.id] }, [
created.integration,
]);
},
})
}
>
{t("connectIntegration")}
</Button>
) : (
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
{t("manageIntegrations")}
</Button>
)}
<Button
size="compact-sm"
variant="default"
@@ -205,28 +252,7 @@ export const AssistantConfigureWidgetTool = ({
</Text>
</Box>
</Group>
<Button
mt="md"
fullWidth
leftSection={<IconSettings size={17} />}
onClick={() =>
openModal(
{
kind: args.kind,
definition: widgetImports[args.kind].definition,
value: configuration.value,
integrationData: configuration.integrationData,
integrationSupport: configuration.integrationSupport,
settings,
onSuccessfulEdit: ({ options, integrationIds }) =>
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
},
)
}
>
<Button mt="md" fullWidth leftSection={<IconSettings size={17} />} onClick={() => openConfigurationModal()}>
{t("review")}
</Button>
</Box>
@@ -0,0 +1,16 @@
.root {
position: absolute;
z-index: 3;
top: calc(var(--mantine-spacing-xl) * var(--board-canvas-inverse-scale, 1));
right: 0;
left: 0;
display: flex;
justify-content: center;
padding-inline: var(--mantine-spacing-md);
pointer-events: none;
}
.panel {
max-width: 42rem;
pointer-events: auto;
}
@@ -0,0 +1,54 @@
"use client";
import { Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { IconBox, IconLayoutDashboard, IconPlug, IconResize } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardAddActions } from "./use-board-add-actions";
import classes from "./board-empty-state.module.css";
export const BoardEmptyState = () => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const { addWidget, addApp, connectService, canConnectService } = useBoardAddActions();
const t = useScopedI18n("board.emptyState");
const hasBoardContent = board.items.length > 0 || board.sections.some((section) => section.kind !== "empty");
if (!isEditMode || hasBoardContent) return null;
return (
<div className={classes.root}>
<Paper className={classes.panel} withBorder radius="md" p="lg">
<Stack align="center" gap="sm">
<ThemeIcon variant="light" size="xl" radius="md">
<IconLayoutDashboard size={24} />
</ThemeIcon>
<Stack align="center" gap={4}>
<Title order={3} size="h4" ta="center">
{t("title")}
</Title>
<Text c="dimmed" size="sm" ta="center" maw="60ch">
{t("description")}
</Text>
</Stack>
<Group justify="center">
<Button leftSection={<IconResize size={18} />} onClick={addWidget}>
{t("addWidget")}
</Button>
<Button variant="default" leftSection={<IconBox size={18} />} onClick={addApp}>
{t("addApp")}
</Button>
{canConnectService && (
<Button variant="default" leftSection={<IconPlug size={18} />} onClick={connectService}>
{t("connectService")}
</Button>
)}
</Group>
</Stack>
</Paper>
</div>
);
};
@@ -0,0 +1,21 @@
.root {
position: absolute;
z-index: 4;
bottom: calc(var(--mantine-spacing-md) * var(--board-canvas-inverse-scale, 1));
left: calc(var(--mantine-spacing-md) * var(--board-canvas-inverse-scale, 1));
max-width: min(24rem, calc(100% - 2 * var(--mantine-spacing-md)));
pointer-events: auto;
}
.panel {
width: min(24rem, calc(100vw - 2rem));
}
.step {
border-radius: var(--mantine-radius-md);
padding: var(--mantine-spacing-xs);
}
.step[data-complete] {
background: light-dark(var(--mantine-color-green-0), rgba(47, 158, 68, 0.1));
}
@@ -0,0 +1,6 @@
declare const classes: {
readonly panel: string;
readonly root: string;
readonly step: string;
};
export default classes;
@@ -0,0 +1,128 @@
"use client";
import { ActionIcon, Button, Group, Paper, Progress, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconCheck, IconChevronDown, IconChecklist } from "@tabler/icons-react";
import { useIntegrationsWithUseAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import { getBoardSetupProgress } from "./board-setup-progress";
import { useBoardAddActions } from "./use-board-add-actions";
import classes from "./board-setup-checklist.module.css";
export const BoardSetupChecklist = () => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const integrations = useIntegrationsWithUseAccess();
const { addWidget, addApp, connectService, canConnectService } = useBoardAddActions();
const [collapsed, setCollapsed] = useLocalStorage({
key: `homarr-board-setup-checklist-${board.id}`,
defaultValue: false,
});
const t = useScopedI18n("board.setupChecklist");
const trackSetup = useSetupAnalytics();
const progress = getBoardSetupProgress({
itemKinds: board.items.map((item) => item.kind),
usableIntegrationCount: integrations.length,
});
if (!isEditMode || progress.isComplete) return null;
if (collapsed) {
return (
<div className={classes.root}>
<Button
variant="default"
leftSection={<IconChecklist size={18} />}
onClick={() => {
trackSetup("checklist-resumed", { entryPoint: "board", outcome: "continued", hasBoardContext: true });
setCollapsed(false);
}}
>
{t("resume", { completed: progress.completedCount, total: progress.totalCount })}
</Button>
</div>
);
}
return (
<div className={classes.root}>
<Paper className={classes.panel} withBorder shadow="md" radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={600}>{t("title")}</Text>
<Text c="dimmed" size="xs">
{t("description")}
</Text>
</div>
<Tooltip label={t("collapse")}>
<ActionIcon variant="subtle" aria-label={t("collapse")} onClick={() => setCollapsed(true)}>
<IconChevronDown size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Progress value={(progress.completedCount / progress.totalCount) * 100} aria-label={t("progress")} />
<ChecklistStep
complete={progress.steps.content}
label={t("step.content.label")}
description={t("step.content.description")}
actionLabel={t("step.content.action")}
onAction={addWidget}
/>
<ChecklistStep
complete={progress.steps.app}
label={t("step.app.label")}
description={t("step.app.description")}
actionLabel={t("step.app.action")}
onAction={addApp}
/>
<ChecklistStep
complete={progress.steps.service}
label={t("step.service.label")}
description={canConnectService ? t("step.service.description") : t("step.service.permissionDescription")}
actionLabel={canConnectService ? t("step.service.action") : undefined}
onAction={canConnectService ? connectService : undefined}
/>
</Stack>
</Paper>
</div>
);
};
const ChecklistStep = ({
complete,
label,
description,
actionLabel,
onAction,
}: {
complete: boolean;
label: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}) => (
<Group className={classes.step} data-complete={complete || undefined} wrap="nowrap" align="flex-start">
<ThemeIcon variant={complete ? "filled" : "light"} color={complete ? "green" : "gray"} size="md" mt={2}>
{complete ? <IconCheck size={14} /> : <IconChecklist size={14} />}
</ThemeIcon>
<Stack gap={1} style={{ flex: 1 }}>
<Text size="sm" fw={600} td={complete ? "line-through" : undefined}>
{label}
</Text>
<Text size="xs" c="dimmed">
{description}
</Text>
</Stack>
{!complete && onAction && actionLabel && (
<Button variant="subtle" size="compact-xs" onClick={onAction}>
{actionLabel}
</Button>
)}
</Group>
);
@@ -0,0 +1,22 @@
import { describe, expect, test } from "vitest";
import { getBoardSetupProgress } from "./board-setup-progress";
describe("getBoardSetupProgress", () => {
test("derives resumable progress from the board and connected services", () => {
expect(getBoardSetupProgress({ itemKinds: ["clock", "app"], usableIntegrationCount: 1 })).toEqual({
steps: { content: true, app: true, service: true },
completedCount: 3,
totalCount: 3,
isComplete: true,
});
});
test("does not count an app as a data widget", () => {
expect(getBoardSetupProgress({ itemKinds: ["app"], usableIntegrationCount: 0 })).toMatchObject({
steps: { content: false, app: true, service: false },
completedCount: 1,
isComplete: false,
});
});
});
@@ -0,0 +1,22 @@
import type { WidgetKind } from "@homarr/definitions";
export interface BoardSetupProgressInput {
itemKinds: readonly WidgetKind[];
usableIntegrationCount: number;
}
export const getBoardSetupProgress = ({ itemKinds, usableIntegrationCount }: BoardSetupProgressInput) => {
const steps = {
content: itemKinds.some((kind) => kind !== "app"),
app: itemKinds.includes("app"),
service: usableIntegrationCount > 0,
};
const completedCount = Object.values(steps).filter(Boolean).length;
return {
steps,
completedCount,
totalCount: Object.keys(steps).length,
isComplete: completedCount === Object.keys(steps).length,
};
};
@@ -4,8 +4,9 @@ import type { Modify } from "@homarr/common/types";
import { getRootSectionLane } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import type { Board, ContainerSection, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
@@ -15,33 +16,38 @@ export interface CreateItemInput {
kind: WidgetKind;
options?: Record<string, unknown>;
integrationIds?: string[];
targetSectionId?: string;
advancedOptions?: BoardItemAdvancedOptions;
}
export const createItemCallback =
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions }: CreateItemInput) =>
(previous: Board): Board => {
const firstSection = previous.sections
const firstCanvasSection = previous.sections
.filter(
(section): section is EmptySection =>
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
const requestedSection = targetSectionId
? previous.sections.find(
(section): section is EmptySection | ContainerSection =>
section.id === targetSectionId && (section.kind === "empty" || section.kind === "container"),
)
: undefined;
const targetSection = requestedSection ?? firstCanvasSection;
if (!firstSection) return previous;
if (!targetSection) return previous;
const itemLayouts = createItemLayouts(previous, firstSection, kind);
const itemLayouts = createItemLayouts(previous, targetSection, kind);
const widget = {
id: id ?? createId(),
kind,
options,
layouts: itemLayouts,
integrationIds,
advancedOptions: {
title: null,
customCssClasses: [],
borderColor: "",
},
advancedOptions: advancedOptions ?? { title: null, customCssClasses: [], borderColor: "" },
} satisfies Modify<
Item,
{
@@ -59,14 +65,23 @@ const defaultItemSize = { width: 1, height: 1 };
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => {
const createItemLayouts = (
board: Board,
currentSection: EmptySection | ContainerSection,
kind: WidgetKind,
): ItemLayout[] => {
const layouts = getBoardLayouts(board);
const itemSize = getWidgetItemSize(kind);
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
const containerLayout =
currentSection.kind === "container"
? currentSection.layouts.find((layout) => layout.layoutId === layoutId)
: undefined;
const columnCount =
containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width);
const size = {
width: Math.min(itemSize.width, columnCount),
height: itemSize.height,
@@ -149,6 +149,48 @@ describe("item actions create-item", () => {
});
});
test("places a new item directly in the requested container", () => {
const board = new BoardMockBuilder()
.addEmptySection({ id: "canvas" })
.addSection(
new ContainerSectionMockBuilder({ id: "media" })
.addLayout({ layoutId: "layout", parentSectionId: "canvas", width: 4, height: 3 })
.build(),
)
.build();
const layout = board.layouts.at(0);
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }),
]);
});
test("falls back to the main canvas when a requested destination no longer exists", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build();
const layout = board.layouts.at(0);
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
const result = createItemCallback({ id: "clock", kind: "clock", targetSectionId: "removed" })(board);
expect(result.items.find((item) => item.id === "clock")?.layouts).toEqual([
expect.objectContaining({ layoutId: "layout", sectionId: "canvas" }),
]);
});
test("commits configured advanced options in the creation transaction", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build();
const advancedOptions = { title: "Kitchen", customCssClasses: ["compact"], borderColor: "#abc" };
const result = createItemCallback({ id: "clock", kind: "clock", advancedOptions })(board);
expect(result.items.find((item) => item.id === "clock")?.advancedOptions).toEqual(advancedOptions);
});
test("clamps wide defaults and places below a full automatic canvas", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
const layout = board.layouts[0];
@@ -0,0 +1,73 @@
import { describe, expect, test } from "vitest";
import { getBoardRecipeDismissalKey, getBoardRecipeRecommendations } from "./board-recipe-recommendations";
test("recipe dismissal is scoped to both the board and integration kind", () => {
expect(getBoardRecipeDismissalKey("board-a", "piHole")).not.toBe(getBoardRecipeDismissalKey("board-b", "piHole"));
expect(getBoardRecipeDismissalKey("board-a", "piHole")).not.toBe(getBoardRecipeDismissalKey("board-a", "jellyfin"));
});
describe("getBoardRecipeRecommendations", () => {
test("prioritizes widgets unlocked by the newly connected service", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole", "jellyfin"],
existingItemKinds: [],
preferredIntegrationKind: "jellyfin",
});
expect(recommendations).toHaveLength(3);
expect(recommendations.slice(0, 2).every(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
expect(recommendations.at(2)).toEqual(
expect.objectContaining({ integrationKind: "piHole", isNewlyAvailable: false }),
);
});
test("does not recommend widget kinds already present on the board", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole"],
existingItemKinds: ["dnsHoleSummary"],
preferredIntegrationKind: "piHole",
});
expect(recommendations.map(({ widgetKind }) => widgetKind)).toEqual(["dnsHoleControls"]);
});
test("deduplicates configured kinds and emitted widget capabilities", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole", "piHole"],
existingItemKinds: [],
limit: 10,
});
expect(recommendations.map(({ widgetKind }) => widgetKind)).toEqual(["dnsHoleControls", "dnsHoleSummary"]);
});
test("falls back to other configured integrations when the new service has no missing widgets", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole", "jellyfin"],
existingItemKinds: ["dnsHoleSummary", "dnsHoleControls"],
preferredIntegrationKind: "piHole",
limit: 1,
});
expect(recommendations).toEqual([
expect.objectContaining({ integrationKind: "jellyfin", isNewlyAvailable: false }),
]);
});
test("returns no recipes without a usable connection or capacity", () => {
expect(
getBoardRecipeRecommendations({
configuredIntegrationKinds: [],
existingItemKinds: [],
}),
).toEqual([]);
expect(
getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole"],
existingItemKinds: [],
limit: 0,
}),
).toEqual([]);
});
});
@@ -0,0 +1,60 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions";
export interface BoardRecipeRecommendation {
widgetKind: WidgetKind;
integrationKind: IntegrationKind;
isNewlyAvailable: boolean;
score: number;
}
export const getBoardRecipeDismissalKey = (boardId: string, integrationKind: IntegrationKind) =>
`homarr-board-recipes-${boardId}-${integrationKind}`;
interface BoardRecipeRecommendationInput {
configuredIntegrationKinds: readonly IntegrationKind[];
existingItemKinds: readonly WidgetKind[];
preferredIntegrationKind?: IntegrationKind;
limit?: number;
}
/**
* Suggests missing widgets that can use an existing connection. The newly
* connected service ranks first, followed by specific capabilities with fewer
* unrelated provider matches. Inputs and outputs are deduplicated so this can
* remain a small, explainable client-side recommendation layer.
*/
export const getBoardRecipeRecommendations = ({
configuredIntegrationKinds,
existingItemKinds,
preferredIntegrationKind,
limit = 3,
}: BoardRecipeRecommendationInput): BoardRecipeRecommendation[] => {
const availableKinds = new Set(configuredIntegrationKinds);
const existingKinds = new Set(existingItemKinds);
return widgetKinds
.filter((widgetKind) => !existingKinds.has(widgetKind))
.flatMap((widgetKind) => {
const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities];
if (!capability) return [];
const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind));
if (matches.length === 0) return [];
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
const integrationKind = preferredMatch ? preferredIntegrationKind : matches[0];
if (!integrationKind) return [];
return [
{
widgetKind,
integrationKind,
isNewlyAvailable: Boolean(preferredMatch),
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length,
},
];
})
.toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.slice(0, Math.max(0, limit));
};
@@ -1,6 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
import {
getWidgetConnectionStatus,
resolveMatchingIntegrationsAsync,
tryLockSelection,
unlockSelection,
} from "./item-select-data";
describe("getWidgetConnectionStatus", () => {
const availableKinds = new Set(["sonarr" as const]);
it("reports widgets with a configured compatible integration as ready", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr", "radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("ready");
});
it("reports required integrations that are not configured as needing setup", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("needsSetup");
});
it.each([
{ supportedIntegrations: [], connectionOptional: false },
{ supportedIntegrations: ["radarr" as const], connectionOptional: true },
])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => {
expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe(
"noConnectionRequired",
);
});
});
describe("widget selection lock", () => {
it("rejects repeated selection synchronously until the active selection finishes", () => {
@@ -1,5 +1,21 @@
import type { IntegrationKind } from "@homarr/definitions";
export type WidgetConnectionStatus = "ready" | "needsSetup" | "noConnectionRequired";
export const getWidgetConnectionStatus = ({
supportedIntegrations,
availableKinds,
connectionOptional,
}: {
supportedIntegrations: readonly IntegrationKind[];
availableKinds: ReadonlySet<IntegrationKind>;
connectionOptional: boolean;
}): WidgetConnectionStatus => {
if (supportedIntegrations.some((kind) => availableKinds.has(kind))) return "ready";
if (supportedIntegrations.length === 0 || connectionOptional) return "noConnectionRequired";
return "needsSetup";
};
export const tryLockSelection = (lock: { current: boolean }) => {
if (lock.current) return false;
lock.current = true;
@@ -1,27 +1,43 @@
import { useMemo, useRef, useState } from "react";
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { Avatar, Badge, Box, Button, Center, Divider, Group, Image, Select, Stack, Text, Tooltip } from "@mantine/core";
import { IconApi } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
import {
getIconUrl,
getIntegrationName,
getRootSectionLane,
widgetIntegrationSupport,
widgetKinds,
widgetKindsWithOptionalIntegrations,
} from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useItemActions } from "./item-actions";
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
import type { WidgetConnectionStatus } from "./item-select-data";
import {
getWidgetConnectionStatus,
resolveMatchingIntegrationsAsync,
tryLockSelection,
unlockSelection,
} from "./item-select-data";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import classes from "./item-select-modal.module.css";
interface ItemSelectModalContentProps {
actions: { closeModal: () => void };
@@ -29,6 +45,8 @@ interface ItemSelectModalContentProps {
customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined;
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
isAdmin: boolean;
canCreateIntegration: boolean;
initialSearch?: string;
}
const ItemSelectModalContent = ({
@@ -37,14 +55,50 @@ const ItemSelectModalContent = ({
customWidgetDefs,
ensureIntegrationDataAsync,
isAdmin,
canCreateIntegration,
initialSearch = "",
}: ItemSelectModalContentProps) => {
const [search, setSearch] = useState("");
const [search, setSearch] = useState(initialSearch);
const t = useI18n();
const board = useRequiredBoard();
const mainCanvasSection = board.sections
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
const placementOptions = useMemo(
() => [
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
...board.sections.flatMap((section) => {
if (section.kind !== "empty") return [];
const lane = getRootSectionLane(section.xOffset);
if (lane === "main") return [];
return [
{
value: section.id,
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
},
];
}),
...board.sections
.filter((section) => section.kind === "container")
.map((section) => ({
value: section.id,
label: section.options.title.trim() || t("section.container.untitled"),
})),
],
[board.sections, mainCanvasSection, t],
);
const [targetSectionId, setTargetSectionId] = useState<string | null>(mainCanvasSection?.id ?? null);
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false);
const t = useI18n();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { createItem, removeItem } = useItemActions();
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const settings = useSettings();
const trackSetup = useSetupAnalytics();
const flowStartedAt = useRef<number | null>(null);
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
@@ -94,6 +148,21 @@ const ItemSelectModalContent = ({
});
};
const notifyCreated = (itemId: string, name: string) => {
showSuccessNotification({
title: t("item.create.notification.success.title"),
message: (
<Group justify="space-between" wrap="nowrap">
<Text size="sm">{t("item.create.notification.success.message", { name })}</Text>
<Button variant="subtle" size="compact-xs" onClick={() => removeItem({ itemId })}>
{t("common.action.undo")}
</Button>
</Group>
),
autoClose: 10_000,
});
};
const handleAddCustomWidget = async (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => {
if (!tryLockSelection(selectionLock)) return;
setLoadingSelection(`custom:${customWidgetDefinition.id}`);
@@ -119,9 +188,15 @@ const ItemSelectModalContent = ({
integrationIds: [],
},
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: configuredOptions });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
createItem({
id: itemId,
kind: "customApi",
integrationIds: [],
options: configuredOptions,
targetSectionId: targetSectionId ?? undefined,
advancedOptions,
});
notifyCreated(itemId, customWidgetDefinition.name);
},
integrationData: [],
integrationSupport: false,
@@ -141,6 +216,8 @@ const ItemSelectModalContent = ({
const handleAdd = async (kind: WidgetKind) => {
if (!tryLockSelection(selectionLock)) return;
flowStartedAt.current = performance.now();
trackSetup("widget-started", { entryPoint: "board", intent: kind });
setLoadingSelection(kind);
preloadWidgetEditModal();
try {
@@ -157,32 +234,83 @@ const ItemSelectModalContent = ({
const integrationIds = matchingIntegrations.map((i) => i.id);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
const openEditor = (
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
) => {
const selectedIntegrationIds = availableIntegrations.map((integration) => integration.id);
openEditModal(
{
kind,
definition,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds: selectedIntegrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
createItem({
id: itemId,
kind,
options,
integrationIds: newIntegrationIds,
targetSectionId: targetSectionId ?? undefined,
advancedOptions,
});
notifyCreated(itemId, t(`widget.${kind}.name`));
trackSetup("widget-completed", {
entryPoint: "board",
intent: kind,
outcome: "completed",
elapsedMs: flowStartedAt.current ? Math.round(performance.now() - flowStartedAt.current) : undefined,
});
},
integrationData: availableIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
);
};
const integrationsRequired =
hasIntegrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
if (integrationsRequired && integrationIds.length === 0) {
trackSetup("dependency-blocked", {
entryPoint: "board",
intent: kind,
outcome: "blocked",
canResolveInline: canCreateIntegration,
});
if (!canCreateIntegration) {
showErrorNotification({
title: t("item.create.missingIntegration.title"),
message: t("item.create.missingIntegration.noPermission"),
});
return;
}
actions.closeModal();
openIntegrationModal({
allowedKinds: (definition.supportedIntegrations ?? []).filter((integration) => integration !== "mock"),
onSuccess: (result) => {
if (result) {
trackSetup("dependency-resolved-inline", {
entryPoint: "board",
intent: kind,
outcome: "continued",
canResolveInline: true,
});
openEditor([result.integration]);
}
},
});
return;
}
createItem({ id: itemId, kind, integrationIds });
actions.closeModal();
openEditModal(
{
kind,
definition,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
updateItemOptions({ itemId, newOptions: options });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
},
integrationData: matchingIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
);
openEditor(matchingIntegrations);
} catch (error) {
notifyDefinitionLoadError(error);
} finally {
@@ -204,6 +332,18 @@ const ItemSelectModalContent = ({
}
}}
>
{placementOptions.length > 1 && (
<Box style={{ gridColumn: "1 / -1" }}>
<Select
label={t("item.create.destination.label")}
description={t("item.create.destination.description")}
data={placementOptions}
value={targetSectionId}
onChange={setTargetSectionId}
allowDeselect={false}
/>
</Box>
)}
{filteredItems.map((item) => (
<WidgetItem
key={item.kind}
@@ -215,7 +355,11 @@ const ItemSelectModalContent = ({
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
connectionStatus={getWidgetConnectionStatus({
supportedIntegrations: item.supportedIntegrations,
availableKinds,
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
})}
/>
))}
@@ -228,13 +372,15 @@ const ItemSelectModalContent = ({
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<Card
<CatalogItem
key={def.id}
className={classes.card}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden" }}
height={selectGridCardHeight}
label={def.name}
status={t("item.create.connectionStatus.noConnectionRequired")}
disabled={loadingSelection !== null}
busy={loadingSelection === `custom:${def.id}`}
onSelect={() => void handleAddCustomWidget(def)}
onFocus={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
>
<Stack h="100%" gap="xs">
@@ -251,30 +397,9 @@ const ItemSelectModalContent = ({
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
<ConnectionStatusBadge status="noConnectionRequired" />
</Stack>
<Box
className={classes.action}
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button
onClick={() => void handleAddCustomWidget(def)}
variant="light"
size="xs"
fullWidth
disabled={loadingSelection !== null}
loading={loadingSelection === `custom:${def.id}`}
>
{t("item.create.addToBoard")}
</Button>
</Box>
</Card>
</CatalogItem>
))}
<Box style={{ gridColumn: "1 / -1" }}>
@@ -296,11 +421,13 @@ const ItemSelectModalFrame = ({
innerProps,
}: {
actions: { closeModal: () => void };
innerProps: { boardId: string };
innerProps: { boardId: string; initialIntegrationKind?: IntegrationKind; initialWidgetKind?: WidgetKind };
}) => {
const utils = clientApi.useUtils();
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const canCreateIntegration = session?.user.permissions.includes("integration-create") ?? false;
const t = useI18n();
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
{ boardId: innerProps.boardId },
@@ -314,13 +441,23 @@ const ItemSelectModalFrame = ({
customWidgetDefs={customWidgetDefs}
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
isAdmin={isAdmin}
canCreateIntegration={canCreateIntegration}
initialSearch={
innerProps.initialWidgetKind
? t(`widget.${innerProps.initialWidgetKind}.name`)
: innerProps.initialIntegrationKind
? getIntegrationName(innerProps.initialIntegrationKind)
: undefined
}
/>
);
};
export const ItemSelectModal = createModal<{ boardId: string }>((props) => (
<ItemSelectModalFrame {...props} />
)).withOptions({
export const ItemSelectModal = createModal<{
boardId: string;
initialIntegrationKind?: IntegrationKind;
initialWidgetKind?: WidgetKind;
}>((props) => <ItemSelectModalFrame {...props} />).withOptions({
defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect,
});
@@ -331,7 +468,7 @@ const WidgetItem = ({
onIntent,
disabled,
loading,
hasMatchingIntegration,
connectionStatus,
}: {
item: {
kind: WidgetKind;
@@ -344,24 +481,21 @@ const WidgetItem = ({
onIntent: () => void;
disabled: boolean;
loading: boolean;
hasMatchingIntegration: boolean;
connectionStatus: WidgetConnectionStatus;
}) => {
const t = useI18n();
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
return (
<Card
className={classes.card}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{
overflow: "hidden",
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: hasMatchingIntegration ? 2 : undefined,
}}
<CatalogItem
height={selectGridCardHeight}
label={item.name}
status={statusLabel}
onFocus={onIntent}
onPointerEnter={onIntent}
aria-busy={loading || undefined}
onSelect={onSelect}
disabled={disabled}
busy={loading}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
@@ -375,24 +509,21 @@ const WidgetItem = ({
{item.description}
</Text>
</Tooltip>
<ConnectionStatusBadge status={connectionStatus} />
<SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack>
<Box
className={classes.action}
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
{t(`item.create.addToBoard`)}
</Button>
</Box>
</Card>
</CatalogItem>
);
};
const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
const t = useI18n();
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
return (
<Badge variant="light" color={color} size="xs" tt="none" w="fit-content" maw="100%">
{t(`item.create.connectionStatus.${status}`)}
</Badge>
);
};
@@ -405,7 +536,7 @@ const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind
const moreCount = integrations.length - countToShow;
return (
<Group gap={2} mt="auto">
<Group gap={2}>
<Tooltip.Group closeDelay={100}>
<Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => (
@@ -65,4 +65,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
return t("item.edit.title");
},
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
});
@@ -0,0 +1,46 @@
"use client";
import { useCallback } from "react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useItemActions } from "./items/item-actions";
import { ItemSelectModal } from "./items/item-select-modal";
import { useContainerActions } from "./sections/container/container-actions";
export const useBoardAddActions = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const canCreateApp = session?.user.permissions.includes("app-create") ?? false;
const canConnectService = session?.user.permissions.includes("integration-create") ?? false;
const addWidget = useCallback(() => openItemSelectModal({ boardId: board.id }), [board.id, openItemSelectModal]);
const addApp = useCallback(
() =>
openAppSelectModal({
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
withCreate: canCreateApp,
}),
[canCreateApp, createItem, openAppSelectModal],
);
const connectService = useCallback(
() => openIntegrationSelectModal({ completionBoardId: board.id }),
[board.id, openIntegrationSelectModal],
);
return {
addWidget,
addApp,
connectService,
addContainer,
canConnectService,
};
};
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import type { SetupMetricProperties } from "./setup-analytics";
describe("SetupMetricProperties", () => {
test("keeps the measurement contract coarse and configuration-free", () => {
const allowedKeys = [
"entryPoint",
"intent",
"outcome",
"elapsedMs",
"hasBoardContext",
"canResolveInline",
] satisfies (keyof SetupMetricProperties)[];
expect(allowedKeys).not.toContain("url");
expect(allowedKeys).not.toContain("query");
expect(allowedKeys).not.toContain("recordId");
});
});
@@ -0,0 +1,41 @@
"use client";
import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
export type SetupMetricEvent =
| "surface-opened"
| "intent-selected"
| "widget-started"
| "dependency-blocked"
| "dependency-resolved-inline"
| "widget-completed"
| "completion-recipe-selected"
| "checklist-resumed";
export type SetupMetricEntryPoint = "header" | "spotlight" | "board" | "assistant" | "docker" | "management";
export interface SetupMetricProperties {
entryPoint: SetupMetricEntryPoint;
intent?: string;
outcome?: "completed" | "blocked" | "continued";
elapsedMs?: number;
hasBoardContext?: boolean;
canResolveInline?: boolean;
}
/**
* Emits only coarse workflow measurements through Homarr's existing opt-in analytics gate.
* Never pass record IDs, names, URLs, search text, credentials, or provider responses here.
*/
export const useSetupAnalytics = () => {
const { mutate } = clientApi.analytics.trackFeature.useMutation();
return useCallback(
(event: SetupMetricEvent, properties: SetupMetricProperties) => {
mutate({ feature: `setup:${event}`, properties: { ...properties } });
},
[mutate],
);
};
@@ -0,0 +1,78 @@
import { describe, expect, test } from "vitest";
import type { RankedUniversalCreateAction } from "./universal-create-actions";
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
describe("getUniversalCreateActionDefinitions", () => {
test("only exposes actions allowed without a board context", () => {
const actions = getUniversalCreateActionDefinitions({
hasBoardContext: false,
permissions: ["board-create", "integration-create"],
});
expect(actions.map(({ key }) => key)).toEqual(["integration", "board", "importBoard"]);
});
test("places globally-created apps in the library group", () => {
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
});
test("keeps board content actions available without global create permissions", () => {
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
});
test("exposes the complete surface to administrators on a board", () => {
const actions = getUniversalCreateActionDefinitions({
hasBoardContext: true,
permissions: ["admin", "app-create", "integration-create", "board-create"],
});
expect(actions.map(({ key }) => key)).toEqual([
"widget",
"app",
"integration",
"container",
"board",
"importBoard",
"workshop",
"customWidget",
]);
});
});
describe("filterAndRankUniversalCreateActions", () => {
const actions = [
{ key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
{
key: "integration",
group: "library",
priority: 80,
name: "Connect service",
description: "Create an integration",
keywords: ["provider"],
},
{ key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
] satisfies RankedUniversalCreateAction[];
test("uses product priority when no search is entered", () => {
expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
"widget",
"integration",
"board",
]);
});
test("ranks a name prefix ahead of a description or keyword match", () => {
const results = filterAndRankUniversalCreateActions(actions, "create");
expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
});
test("matches keywords and removes unrelated actions", () => {
expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
});
});
@@ -0,0 +1,127 @@
import type { GroupPermissionKey } from "@homarr/definitions";
export const universalCreateActionKeys = [
"widget",
"app",
"integration",
"container",
"board",
"importBoard",
"workshop",
"customWidget",
] as const;
export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
export interface UniversalCreateContext {
hasBoardContext: boolean;
permissions: readonly GroupPermissionKey[];
}
interface UniversalCreateActionDefinition {
key: UniversalCreateActionKey;
group: UniversalCreateActionGroup;
priority: number;
isVisible: (context: UniversalCreateContext) => boolean;
}
const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
context.permissions.includes(permission);
const definitions: readonly UniversalCreateActionDefinition[] = [
{
key: "widget",
group: "currentBoard",
priority: 100,
isVisible: ({ hasBoardContext }) => hasBoardContext,
},
{
key: "app",
group: "currentBoard",
priority: 90,
isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
},
{
key: "integration",
group: "library",
priority: 80,
isVisible: (context) => hasPermission(context, "integration-create"),
},
{
key: "container",
group: "currentBoard",
priority: 70,
isVisible: ({ hasBoardContext }) => hasBoardContext,
},
{
key: "board",
group: "boards",
priority: 60,
isVisible: (context) => hasPermission(context, "board-create"),
},
{
key: "importBoard",
group: "boards",
priority: 50,
isVisible: (context) => hasPermission(context, "board-create"),
},
{
key: "workshop",
group: "library",
priority: 40,
isVisible: (context) => hasPermission(context, "admin"),
},
{
key: "customWidget",
group: "library",
priority: 30,
isVisible: (context) => hasPermission(context, "admin"),
},
];
export interface RankedUniversalCreateAction {
key: UniversalCreateActionKey;
group: UniversalCreateActionGroup;
priority: number;
name: string;
description: string;
keywords?: readonly string[];
}
export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
definitions
.filter((definition) => definition.isVisible(context))
.map((definition) =>
definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
);
const normalize = (value: string) => value.trim().toLocaleLowerCase();
export const filterAndRankUniversalCreateActions = <TAction extends RankedUniversalCreateAction>(
actions: readonly TAction[],
query: string,
) => {
const normalizedQuery = normalize(query);
return actions
.map((action) => {
const name = normalize(action.name);
const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
const relevance =
normalizedQuery.length === 0
? 0
: name === normalizedQuery
? 300
: name.startsWith(normalizedQuery)
? 200
: searchableText.includes(normalizedQuery)
? 100
: -1;
return { action, relevance };
})
.filter(({ relevance }) => relevance >= 0)
.toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
.map(({ action }) => action);
};
@@ -0,0 +1,21 @@
.action {
width: 100%;
border-radius: var(--mantine-radius-md);
padding: var(--mantine-spacing-sm);
text-align: start;
}
@media (hover: hover) {
.action:hover {
background: var(--mantine-color-default-hover);
}
}
.action:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.actionIcon {
flex: 0 0 auto;
}
@@ -0,0 +1,5 @@
declare const classes: {
readonly action: string;
readonly actionIcon: string;
};
export default classes;
@@ -0,0 +1,184 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Group, Stack, Text, TextInput, ThemeIcon, UnstyledButton } from "@mantine/core";
import {
IconApps,
IconBox,
IconCode,
IconFileImport,
IconLayoutDashboard,
IconLayoutGridAdd,
IconPlugConnected,
IconSearch,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import classes from "./universal-create-modal.module.css";
import type {
RankedUniversalCreateAction,
UniversalCreateActionGroup,
UniversalCreateActionKey,
} from "./universal-create-actions";
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
import type { SetupMetricEntryPoint } from "./setup-analytics";
import { useSetupAnalytics } from "./setup-analytics";
type BoardActionKey = "widget" | "app" | "integration" | "container";
export interface UniversalCreateModalProps {
boardActions?: Partial<Record<BoardActionKey, () => void>>;
entryPoint?: SetupMetricEntryPoint;
}
const actionIcons = {
widget: IconApps,
app: IconBox,
integration: IconPlugConnected,
container: IconLayoutGridAdd,
board: IconLayoutDashboard,
importBoard: IconFileImport,
workshop: IconSearch,
customWidget: IconCode,
} satisfies Record<UniversalCreateActionKey, TablerIcon>;
export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ actions, innerProps }) => {
const { data: session } = useSession();
const router = useRouter();
const t = useScopedI18n("universalCreate");
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
const { openModal: openImportBoardModal } = useModalAction(ImportBoardModal);
const [query, setQuery] = useState("");
const trackedOpen = useRef(false);
const boardActions = innerProps.boardActions;
const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
const trackSetup = useSetupAnalytics();
const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[];
const definitions = getUniversalCreateActionDefinitions({
hasBoardContext: boardActions !== undefined,
permissions,
});
useEffect(() => {
if (trackedOpen.current) return;
trackedOpen.current = true;
trackSetup("surface-opened", {
entryPoint,
hasBoardContext: boardActions !== undefined,
});
}, [boardActions, entryPoint, trackSetup]);
const invokeAction = (key: UniversalCreateActionKey) => {
const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined;
actions.closeModal();
trackSetup("intent-selected", {
entryPoint,
intent: key,
outcome: "continued",
hasBoardContext: boardActions !== undefined,
});
if (boardAction) {
boardAction();
return;
}
switch (key) {
case "app":
router.push("/manage/apps/new");
break;
case "integration":
router.push("/manage/integrations/new");
break;
case "board":
openAddBoardModal(undefined);
break;
case "importBoard":
openImportBoardModal(undefined);
break;
case "workshop":
router.push("/manage/workshop");
break;
case "customWidget":
router.push("/manage/custom-widgets/new");
break;
case "widget":
case "container":
break;
}
};
const resolvedActions = definitions.map(
(definition) =>
({
...definition,
name: t(`action.${definition.key}.label`),
description: t(`action.${definition.key}.description`),
keywords: t(`action.${definition.key}.keywords`).split(" "),
}) satisfies RankedUniversalCreateAction,
);
const visibleActions = filterAndRankUniversalCreateActions(resolvedActions, query);
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
return (
<Stack gap="md">
<TextInput
data-autofocus
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={t("search.placeholder")}
aria-label={t("search.label")}
/>
{visibleActions.length === 0 ? (
<Text c="dimmed" ta="center" py="lg">
{t("search.noResults")}
</Text>
) : (
groups.map((group) => {
const groupActions = visibleActions.filter((action) => action.group === group);
if (groupActions.length === 0) return null;
return (
<Stack key={group} gap={4}>
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
{t(`group.${group}`)}
</Text>
{groupActions.map((action) => {
const Icon = actionIcons[action.key];
return (
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
<Group wrap="nowrap" align="flex-start">
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
<Icon size={18} stroke={1.5} />
</ThemeIcon>
<Stack gap={1}>
<Text fw={600} size="sm">
{action.name}
</Text>
<Text c="dimmed" size="xs">
{action.description}
</Text>
</Stack>
</Group>
</UnstyledButton>
);
})}
</Stack>
);
})
)}
</Stack>
);
}).withOptions({
defaultTitle: "universalCreate.title",
size: "lg",
});
@@ -0,0 +1,35 @@
"use client";
import { useMemo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { UniversalCreateModal } from "./universal-create-modal";
/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */
export const UniversalCreateSpotlightResult = () => {
const t = useScopedI18n("universalCreate");
const { openModal } = useModalAction(UniversalCreateModal);
const item = useMemo(
() => ({
id: "universal-create",
name: t("title"),
description: t("trigger.description"),
icon: IconPlus,
alwaysVisible: true,
interaction: () => ({
type: "javaScript" as const,
onSelect: () => {
openModal({ entryPoint: "spotlight" });
},
}),
}),
[openModal, t],
);
useRegisterSpotlightContextResults("universal-create", [item], [item]);
return null;
};
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { HeaderButton } from "~/components/layout/header/button";
import { UniversalCreateModal } from "./universal-create-modal";
export const UniversalCreateHeaderAction = () => {
const t = useScopedI18n("universalCreate");
const { openModal } = useModalAction(UniversalCreateModal);
return (
<HeaderButton onClick={() => openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}>
<IconPlus stroke={1.5} />
</HeaderButton>
);
};
export const UniversalCreateQueryGate = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const { openModal } = useModalAction(UniversalCreateModal);
const handled = useRef(false);
const shouldOpen = searchParams.get("create") === "true";
useEffect(() => {
if (!shouldOpen) {
handled.current = false;
return;
}
if (handled.current) return;
handled.current = true;
openModal({ entryPoint: "spotlight" });
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("create");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [openModal, pathname, router, searchParams, shouldOpen]);
return null;
};
@@ -0,0 +1,194 @@
"use client";
import { ActionIcon, Button, Divider, Group, List, Stack, Text, ThemeIcon, Title, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconAppWindow, IconCheck, IconLayoutDashboard, IconPlugConnected, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, getWidgetKindsForIntegration } from "@homarr/definitions";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { CreatedIntegrationResult } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import {
getBoardRecipeDismissalKey,
getBoardRecipeRecommendations,
} from "~/components/board/items/board-recipe-recommendations";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
interface IntegrationCompletionModalProps {
result: CreatedIntegrationResult;
boardId?: string;
}
export const IntegrationCompletionModal = createModal<IntegrationCompletionModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const board = useOptionalBoard();
const { data: session } = useSession();
const { data: integrations = [] } = clientApi.integration.all.useQuery();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const trackSetup = useSetupAnalytics();
const compatibleWidgets = getWidgetKindsForIntegration(innerProps.result.integration.kind);
const boardMatchesContext = board !== null && board.id === innerProps.boardId;
const canModifyBoard = boardMatchesContext && constructBoardPermissions(board, session).hasChangeAccess;
const dismissalKey = getBoardRecipeDismissalKey(
innerProps.boardId ?? "no-board-context",
innerProps.result.integration.kind,
);
const [recommendationsDismissed, setRecommendationsDismissed] = useLocalStorage({
key: dismissalKey,
defaultValue: false,
});
const configuredIntegrationKinds = [
...new Set<IntegrationKind>([
...integrations.map((integration) => integration.kind),
innerProps.result.integration.kind,
]),
];
const recommendations = boardMatchesContext
? getBoardRecipeRecommendations({
configuredIntegrationKinds,
existingItemKinds: board.items.map((item) => item.kind),
preferredIntegrationKind: innerProps.result.integration.kind,
})
: [];
const showRecommendations = canModifyBoard && !recommendationsDismissed && recommendations.length > 0;
const addWidget = () => {
if (!innerProps.boardId) return;
actions.closeModal();
openItemSelectModal({
boardId: innerProps.boardId,
initialIntegrationKind: innerProps.result.integration.kind,
});
};
const addRecommendedWidget = (widgetKind: (typeof recommendations)[number]["widgetKind"]) => {
if (!innerProps.boardId) return;
trackSetup("completion-recipe-selected", {
entryPoint: "board",
intent: "add-compatible-widget",
outcome: "continued",
hasBoardContext: true,
});
actions.closeModal();
openItemSelectModal({ boardId: innerProps.boardId, initialWidgetKind: widgetKind });
};
return (
<Stack gap="lg">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon color="green" variant="light" size="xl" radius="xl">
<IconCheck size={22} />
</ThemeIcon>
<Stack gap={2}>
<Title order={3} size="h4">
{t("integration.completion.title", { name: innerProps.result.integration.name })}
</Title>
<Text c="dimmed" size="sm">
{t("integration.completion.description")}
</Text>
</Stack>
</Group>
<List
spacing="sm"
icon={
<ThemeIcon color="green" size="sm" radius="xl">
<IconCheck size={12} />
</ThemeIcon>
}
>
<List.Item>
<Group gap="xs">
<IconPlugConnected size={17} />
<Text size="sm">{t("integration.completion.connectionReady")}</Text>
</Group>
</List.Item>
{innerProps.result.appId && (
<List.Item>
<Group gap="xs">
<IconAppWindow size={17} />
<Text size="sm">{t("integration.completion.appReady")}</Text>
</Group>
</List.Item>
)}
<List.Item>
<Group gap="xs">
<IconLayoutDashboard size={17} />
<Text size="sm">
{t("integration.completion.compatibleWidgets", { count: String(compatibleWidgets.length) })}
</Text>
</Group>
</List.Item>
</List>
{showRecommendations && (
<Stack gap="xs">
<Divider />
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={600} size="sm">
{t("integration.completion.recipes.title")}
</Text>
<Text c="dimmed" size="xs">
{t("integration.completion.recipes.description")}
</Text>
</div>
<Tooltip label={t("integration.completion.recipes.dismiss")}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("integration.completion.recipes.dismiss")}
onClick={() => setRecommendationsDismissed(true)}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
</Group>
{recommendations.map((recommendation) => (
<Group key={recommendation.widgetKind} justify="space-between" wrap="nowrap" gap="sm">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{t(`widget.${recommendation.widgetKind}.name`)}
</Text>
<Text c="dimmed" size="xs" truncate>
{t(
recommendation.isNewlyAvailable
? "integration.completion.recipes.newConnectionReason"
: "integration.completion.recipes.existingConnectionReason",
{
integration: recommendation.isNewlyAvailable
? innerProps.result.integration.name
: getIntegrationName(recommendation.integrationKind),
},
)}
</Text>
</Stack>
<Button variant="light" size="compact-xs" onClick={() => addRecommendedWidget(recommendation.widgetKind)}>
{t("integration.completion.recipes.add")}
</Button>
</Group>
))}
</Stack>
)}
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.close")}
</Button>
{innerProps.boardId && compatibleWidgets.length > 0 && (
<Button onClick={addWidget}>{t("integration.completion.chooseWidget")}</Button>
)}
</Group>
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("integration.completion.modalTitle"),
size: modalSizeForm,
});
@@ -6,20 +6,25 @@ import { IconArrowLeft } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeSelect } from "@homarr/modals";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { IntegrationAvatar } from "@homarr/ui";
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import type { CreatedIntegrationResult } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { IntegrationSelectGrid } from "./integration-select-grid";
import { IntegrationCompletionModal } from "./integration-completion-modal";
interface IntegrationSelectModalProps {
onSuccess?: () => void;
onSuccess?: (result?: CreatedIntegrationResult) => void;
enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[];
completionBoardId?: string;
}
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const [step, setStep] = useState<"select" | "form">("select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
const handleSelect = (kind: IntegrationKind) => {
setSelectedKind(kind);
@@ -31,9 +36,13 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
setSelectedKind(null);
};
const handleSuccess = () => {
innerProps.onSuccess?.();
const handleSuccess = (result?: CreatedIntegrationResult) => {
actions.closeModal();
if (innerProps.onSuccess) {
innerProps.onSuccess(result);
} else if (result) {
openCompletionModal({ result, boardId: innerProps.completionBoardId });
}
};
if (step === "form" && selectedKind) {
@@ -49,7 +58,13 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
);
}
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={innerProps.enableMockIntegration} />;
return (
<IntegrationSelectGrid
onSelect={handleSelect}
enableMockIntegration={innerProps.enableMockIntegration}
allowedKinds={innerProps.allowedKinds}
/>
);
}).withOptions({
defaultTitle: (t) => t("integration.action.create"),
size: modalSizeSelect,
@@ -3,6 +3,8 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Link } from "@homarr/ui";
import { UniversalCreateHeaderAction, UniversalCreateQueryGate } from "~/components/create/universal-create-trigger";
import { UniversalCreateSpotlightResult } from "~/components/create/universal-create-spotlight";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
@@ -36,6 +38,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <UniversalCreateHeaderAction />}
{actions}
<MobileSearchButton />
<TourTarget id="board-user-menu">
@@ -43,6 +46,8 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
</TourTarget>
</Group>
</Group>
<UniversalCreateQueryGate />
<UniversalCreateSpotlightResult />
<LazySpotlight />
</AppShellHeader>
);