enhance release v2 experience
This commit is contained in:
1 parent
9b4970583e
commit
ef2ed6c602
152 files changed
+7236
-764
No files matched your search
@@ -95,6 +95,9 @@ jobs:
|
||||
- name: Validate checked-in OpenAPI schema
|
||||
run: pnpm check:openapi
|
||||
|
||||
- name: Validate feature contracts
|
||||
run: pnpm check:feature-contracts
|
||||
|
||||
- name: Validate Custom Widget bundle budget
|
||||
run: |
|
||||
pnpm --filter nextjs build
|
||||
|
||||
@@ -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">
|
||||
|
||||
+3
-1
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+72
@@ -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;
|
||||
+91
@@ -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");
|
||||
});
|
||||
});
|
||||
+186
@@ -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>
|
||||
);
|
||||
}
|
||||
+30
@@ -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>
|
||||
);
|
||||
}
|
||||
+18
-4
@@ -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}
|
||||
/>
|
||||
|
||||
+6
-1
@@ -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">
|
||||
|
||||
+11
-7
@@ -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>
|
||||
);
|
||||
}
|
||||
+11
-7
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# Release v2 experience implementation report
|
||||
|
||||
Implementation date: 2026-08-13
|
||||
|
||||
Starting point: live `release/v2` roll-up PR #6545 at `3dd1fd015e3bcb2fe1d01f2ca2e78325c9bd2983`
|
||||
|
||||
Reconciled parent: `origin/dev` at `2513e8454`
|
||||
|
||||
Working branch: `agent/release-v2-experience`
|
||||
|
||||
## TL;DR
|
||||
|
||||
Homarr now has one recognizable creation language, in-context prerequisite resolution, useful completion recipes, repeatable contributor tooling, and endpoint-safe infrastructure management; the remaining North Star boundary is a durable cross-record setup transaction rather than another visual redesign.
|
||||
|
||||
## 1. Outcome
|
||||
|
||||
**In one sentence:** The implementation converges the highest-value release-v2 paths without replacing the UI system or introducing a database-wide Service migration.
|
||||
|
||||
The work was delivered as four parallel vertical streams and then reconciled against the current `dev` parent:
|
||||
|
||||
1. Shared interaction and transactional board creation.
|
||||
2. Contextual completion and fewer-click setup flows.
|
||||
3. Contributor contracts, generators, fixtures, and a visual workbench.
|
||||
4. Docker and Kubernetes identity, resilience, security, and diagnostics.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Before["Before: choose the right subsystem"] --> Friction["Route switches, hidden prerequisites, duplicated contracts"]
|
||||
Friction --> Catalog["Shared CatalogItem"]
|
||||
Friction --> Create["Universal Create + inline resolution"]
|
||||
Friction --> DX["Capability contracts + generators"]
|
||||
Friction --> Infra["Endpoint-scoped infrastructure"]
|
||||
Catalog --> Now["Now: state intent, resolve needs, return to context"]
|
||||
Create --> Now
|
||||
DX --> Now
|
||||
Infra --> Now
|
||||
```
|
||||
|
||||
## 2. Shared interaction system
|
||||
|
||||
**In one sentence:** Apps, integrations, widgets, and custom widgets now behave like members of one accessible catalog rather than visually related but semantically different cards.
|
||||
|
||||
The new `CatalogItem` contract supplies:
|
||||
|
||||
- Native button semantics and Enter/Space activation.
|
||||
- Visible focus treatment and arrow/Home/End focus movement.
|
||||
- Shared selected, disabled, busy, and status semantics.
|
||||
- Pointer, touch, and keyboard access without hover-only actions.
|
||||
- Explicit Ready, Needs setup, and No connection required language.
|
||||
|
||||
The contract is used by the App, Integration, multi-Integration, Widget, and Custom Widget catalogs. Widget readiness is derived from the canonical compatibility data rather than a decorative border.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Shell["SelectGridLayout"] --> Item["CatalogItem"]
|
||||
Item --> App["App catalog"]
|
||||
Item --> Integration["Integration catalogs"]
|
||||
Item --> Widget["Widget catalog"]
|
||||
Item --> Custom["Custom Widget catalog"]
|
||||
State["Capability + connection state"] --> Item
|
||||
Item --> Semantics["Keyboard, touch, focus, busy, selected, status"]
|
||||
```
|
||||
|
||||
## 3. Board creation and placement
|
||||
|
||||
**In one sentence:** A Widget is now configured before it exists, then added once with its full options, integrations, advanced settings, destination, and an Undo action.
|
||||
|
||||
The Widget flow now:
|
||||
|
||||
1. Loads the typed Widget definition.
|
||||
2. Resolves compatible existing integrations.
|
||||
3. Resolves a missing required Integration inline when permitted.
|
||||
4. Keeps General and Advanced settings under one save boundary.
|
||||
5. Creates one complete board item only after a successful edit.
|
||||
6. Places it in the chosen main canvas, left rail, right rail, or Container.
|
||||
7. Offers Undo for ten seconds.
|
||||
|
||||
Board creation returns its new identity and Base layout, opens the Board directly in edit mode, and launches the creation surface. Empty Boards expose Add Widget, Add App, and Connect Service actions. A resumable, state-derived checklist follows actual Board content and usable integrations instead of tour-view state.
|
||||
|
||||
Desktop Widget editing uses a right-side inspector and mobile uses a bottom sheet while retaining the existing native form renderers.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant Picker as Widget catalog
|
||||
participant Dependency as Integration setup
|
||||
participant Editor as Native Widget editor
|
||||
participant Board as Board patch
|
||||
|
||||
User->>Picker: Choose Widget and destination
|
||||
Picker->>Picker: Resolve readiness
|
||||
alt Required connection missing
|
||||
Picker->>Dependency: Open compatible Integration setup
|
||||
Dependency-->>Picker: Return created Integration
|
||||
end
|
||||
Picker->>Editor: Defaults + available connections
|
||||
User->>Editor: Review and save
|
||||
Editor->>Board: Add one complete item
|
||||
Board-->>User: Visible result + Undo
|
||||
```
|
||||
|
||||
## 4. Universal Create and contextual completion
|
||||
|
||||
**In one sentence:** Header, ordinary Spotlight search, Board actions, empty states, management pages, and Assistant prerequisite recovery now converge on the same native creation flows.
|
||||
|
||||
Universal Create ranks permission-aware actions for the current context:
|
||||
|
||||
- Add Widget, App, or Container to the current Board.
|
||||
- Connect a service.
|
||||
- Create or import a Board.
|
||||
- Open Workshop.
|
||||
- Create a Custom Widget.
|
||||
|
||||
The same action is visible in the header and ordinary Spotlight results, not only Spotlight command mode. Board actions inject context-specific callbacks, so the global surface can return to the active Board instead of routing away.
|
||||
|
||||
When a Widget or Assistant action lacks a required Integration, the user can create a compatible one inline and return to the pending Widget editor with that Integration selected. Integration creation returns the created Integration and linked App details, then opens a completion sheet with up to three capability-derived Widget recipes. Recommendations exclude Widget kinds already present, honor permission/access data, explain why they fit, and can be dismissed per Board and Integration kind.
|
||||
|
||||
Setup analytics use the existing opt-in gate but enforce a special anonymous allowlist for `setup:*` events: coarse entry point, intent, outcome, elapsed time, Board-context presence, and inline-resolution ability. Record IDs, URLs, queries, credentials, provider responses, and session user IDs are excluded.
|
||||
|
||||
## 5. Canonical feature platform and contributor experience
|
||||
|
||||
**In one sentence:** A contributor can now generate a complete feature slice and run one deterministic command that proves the registries, docs, translations, fixtures, tests, and package contracts agree.
|
||||
|
||||
`nativeFeatureCapabilities` is the dependency-light descriptor layer in `@homarr/definitions`. It derives forward and reverse Integration/Widget support, optional connections, and Integration limits while retaining existing public APIs during migration.
|
||||
|
||||
The repository now includes Turbo generators for:
|
||||
|
||||
- Native Integration.
|
||||
- Widget.
|
||||
- Paired Integration plus Widget.
|
||||
|
||||
Generation is deterministic and fail-fast. It creates implementation skeletons, exports and registry entries, English translations, docs pages and indexes, response fixtures, and focused tests. Existing targets are never overwritten.
|
||||
|
||||
The new gates are:
|
||||
|
||||
```text
|
||||
pnpm generate:feature
|
||||
pnpm check:feature-contracts
|
||||
pnpm check:feature <kind> --plan
|
||||
pnpm check:feature <kind>
|
||||
pnpm test:feature-platform
|
||||
```
|
||||
|
||||
`check:feature` resolves feature ownership, nearby tests, descriptor files, docs, translations, and package boundaries. It runs contracts, focused tests, affected package typechecks, affected-file lint, and affected-file formatting in fail-fast order. Live service, container/E2E, and visual proof are printed as explicit optional gates and are never silently simulated.
|
||||
|
||||
The transport-free Integration response contract includes accepted and rejected fixtures. A development-only Feature Workbench renders catalog interaction states and response states at `/manage/tools/feature-workbench` so contributors can inspect semantic, loading, success, and failure behavior without a live third-party service.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Intent["Feature kind"] --> Generator["Turbo generator"]
|
||||
Generator --> Descriptor["Capability descriptor"]
|
||||
Generator --> Runtime["Runtime skeleton"]
|
||||
Generator --> Tests["Fixtures + tests"]
|
||||
Generator --> Docs["Docs + translation"]
|
||||
Descriptor --> Gate["check:feature"]
|
||||
Runtime --> Gate
|
||||
Tests --> Gate
|
||||
Docs --> Gate
|
||||
Gate --> Workbench["Visual state workbench"]
|
||||
Gate --> CI["Fast CI contract gate"]
|
||||
```
|
||||
|
||||
## 6. Docker convergence
|
||||
|
||||
**In one sentence:** Docker is now deterministic across endpoints, truthful under partial failure, capability-aware, and able to explain how runtime services relate to Homarr records.
|
||||
|
||||
Every resource uses `{ endpointId, id }` from inventory through query keys, actions, logs, URLs, and results. Native container IDs are no longer resolved by scanning for the first match across engines.
|
||||
|
||||
Bulk lifecycle and removal mutations return a result for every target. Management and Widget surfaces report partial failures instead of treating a resolved request as universal success. Removal uses one shared confirmation contract on both surfaces.
|
||||
|
||||
The continuous reconciliation surface:
|
||||
|
||||
- Preserves multiple instances of the same Integration kind.
|
||||
- Keeps healthy endpoints visible when another is unavailable.
|
||||
- Distinguishes unavailable from empty.
|
||||
- Classifies new recognized services, App candidates, represented services, linked services, and moved addresses.
|
||||
- Ranks browser and server URL candidates with an explanation.
|
||||
- Avoids automatic adoption when matching is ambiguous.
|
||||
- Supports filters, refresh, local dismissal, and restore.
|
||||
|
||||
The Service health projection joins Docker availability, Integration configuration, App representation, and Widget attachment. Authentication, live API request success, and Widget query success are explicitly Not observed until Homarr has trustworthy evidence; absence of evidence is never shown as healthy.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Endpoint["Endpoint descriptor"] --> Inventory["Endpoint-qualified inventory"]
|
||||
Inventory --> Reconcile["Reconciliation read model"]
|
||||
Apps["Apps"] --> Reconcile
|
||||
Integrations["Integrations"] --> Reconcile
|
||||
Widgets["Widget links"] --> Reconcile
|
||||
Reconcile --> Inbox["Service setup inbox"]
|
||||
Reconcile --> Health["Layered health projection"]
|
||||
Inbox --> Native["Native App or Integration setup"]
|
||||
Health --> Recovery["One evidence-based next action"]
|
||||
```
|
||||
|
||||
## 7. Secure infrastructure descriptors
|
||||
|
||||
**In one sentence:** Docker and Podman endpoints can now declare stable identity, transport security, least-privilege capabilities, and friendly UI metadata without sending infrastructure credentials to the browser.
|
||||
|
||||
`DOCKER_ENDPOINTS` accepts a JSON array with:
|
||||
|
||||
- Stable ID and friendly name.
|
||||
- Docker or Podman kind.
|
||||
- Socket, explicitly insecure TCP, or TLS transport.
|
||||
- CA trust plus optional paired client certificate and key for mTLS.
|
||||
- Inventory, logs, lifecycle, and removal capabilities.
|
||||
- Server-owned admin scope and configuration source.
|
||||
|
||||
Paths must be absolute, IDs must be unique, inventory is mandatory, client certificate and key must be supplied together, and plaintext TCP is rejected unless `allowInsecure: true` is explicit. TLS material is read only on the server. UI actions are disabled when a capability is absent, and the API independently enforces the same policy.
|
||||
|
||||
Legacy socket and hostname/port variables remain compatible but are ignored when the structured descriptor is present. The Docker and environment-variable documentation now explains precedence and security tradeoffs.
|
||||
|
||||
## 8. Kubernetes resilience and multiple contexts
|
||||
|
||||
**In one sentence:** Kubernetes inventory remains useful without Metrics Server and every management request is isolated to an explicit, URL-persisted context.
|
||||
|
||||
The Kubernetes client registry creates a distinct client per kubeconfig context. All routers require `contextId`; all server and client calls propagate it; React Query cache keys therefore stay context-specific. Resource-tile navigation preserves the context query to avoid a default-cluster flash.
|
||||
|
||||
The context selector reports Available, Metrics unavailable, or Unavailable independently. An unreachable context does not hide healthy contexts. An empty kubeconfig `current-context` deterministically falls back to the first configured context. When `KUBECONFIG` is absent in production, the existing in-cluster service-account path remains the default.
|
||||
|
||||
Inventory and optional metrics are separated. Cluster and Node pages show counts and resource identity even when Metrics Server is absent, while percentages become unavailable rather than zero or a fatal page error.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Config["KUBECONFIG or in-cluster account"] --> Registry["Context client registry"]
|
||||
Registry --> A["Context A: available"]
|
||||
Registry --> B["Context B: metrics degraded"]
|
||||
Registry --> C["Context C: unavailable"]
|
||||
Selector["URL-persisted selector"] --> Registry
|
||||
A --> Inventory["Inventory + metrics"]
|
||||
B --> Degraded["Inventory + explicit metrics gap"]
|
||||
C --> Diagnostic["Visible endpoint diagnostic"]
|
||||
```
|
||||
|
||||
## 9. Traceability to the opportunity portfolio
|
||||
|
||||
**In one sentence:** Every P0/P1 platform item and every P2 systems/DX item received a behavior-complete implementation slice, while the intentionally deferred boundary is durable multi-record orchestration.
|
||||
|
||||
| Opportunity | Delivered behavior |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Truthful Docker results | Per-target results, partial-failure notifications, retry-ready failed identities |
|
||||
| Endpoint-scoped Docker identity | Endpoint ID in inventory, actions, logs, URLs, cache identity, and reconciliation |
|
||||
| Accessible catalog | Shared native-button contract, visible status, focus, keyboard, touch, and busy semantics |
|
||||
| Inline missing Integration | Nested compatible setup and return to Widget/Assistant context |
|
||||
| Universal Create | Header, plain Spotlight, Board context, empty states, and native creation routes |
|
||||
| Atomic Widget creation | Configure first, add one complete item, choose destination, Undo |
|
||||
| Completion recipes | Capability-derived, access-aware Widget suggestions after Integration creation |
|
||||
| Docker reconciliation | Refreshable inbox, ambiguity protection, URL reasoning, dismiss/restore |
|
||||
| Infrastructure descriptors | Socket/TCP/TLS/mTLS, stable names, scoped capabilities, server enforcement |
|
||||
| Capability platform | Canonical derived descriptor and parity tests |
|
||||
| Generators and contracts | Three generators, CI contract gate, focused feature command |
|
||||
| Service health | Evidence-based read projection across runtime/config/App/Widget layers |
|
||||
| Setup guidance | Actionable empty states and resumable state-derived checklist |
|
||||
| Protocol simulator | Transport-free accepted/rejected response fixtures |
|
||||
| Visual workbench | Catalog and response-state workbench for development |
|
||||
| Kubernetes degraded mode | Optional metrics, multi-context registry, URL selection, independent status |
|
||||
| Board recipes | Permission-aware recommendations that place exact compatible Widgets |
|
||||
|
||||
## 10. Current architecture and next attainable boundary
|
||||
|
||||
**In one sentence:** The UI and read models now converge, while the next architectural step is to persist a setup-session plan and commit cross-record recipes as one auditable transaction.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Delivered
|
||||
Entry["Header, Spotlight, Board, Docker, Assistant, Management"] --> Native["Shared native create flows"]
|
||||
Native --> Inline["Inline prerequisites"]
|
||||
Inline --> Recipe["Explainable completion recipes"]
|
||||
Recipe --> BoardPatch["Atomic Board item patch + Undo"]
|
||||
Descriptor["Capability descriptor"] --> Native
|
||||
Descriptor --> Recipe
|
||||
Descriptor --> DX["Generators + contracts + workbench"]
|
||||
Infra["Docker/Kubernetes endpoint identity"] --> Health["Reconciliation + health read models"]
|
||||
end
|
||||
|
||||
subgraph NextBoundary["Next durable boundary"]
|
||||
Session["Persisted setup session"] --> Preview["Cross-record change preview"]
|
||||
Preview --> Transaction["Integration + App + Search + Board transaction"]
|
||||
Transaction --> DurableUndo["Durable undo/audit result"]
|
||||
end
|
||||
|
||||
Native --> Session
|
||||
Health --> Session
|
||||
```
|
||||
|
||||
No schema-wide Service entity was added. This is deliberate: the current projection proves identity and workflow contracts before committing the project to a migration. Likewise, Docker reconciliation is refreshable and durable at the UI contract level, but no background watcher or automatic mutation was introduced.
|
||||
|
||||
## 11. Validation
|
||||
|
||||
**In one sentence:** The reconciled implementation passes the repository-wide type and format gates plus focused behavior, contract, system, and generator suites.
|
||||
|
||||
Completed locally:
|
||||
|
||||
- Frozen-lockfile dependency verification.
|
||||
- Full Turbo typecheck: 39 successful tasks.
|
||||
- Full Turbo formatting check: 39 successful tasks.
|
||||
- Affected-package oxlint with no errors; pre-existing warnings remain warnings.
|
||||
- 64 focused DOM tests across 17 files.
|
||||
- 43 focused API tests across Docker, Kubernetes, and anonymous setup analytics.
|
||||
- 8 focused Board and Integration creation tests.
|
||||
- 12 Docker descriptor/singleton tests.
|
||||
- 2 anonymous setup-analytics contract tests.
|
||||
- 7 feature-platform generator/planner contract tests.
|
||||
- `check:feature-contracts` passes.
|
||||
- `check:feature wud --plan` resolves the intended full feature slice.
|
||||
- Board static-dependency graph guard passes after keeping the Spotlight store behind its lazy boundary.
|
||||
- `git diff --check` passes.
|
||||
|
||||
The broad non-E2E suite was also attempted: 3,258 tests passed and 91 were skipped. It caught one change-related static-graph regression, which was fixed and re-tested. The remaining failures require facilities unavailable in this sandbox: a container runtime for Integration and database migration suites, loopback socket permission for Custom Widget network-policy suites, external HTTP access for Integration documentation checks, and the existing child-process bundle fixture. They do not overlap the focused green suites above.
|
||||
|
||||
Environment boundary: the local host runs Node 24.16 while the repository requires Node 24.18 or newer. TypeScript, lint, formatting, and focused tests pass with engine strictness disabled only for command execution. Exact-engine container and hosted CI remain the final release proof. No live Docker daemon, Kubernetes cluster, third-party Integration credentials, or authenticated browser preview was used in this implementation pass.
|
||||
|
||||
## 12. Final recommendation
|
||||
|
||||
**In one sentence:** Ship these slices behind the existing release-v2 review process, measure task completion and recovery, then build the persisted setup transaction only after the new capability and identity contracts have proven stable.
|
||||
|
||||
The experience is now substantially closer to the North Star: users can begin from intent, see readiness, resolve a prerequisite without losing context, place the result where it belongs, and understand failures by layer. Contributors can add features through one generated path and prove the contract locally. Operators get deterministic multi-endpoint behavior and explicit least privilege.
|
||||
|
||||
The visual language remains Homarr’s. The implementation improves recognition, continuity, safety, and contribution speed without flattening the interface or making deployment less compact.
|
||||
@@ -13,6 +13,8 @@
|
||||
"clean:workspaces": "turbo clean",
|
||||
"check:custom-widgets": "pnpm --filter @homarr/custom-widgets check:architecture",
|
||||
"check:custom-widget-bundle": "node ./scripts/check-custom-widget-bundle.mjs",
|
||||
"check:feature-contracts": "node --experimental-strip-types ./scripts/check-feature-contracts.mts",
|
||||
"check:feature": "node --experimental-strip-types ./scripts/check-feature.mts",
|
||||
"check:openapi": "tsx ./scripts/check-openapi-schema.mts",
|
||||
"ci:act:all": "bash scripts/ci/act.sh all",
|
||||
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
|
||||
@@ -39,6 +41,7 @@
|
||||
"docker:dev:up": "docker compose -f ./development/development.docker-compose.yml up -d redis",
|
||||
"format": "turbo format --continue -- --check",
|
||||
"format:fix": "turbo format --continue",
|
||||
"generate:feature": "turbo gen",
|
||||
"lint": "turbo lint --continue",
|
||||
"lint:fix": "turbo lint --continue -- --fix",
|
||||
"lint:ws": "pnpm dlx sherif@latest",
|
||||
@@ -48,6 +51,7 @@
|
||||
"start": "pnpm -F nextjs start",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --retry 2 --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2",
|
||||
"test:feature-platform": "node --experimental-strip-types ./scripts/feature-platform/feature-platform.contract.mts",
|
||||
"test:workshop": "sh apps/workshop/test.sh",
|
||||
"test:workshop-live-provider": "node apps/workshop/tests/openrouter-live-smoke.mjs",
|
||||
"test:workshop-image": "sh apps/workshop/test-image.sh",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getTrackedFeatureProperties } from "./analytics-properties";
|
||||
|
||||
describe("getTrackedFeatureProperties", () => {
|
||||
test("keeps setup telemetry anonymous and strips configuration-shaped properties", () => {
|
||||
expect(
|
||||
getTrackedFeatureProperties(
|
||||
"setup:widget-completed",
|
||||
{
|
||||
entryPoint: "board",
|
||||
outcome: "completed",
|
||||
elapsedMs: 1200,
|
||||
hasBoardContext: true,
|
||||
userId: "supplied-user",
|
||||
url: "https://private.example",
|
||||
recordId: "secret-id",
|
||||
},
|
||||
"session-user",
|
||||
),
|
||||
).toEqual({ entryPoint: "board", outcome: "completed", elapsedMs: 1200, hasBoardContext: true });
|
||||
});
|
||||
|
||||
test("preserves the existing identified contract for non-setup events", () => {
|
||||
expect(getTrackedFeatureProperties("theme-changed", { theme: "dark" }, "user-1")).toEqual({
|
||||
theme: "dark",
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const setupPropertyKeys = new Set([
|
||||
"entryPoint",
|
||||
"intent",
|
||||
"outcome",
|
||||
"elapsedMs",
|
||||
"hasBoardContext",
|
||||
"canResolveInline",
|
||||
]);
|
||||
|
||||
export const getTrackedFeatureProperties = (
|
||||
feature: string,
|
||||
properties: Record<string, unknown> | undefined,
|
||||
userId: string,
|
||||
) => {
|
||||
if (!feature.startsWith("setup:")) return { ...properties, userId };
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(properties ?? {}).filter(
|
||||
([key, value]) => setupPropertyKeys.has(key) && ["string", "number", "boolean"].includes(typeof value),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { getTrackedFeatureProperties } from "./analytics-properties";
|
||||
|
||||
const getInstanceId = async (): Promise<string | null> => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return null;
|
||||
@@ -34,10 +35,11 @@ export const analyticsRouter = createTRPCRouter({
|
||||
const instanceId = await getInstanceId();
|
||||
if (!instanceId) return;
|
||||
|
||||
trackEvent(instanceId, `feature:${input.feature}`, {
|
||||
...input.properties,
|
||||
userId: ctx.session.user.id,
|
||||
});
|
||||
trackEvent(
|
||||
instanceId,
|
||||
`feature:${input.feature}`,
|
||||
getTrackedFeatureProperties(input.feature, input.properties, ctx.session.user.id),
|
||||
);
|
||||
}),
|
||||
|
||||
heartbeat: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
|
||||
@@ -566,9 +566,11 @@ export const boardRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(boardCreateSchema)
|
||||
.output(z.object({ boardId: z.string() }))
|
||||
.output(z.object({ boardId: z.string(), name: z.string(), layoutId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const boardId = createId();
|
||||
const mobileLayoutId = createId();
|
||||
const baseLayoutId = createId();
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, ctx.session.user.id),
|
||||
@@ -594,7 +596,7 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
createBoardCollection.layouts.push(
|
||||
{
|
||||
id: createId(),
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
@@ -604,7 +606,7 @@ export const boardRouter = createTRPCRouter({
|
||||
boardId,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
id: baseLayoutId,
|
||||
name: "Base",
|
||||
columnCount: input.columnCount,
|
||||
leftGutterColumnCount: 0,
|
||||
@@ -621,7 +623,7 @@ export const boardRouter = createTRPCRouter({
|
||||
await ctx.db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, ctx.session.user.id));
|
||||
}
|
||||
|
||||
return { boardId };
|
||||
return { boardId, name: input.name, layoutId: baseLayoutId };
|
||||
}),
|
||||
duplicateBoard: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import {
|
||||
buildDockerServiceUrlCandidates,
|
||||
getIntegrationDefaultPort,
|
||||
matchDockerService,
|
||||
normalizeDockerServiceUrl,
|
||||
} from "@homarr/definitions";
|
||||
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
|
||||
|
||||
export type DockerReconciliationState = "newRecognized" | "newApp" | "represented" | "linked" | "moved";
|
||||
export type DockerReconciliationNextAction =
|
||||
| "setupIntegration"
|
||||
| "createApp"
|
||||
| "viewRepresentation"
|
||||
| "reviewIntegration";
|
||||
|
||||
export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
const [{ data, timestamp }, integrations, apps] = await Promise.all([
|
||||
dockerContainersRequestHandler.handler({}).getDataAsync(),
|
||||
db.query.integrations.findMany({
|
||||
with: {
|
||||
items: { columns: { itemId: true } },
|
||||
secrets: { columns: { kind: true } },
|
||||
},
|
||||
}),
|
||||
db.query.apps.findMany(),
|
||||
]);
|
||||
const containerNameCounts = new Map<string, number>();
|
||||
for (const container of data.containers) {
|
||||
const name = normalizeName(container.name);
|
||||
containerNameCounts.set(name, (containerNameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const candidates = data.containers.map((container) => {
|
||||
const match = matchDockerService(container);
|
||||
const urlCandidates = buildDockerServiceUrlCandidates({
|
||||
containerName: container.name,
|
||||
endpointHost: container.host,
|
||||
ports: container.ports,
|
||||
preferredPort: match ? getIntegrationDefaultPort(match.kind) : undefined,
|
||||
});
|
||||
const candidateUrls = new Set(
|
||||
urlCandidates.map(({ url }) => normalizeDockerServiceUrl(url)).filter((url) => url !== null),
|
||||
);
|
||||
const uniqueName = containerNameCounts.get(normalizeName(container.name)) === 1;
|
||||
const sameKindIntegrations = match ? integrations.filter(({ kind }) => kind === match.kind) : [];
|
||||
const exactIntegrationMatches = sameKindIntegrations.filter(({ url }) => {
|
||||
const normalizedUrl = normalizeDockerServiceUrl(url);
|
||||
return normalizedUrl ? candidateUrls.has(normalizedUrl) : false;
|
||||
});
|
||||
const nameIntegrationMatches = uniqueName
|
||||
? sameKindIntegrations.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
|
||||
: [];
|
||||
const exactIntegration = exactIntegrationMatches.length === 1 ? exactIntegrationMatches[0] : undefined;
|
||||
const nameIntegration = nameIntegrationMatches.length === 1 ? nameIntegrationMatches[0] : undefined;
|
||||
const integration = exactIntegration ?? nameIntegration;
|
||||
const exactAppMatches = apps.filter(({ href }) => {
|
||||
const normalizedUrl = normalizeDockerServiceUrl(href);
|
||||
return normalizedUrl ? candidateUrls.has(normalizedUrl) : false;
|
||||
});
|
||||
const nameAppMatches = uniqueName
|
||||
? apps.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
|
||||
: [];
|
||||
const exactApp = exactAppMatches.length === 1 ? exactAppMatches[0] : undefined;
|
||||
const nameApp = nameAppMatches.length === 1 ? nameAppMatches[0] : undefined;
|
||||
const linkedApp = integration?.appId ? apps.find(({ id }) => id === integration.appId) : undefined;
|
||||
const app = linkedApp ?? exactApp ?? nameApp;
|
||||
const exactUrl = Boolean(exactIntegration || exactApp);
|
||||
const linked = Boolean(integration?.appId && linkedApp);
|
||||
const state: DockerReconciliationState = linked
|
||||
? "linked"
|
||||
: integration && !exactUrl
|
||||
? "moved"
|
||||
: integration || app
|
||||
? "represented"
|
||||
: match
|
||||
? "newRecognized"
|
||||
: "newApp";
|
||||
|
||||
return {
|
||||
candidateKey: container.resourceId,
|
||||
endpointId: container.endpointId,
|
||||
endpointName: container.endpointName,
|
||||
nativeId: container.id,
|
||||
container,
|
||||
match,
|
||||
urlCandidates,
|
||||
state,
|
||||
nextAction: nextActionForState(state),
|
||||
representation: {
|
||||
integration: integration
|
||||
? {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
appId: integration.appId,
|
||||
itemCount: integration.items.length,
|
||||
configuredSecretKinds: integration.secrets.map(({ kind }) => kind),
|
||||
}
|
||||
: null,
|
||||
app: app ? { id: app.id, name: app.name, href: app.href } : null,
|
||||
signals: {
|
||||
exactUrl,
|
||||
nameMatch: Boolean(nameIntegration || nameApp),
|
||||
linked,
|
||||
ambiguous:
|
||||
exactIntegrationMatches.length > 1 ||
|
||||
nameIntegrationMatches.length > 1 ||
|
||||
exactAppMatches.length > 1 ||
|
||||
nameAppMatches.length > 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { candidates, endpoints: data.endpoints, timestamp };
|
||||
};
|
||||
|
||||
export const getDockerServiceHealthAsync = async (db: Database) => {
|
||||
const reconciliation = await getDockerReconciliationAsync(db);
|
||||
return {
|
||||
timestamp: reconciliation.timestamp,
|
||||
endpoints: reconciliation.endpoints.map((endpoint) => ({
|
||||
key: `endpoint:${endpoint.id}`,
|
||||
identity: { endpointId: endpoint.id, name: endpoint.name },
|
||||
layer: "docker" as const,
|
||||
status: endpoint.status,
|
||||
nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const),
|
||||
})),
|
||||
services: reconciliation.candidates.map((candidate) => ({
|
||||
key: candidate.candidateKey,
|
||||
identity: {
|
||||
endpointId: candidate.endpointId,
|
||||
nativeId: candidate.nativeId,
|
||||
name: candidate.container.name,
|
||||
integration: candidate.representation.integration,
|
||||
app: candidate.representation.app,
|
||||
},
|
||||
layers: [
|
||||
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
|
||||
{
|
||||
layer: "integrationConfiguration" as const,
|
||||
status: integrationHealthStatus(
|
||||
candidate.state,
|
||||
candidate.match?.kind,
|
||||
Boolean(candidate.representation.integration),
|
||||
),
|
||||
nextAction:
|
||||
candidate.state === "moved"
|
||||
? ("reviewIntegration" as const)
|
||||
: candidate.state === "newRecognized"
|
||||
? ("setupIntegration" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "authentication" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: candidate.representation.integration
|
||||
? ("testConnection" as const)
|
||||
: candidate.match
|
||||
? ("setupIntegration" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "apiRequest" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: candidate.representation.integration
|
||||
? ("openIntegrationDiagnostics" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "appRepresentation" as const,
|
||||
status: candidate.representation.app ? ("linked" as const) : ("missing" as const),
|
||||
nextAction: candidate.representation.app ? ("none" as const) : ("createApp" as const),
|
||||
},
|
||||
{
|
||||
layer: "widgetConfiguration" as const,
|
||||
status: candidate.representation.integration
|
||||
? candidate.representation.integration.itemCount > 0
|
||||
? ("linked" as const)
|
||||
: ("unused" as const)
|
||||
: ("notApplicable" as const),
|
||||
nextAction:
|
||||
candidate.representation.integration && candidate.representation.integration.itemCount === 0
|
||||
? ("addWidget" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "widgetQuery" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction:
|
||||
candidate.representation.integration && candidate.representation.integration.itemCount > 0
|
||||
? ("openBoard" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
],
|
||||
status: candidate.state,
|
||||
nextAction: candidate.nextAction,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeName = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => {
|
||||
if (state === "newRecognized") return "setupIntegration";
|
||||
if (state === "newApp") return "createApp";
|
||||
if (state === "moved") return "reviewIntegration";
|
||||
return "viewRepresentation";
|
||||
};
|
||||
|
||||
const integrationHealthStatus = (
|
||||
state: DockerReconciliationState,
|
||||
kind: IntegrationKind | undefined,
|
||||
hasIntegration: boolean,
|
||||
) => {
|
||||
if (!kind) return "notApplicable" as const;
|
||||
if (state === "moved") return "changed" as const;
|
||||
return hasIntegration ? ("configured" as const) : ("missing" as const);
|
||||
};
|
||||
@@ -2,19 +2,33 @@ import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
|
||||
import { DockerSingleton } from "@homarr/docker";
|
||||
import type { ContainerState, DockerContainerTarget, Port } from "@homarr/docker";
|
||||
import {
|
||||
dockerContainersRequestHandler,
|
||||
findDockerContainerAsync,
|
||||
getContainerLogsAsync,
|
||||
hasDockerEndpointCapability,
|
||||
streamContainerLogsAsync,
|
||||
} from "@homarr/request-handler/docker";
|
||||
|
||||
import { dockerMiddleware } from "../../middlewares/docker";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
import { throwIfDockerOperationsFailed } from "./docker-operation-results";
|
||||
import { getDockerReconciliationAsync, getDockerServiceHealthAsync } from "./docker-reconciliation";
|
||||
|
||||
const dockerContainerTargetSchema = z.object({
|
||||
endpointId: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export const dockerRouter = createTRPCRouter({
|
||||
reconcileServices: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => await getDockerReconciliationAsync(ctx.db)),
|
||||
getServiceHealth: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => await getDockerServiceHealthAsync(ctx.db)),
|
||||
getContainers: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
@@ -31,7 +45,8 @@ export const dockerRouter = createTRPCRouter({
|
||||
const { data, timestamp } = result;
|
||||
|
||||
return {
|
||||
containers: data satisfies DockerContainer[],
|
||||
containers: data.containers satisfies DockerContainer[],
|
||||
endpoints: data.endpoints,
|
||||
timestamp,
|
||||
};
|
||||
}),
|
||||
@@ -40,58 +55,39 @@ export const dockerRouter = createTRPCRouter({
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Start Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
description: "Start Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.start();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "start");
|
||||
}),
|
||||
stopAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Stop Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
description: "Stop Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.stop();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "stop");
|
||||
}),
|
||||
restartAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Restart Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
description: "Restart Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.restart();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "restart");
|
||||
}),
|
||||
removeAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -99,19 +95,13 @@ export const dockerRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Remove/delete Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
"Remove/delete Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.remove();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "remove");
|
||||
}),
|
||||
logs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -119,18 +109,19 @@ export const dockerRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Fetch logs from a Docker container. REQUIRED: id (container ID from docker_getContainers). OPTIONAL: tail (number 1-1000, default 200)",
|
||||
"Fetch logs from a Docker container. REQUIRED: endpointId and id from docker_getContainers. OPTIONAL: tail (number 1-1000, default 200)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
...dockerContainerTargetSchema.shape,
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const logs = await getContainerLogsAsync(input.id, input.tail ?? 200);
|
||||
assertDockerEndpointCapability(input.endpointId, "logs");
|
||||
const logs = await getContainerLogsAsync(input, input.tail ?? 200);
|
||||
|
||||
if (!logs) {
|
||||
throw new TRPCError({
|
||||
@@ -148,11 +139,12 @@ export const dockerRouter = createTRPCRouter({
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
...dockerContainerTargetSchema.shape,
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.subscription(({ input }) => {
|
||||
assertDockerEndpointCapability(input.endpointId, "logs");
|
||||
return observable<string>((emit) => {
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
let isSubscribed = true;
|
||||
@@ -160,7 +152,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
const initializeStreamAsync = async () => {
|
||||
try {
|
||||
cleanupFn = await streamContainerLogsAsync(
|
||||
input.id,
|
||||
input,
|
||||
input.tail ?? 200,
|
||||
(data) => {
|
||||
if (isSubscribed) {
|
||||
@@ -200,36 +192,49 @@ export const dockerRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
|
||||
const container = instance.getContainer(id);
|
||||
type DockerContainerAction = "start" | "stop" | "restart" | "remove";
|
||||
|
||||
return await new Promise<Container | null>((resolve) => {
|
||||
container.inspect((err, data) => {
|
||||
if (err || !data) {
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(container);
|
||||
export const performDockerContainerActionsAsync = async (
|
||||
targets: DockerContainerTarget[],
|
||||
action: DockerContainerAction,
|
||||
) =>
|
||||
await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const capability = action === "remove" ? "remove" : "lifecycle";
|
||||
if (!hasDockerEndpointCapability(target.endpointId, capability)) {
|
||||
return { target, success: false as const, error: `Endpoint does not permit ${capability} actions` };
|
||||
}
|
||||
const container = await findDockerContainerAsync(target, capability);
|
||||
if (!container) return { target, success: false as const, error: "Container not found" };
|
||||
|
||||
if (action === "start") await container.start();
|
||||
else if (action === "stop") await container.stop();
|
||||
else if (action === "restart") await container.restart();
|
||||
else await container.remove();
|
||||
return { target, success: true as const };
|
||||
} catch (error) {
|
||||
return {
|
||||
target,
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Docker action failed",
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const getContainerOrThrowAsync = async (id: string) => {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const containers = await Promise.all(dockerInstances.map(({ instance }) => getContainerOrDefaultAsync(instance, id)));
|
||||
const foundContainer = containers.find((container) => container) ?? null;
|
||||
|
||||
if (!foundContainer) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Container not found",
|
||||
});
|
||||
}
|
||||
|
||||
return foundContainer;
|
||||
const assertDockerEndpointCapability = (
|
||||
endpointId: string,
|
||||
capability: "inventory" | "logs" | "lifecycle" | "remove",
|
||||
) => {
|
||||
if (hasDockerEndpointCapability(endpointId, capability)) return;
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: `Docker endpoint does not permit ${capability} access` });
|
||||
};
|
||||
|
||||
interface DockerContainer {
|
||||
endpointId: string;
|
||||
endpointName: string;
|
||||
resourceId: string;
|
||||
name: string;
|
||||
id: string;
|
||||
host: string;
|
||||
|
||||
@@ -368,6 +368,16 @@ export const integrationRouter = createTRPCRouter({
|
||||
kind: input.kind,
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
url: input.url,
|
||||
},
|
||||
appId,
|
||||
};
|
||||
}),
|
||||
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { KubeConfig } from "@kubernetes/client-node";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { KubernetesClientRegistry, KubernetesContextNotFoundError } from "./kubernetes-client";
|
||||
|
||||
const createRegistry = () => {
|
||||
const kubeConfig = new KubeConfig();
|
||||
const contextNames = ["home", "remote", "metrics-degraded"];
|
||||
kubeConfig.loadFromOptions({
|
||||
clusters: contextNames.map((name) => ({ name, server: `https://${name}.example` })),
|
||||
users: contextNames.map((name) => ({ name, token: `${name}-token` })),
|
||||
contexts: contextNames.map((name) => ({ name, cluster: name, user: name })),
|
||||
currentContext: "home",
|
||||
});
|
||||
return new KubernetesClientRegistry(kubeConfig);
|
||||
};
|
||||
|
||||
describe("KubernetesClientRegistry", () => {
|
||||
test("isolates duplicate resource names by context", async () => {
|
||||
const registry = createRegistry();
|
||||
const home = registry.getClient("home");
|
||||
const remote = registry.getClient("remote");
|
||||
vi.spyOn(home.coreApi, "listNamespace").mockResolvedValue({ items: [{ metadata: { name: "default" } }] } as never);
|
||||
vi.spyOn(remote.coreApi, "listNamespace").mockResolvedValue({
|
||||
items: [{ metadata: { name: "default" } }],
|
||||
} as never);
|
||||
|
||||
const [homeNamespaces, remoteNamespaces] = await Promise.all([
|
||||
home.coreApi.listNamespace(),
|
||||
remote.coreApi.listNamespace(),
|
||||
]);
|
||||
|
||||
expect(homeNamespaces.items[0]?.metadata?.name).toBe("default");
|
||||
expect(remoteNamespaces.items[0]?.metadata?.name).toBe("default");
|
||||
expect(home).not.toBe(remote);
|
||||
expect(home.kubeConfig.getCurrentContext()).toBe("home");
|
||||
expect(remote.kubeConfig.getCurrentContext()).toBe("remote");
|
||||
});
|
||||
|
||||
test("lists unavailable and metrics-degraded contexts without failing healthy contexts", async () => {
|
||||
const registry = createRegistry();
|
||||
for (const contextId of ["home", "remote", "metrics-degraded"]) {
|
||||
const client = registry.getClient(contextId);
|
||||
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
|
||||
vi.spyOn(client.metricsApi, "getNodeMetrics").mockResolvedValue({ items: [] } as never);
|
||||
}
|
||||
registry.getClient("remote").versionApi.getCode = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
registry.getClient("metrics-degraded").metricsApi.getNodeMetrics = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("no metrics"));
|
||||
|
||||
const result = await registry.getContextsAsync();
|
||||
|
||||
expect(result.defaultContextId).toBe("home");
|
||||
expect(result.contexts).toEqual([
|
||||
{ contextId: "home", name: "home", status: "available", metricsStatus: "available", isDefault: true },
|
||||
{ contextId: "remote", name: "remote", status: "unavailable", metricsStatus: "unknown", isDefault: false },
|
||||
{
|
||||
contextId: "metrics-degraded",
|
||||
name: "metrics-degraded",
|
||||
status: "degraded",
|
||||
metricsStatus: "unavailable",
|
||||
isDefault: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects unknown context IDs", () => {
|
||||
expect(() => createRegistry().getClient("missing")).toThrow(KubernetesContextNotFoundError);
|
||||
});
|
||||
|
||||
test("uses the first configured context when current-context is empty", async () => {
|
||||
const kubeConfig = new KubeConfig();
|
||||
kubeConfig.loadFromOptions({
|
||||
clusters: [{ name: "first", server: "https://first.example" }],
|
||||
users: [{ name: "first", token: "token" }],
|
||||
contexts: [{ name: "first", cluster: "first", user: "first" }],
|
||||
currentContext: "",
|
||||
});
|
||||
const registry = new KubernetesClientRegistry(kubeConfig);
|
||||
const client = registry.getClient("first");
|
||||
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
|
||||
vi.spyOn(client.metricsApi, "getNodeMetrics").mockResolvedValue({ items: [] } as never);
|
||||
|
||||
const result = await registry.getContextsAsync();
|
||||
|
||||
expect(result.defaultContextId).toBe("first");
|
||||
expect(result.contexts[0]).toEqual(expect.objectContaining({ contextId: "first", isDefault: true }));
|
||||
});
|
||||
});
|
||||
@@ -3,69 +3,132 @@ import { CoreV1Api, KubeConfig, Metrics, NetworkingV1Api, VersionApi } from "@ku
|
||||
|
||||
import { env } from "../../env";
|
||||
|
||||
export interface KubernetesContextStatus {
|
||||
contextId: string;
|
||||
name: string;
|
||||
status: "available" | "degraded" | "unavailable";
|
||||
metricsStatus: "available" | "unavailable" | "unknown";
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export class KubernetesClient {
|
||||
private static instance: KubernetesClient | null = null;
|
||||
private static registry: KubernetesClientRegistry | null = null;
|
||||
public kubeConfig: KubeConfig;
|
||||
public coreApi: CoreV1Api;
|
||||
public networkingApi: NetworkingV1Api;
|
||||
public metricsApi: Metrics;
|
||||
public versionApi: VersionApi;
|
||||
|
||||
private constructor() {
|
||||
this.kubeConfig = new KubeConfig();
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
this.kubeConfig.loadFromDefault();
|
||||
} else {
|
||||
this.kubeConfig.loadFromCluster();
|
||||
|
||||
const currentCluster = this.kubeConfig.getCurrentCluster();
|
||||
if (!currentCluster) throw new Error("No cluster configuration found");
|
||||
|
||||
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
|
||||
const caData = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "utf8");
|
||||
|
||||
const clusterWithCA = {
|
||||
...currentCluster,
|
||||
name: `${currentCluster.name}-service-account`,
|
||||
caData,
|
||||
};
|
||||
|
||||
const serviceAccountUser = {
|
||||
name: env.KUBERNETES_SERVICE_ACCOUNT_NAME ?? "default-sa",
|
||||
token,
|
||||
};
|
||||
|
||||
this.kubeConfig.clusters = [];
|
||||
this.kubeConfig.users = [];
|
||||
|
||||
this.kubeConfig.addCluster(clusterWithCA);
|
||||
this.kubeConfig.addUser(serviceAccountUser);
|
||||
|
||||
const currentContext = this.kubeConfig.getCurrentContext();
|
||||
const originalContext = this.kubeConfig.getContextObject(currentContext);
|
||||
if (!originalContext) throw new Error("No context found");
|
||||
|
||||
const updatedContext = {
|
||||
...originalContext,
|
||||
name: `${originalContext.name}-service-account`,
|
||||
cluster: clusterWithCA.name,
|
||||
user: serviceAccountUser.name,
|
||||
};
|
||||
|
||||
this.kubeConfig.contexts = [];
|
||||
this.kubeConfig.addContext(updatedContext);
|
||||
this.kubeConfig.setCurrentContext(updatedContext.name);
|
||||
}
|
||||
|
||||
this.coreApi = this.kubeConfig.makeApiClient(CoreV1Api);
|
||||
this.networkingApi = this.kubeConfig.makeApiClient(NetworkingV1Api);
|
||||
this.metricsApi = new Metrics(this.kubeConfig);
|
||||
this.versionApi = this.kubeConfig.makeApiClient(VersionApi);
|
||||
public constructor(kubeConfig: KubeConfig) {
|
||||
this.kubeConfig = kubeConfig;
|
||||
this.coreApi = kubeConfig.makeApiClient(CoreV1Api);
|
||||
this.networkingApi = kubeConfig.makeApiClient(NetworkingV1Api);
|
||||
this.metricsApi = new Metrics(kubeConfig);
|
||||
this.versionApi = kubeConfig.makeApiClient(VersionApi);
|
||||
}
|
||||
|
||||
public static getInstance(): KubernetesClient {
|
||||
KubernetesClient.instance ??= new KubernetesClient();
|
||||
return KubernetesClient.instance;
|
||||
public static getInstance(contextId: string): KubernetesClient {
|
||||
return KubernetesClient.getRegistry().getClient(contextId);
|
||||
}
|
||||
|
||||
public static async getContextsAsync() {
|
||||
return await KubernetesClient.getRegistry().getContextsAsync();
|
||||
}
|
||||
|
||||
private static getRegistry() {
|
||||
KubernetesClient.registry ??= new KubernetesClientRegistry(loadKubeConfig());
|
||||
return KubernetesClient.registry;
|
||||
}
|
||||
}
|
||||
|
||||
export class KubernetesClientRegistry {
|
||||
private readonly clients = new Map<string, KubernetesClient>();
|
||||
private readonly defaultContextId: string;
|
||||
|
||||
public constructor(kubeConfig: KubeConfig) {
|
||||
const defaultContextId = kubeConfig.getCurrentContext() || kubeConfig.contexts[0]?.name;
|
||||
if (!defaultContextId) throw new Error("No Kubernetes context is configured");
|
||||
this.defaultContextId = defaultContextId;
|
||||
|
||||
for (const context of kubeConfig.contexts) {
|
||||
const contextConfig = new KubeConfig();
|
||||
contextConfig.loadFromOptions({
|
||||
clusters: kubeConfig.clusters,
|
||||
users: kubeConfig.users,
|
||||
contexts: kubeConfig.contexts,
|
||||
currentContext: context.name,
|
||||
});
|
||||
this.clients.set(context.name, new KubernetesClient(contextConfig));
|
||||
}
|
||||
}
|
||||
|
||||
public getClient(contextId: string) {
|
||||
const client = this.clients.get(contextId);
|
||||
if (!client) throw new KubernetesContextNotFoundError(contextId);
|
||||
return client;
|
||||
}
|
||||
|
||||
public async getContextsAsync() {
|
||||
const contexts = await Promise.all(
|
||||
[...this.clients.entries()].map(async ([contextId, client]): Promise<KubernetesContextStatus> => {
|
||||
const [api, metrics] = await Promise.allSettled([
|
||||
client.versionApi.getCode(),
|
||||
client.metricsApi.getNodeMetrics(),
|
||||
]);
|
||||
const status =
|
||||
api.status === "rejected" ? "unavailable" : metrics.status === "rejected" ? "degraded" : "available";
|
||||
|
||||
return {
|
||||
contextId,
|
||||
name: contextId,
|
||||
status,
|
||||
metricsStatus:
|
||||
api.status === "rejected" ? "unknown" : metrics.status === "rejected" ? "unavailable" : "available",
|
||||
isDefault: contextId === this.defaultContextId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { contexts, defaultContextId: this.defaultContextId };
|
||||
}
|
||||
}
|
||||
|
||||
export class KubernetesContextNotFoundError extends Error {
|
||||
public constructor(contextId: string) {
|
||||
super(`Kubernetes context '${contextId}' was not found`);
|
||||
}
|
||||
}
|
||||
|
||||
const loadKubeConfig = () => {
|
||||
const kubeConfig = new KubeConfig();
|
||||
|
||||
if (process.env.NODE_ENV === "development" || process.env.KUBECONFIG) {
|
||||
kubeConfig.loadFromDefault();
|
||||
return kubeConfig;
|
||||
}
|
||||
|
||||
kubeConfig.loadFromCluster();
|
||||
const currentCluster = kubeConfig.getCurrentCluster();
|
||||
if (!currentCluster) throw new Error("No cluster configuration found");
|
||||
|
||||
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
|
||||
const caData = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "utf8");
|
||||
const clusterWithCA = { ...currentCluster, name: `${currentCluster.name}-service-account`, caData };
|
||||
const serviceAccountUser = { name: env.KUBERNETES_SERVICE_ACCOUNT_NAME ?? "default-sa", token };
|
||||
const currentContext = kubeConfig.getContextObject(kubeConfig.getCurrentContext());
|
||||
if (!currentContext) throw new Error("No context found");
|
||||
const updatedContext = {
|
||||
...currentContext,
|
||||
name: `${currentContext.name}-service-account`,
|
||||
cluster: clusterWithCA.name,
|
||||
user: serviceAccountUser.name,
|
||||
};
|
||||
|
||||
kubeConfig.loadFromOptions({
|
||||
clusters: [clusterWithCA],
|
||||
users: [serviceAccountUser],
|
||||
contexts: [updatedContext],
|
||||
currentContext: updatedContext.name,
|
||||
});
|
||||
return kubeConfig;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { KubernetesClient, KubernetesContextNotFoundError } from "./kubernetes-client";
|
||||
|
||||
export const kubernetesContextInput = z.object({ contextId: z.string().min(1) });
|
||||
|
||||
export const getKubernetesClient = (contextId: string) => {
|
||||
try {
|
||||
return KubernetesClient.getInstance(contextId);
|
||||
} catch (error) {
|
||||
if (error instanceof KubernetesContextNotFoundError) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { calculateResourcePercentage } from "./resource-percentage";
|
||||
|
||||
describe("calculateResourcePercentage", () => {
|
||||
test("calculates a rounded percentage", () => {
|
||||
expect(calculateResourcePercentage(1, 3)).toBe(33.33);
|
||||
});
|
||||
|
||||
test("returns zero when no capacity is available", () => {
|
||||
expect(calculateResourcePercentage(1, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const calculateResourcePercentage = (used: number, available: number) =>
|
||||
available > 0 ? Number(((used / available) * 100).toFixed(2)) : 0;
|
||||
@@ -5,7 +5,8 @@ import type { ClusterResourceCount, KubernetesCluster } from "@homarr/definition
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
import { calculateResourcePercentage } from "../resource-percentage";
|
||||
import { CpuResourceParser } from "../resource-parser/cpu-resource-parser";
|
||||
import { MemoryResourceParser } from "../resource-parser/memory-resource-parser";
|
||||
|
||||
@@ -13,14 +14,17 @@ export const clusterRouter = createTRPCRouter({
|
||||
getCluster: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesCluster> => {
|
||||
const { coreApi, metricsApi, versionApi, kubeConfig } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesCluster> => {
|
||||
const { coreApi, metricsApi, versionApi, kubeConfig } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const versionInfo = await versionApi.getCode();
|
||||
const nodes = await coreApi.listNode();
|
||||
const nodeMetricsClient = await metricsApi.getNodeMetrics();
|
||||
const listPodForAllNamespaces = await coreApi.listPodForAllNamespaces();
|
||||
const [versionInfo, nodes, listPodForAllNamespaces, nodeMetricsClient] = await Promise.all([
|
||||
versionApi.getCode(),
|
||||
coreApi.listNode(),
|
||||
coreApi.listPodForAllNamespaces(),
|
||||
metricsApi.getNodeMetrics().catch(() => null),
|
||||
]);
|
||||
|
||||
let totalCPUCapacity = 0;
|
||||
let totalCPUAllocatable = 0;
|
||||
@@ -48,7 +52,7 @@ export const clusterRouter = createTRPCRouter({
|
||||
totalMemoryAllocatable += memoryAllocatable;
|
||||
|
||||
const nodeName = node.metadata?.name;
|
||||
const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === nodeName);
|
||||
const nodeMetric = nodeMetricsClient?.items.find((metric) => metric.metadata.name === nodeName);
|
||||
if (nodeMetric) {
|
||||
const cpuUsage = cpuResourceParser.parse(nodeMetric.usage.cpu);
|
||||
totalCPUUsage += cpuUsage;
|
||||
@@ -61,13 +65,11 @@ export const clusterRouter = createTRPCRouter({
|
||||
const reservedCPU = totalCPUCapacity - totalCPUAllocatable;
|
||||
const reservedMemory = totalMemoryCapacity - totalMemoryAllocatable;
|
||||
|
||||
const reservedCPUPercentage = (reservedCPU / totalCPUCapacity) * 100;
|
||||
const reservedMemoryPercentage = (reservedMemory / totalMemoryCapacity) * 100;
|
||||
|
||||
const usagePercentageAllocatable = (totalCPUUsage / totalCPUAllocatable) * 100;
|
||||
const usagePercentageMemoryAllocatable = (totalMemoryUsage / totalMemoryAllocatable) * 100;
|
||||
|
||||
const usedPodsPercentage = (listPodForAllNamespaces.items.length / totalCapacityPods) * 100;
|
||||
const reservedCPUPercentage = calculateResourcePercentage(reservedCPU, totalCPUCapacity);
|
||||
const reservedMemoryPercentage = calculateResourcePercentage(reservedMemory, totalMemoryCapacity);
|
||||
const usagePercentageAllocatable = calculateResourcePercentage(totalCPUUsage, totalCPUAllocatable);
|
||||
const usagePercentageMemoryAllocatable = calculateResourcePercentage(totalMemoryUsage, totalMemoryAllocatable);
|
||||
const usedPodsPercentage = calculateResourcePercentage(listPodForAllNamespaces.items.length, totalCapacityPods);
|
||||
|
||||
return {
|
||||
name: kubeConfig.getCurrentContext(),
|
||||
@@ -75,50 +77,59 @@ export const clusterRouter = createTRPCRouter({
|
||||
kubernetesVersion: versionInfo.gitVersion,
|
||||
architecture: versionInfo.platform,
|
||||
nodeCount: nodes.items.length,
|
||||
metricsAvailable: nodeMetricsClient !== null,
|
||||
capacity: [
|
||||
{
|
||||
type: "CPU",
|
||||
resourcesStats: [
|
||||
{
|
||||
percentageValue: Number(reservedCPUPercentage.toFixed(2)),
|
||||
percentageValue: reservedCPUPercentage,
|
||||
type: "Reserved",
|
||||
capacityUnit: "Cores",
|
||||
usedValue: Number(reservedCPU.toFixed(2)),
|
||||
maxUsedValue: Number(totalCPUCapacity.toFixed(2)),
|
||||
},
|
||||
{
|
||||
percentageValue: Number(usagePercentageAllocatable.toFixed(2)),
|
||||
type: "Used",
|
||||
capacityUnit: "Cores",
|
||||
usedValue: Number(totalCPUUsage.toFixed(2)),
|
||||
maxUsedValue: Number(totalCPUAllocatable.toFixed(2)),
|
||||
},
|
||||
...(nodeMetricsClient
|
||||
? [
|
||||
{
|
||||
percentageValue: usagePercentageAllocatable,
|
||||
type: "Used" as const,
|
||||
capacityUnit: "Cores",
|
||||
usedValue: Number(totalCPUUsage.toFixed(2)),
|
||||
maxUsedValue: Number(totalCPUAllocatable.toFixed(2)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Memory",
|
||||
resourcesStats: [
|
||||
{
|
||||
percentageValue: Number(reservedMemoryPercentage.toFixed(2)),
|
||||
percentageValue: reservedMemoryPercentage,
|
||||
type: "Reserved",
|
||||
capacityUnit: "GiB",
|
||||
usedValue: Number(reservedMemory.toFixed(2)),
|
||||
maxUsedValue: Number(totalMemoryCapacity.toFixed(2)),
|
||||
},
|
||||
{
|
||||
percentageValue: Number(usagePercentageMemoryAllocatable.toFixed(2)),
|
||||
type: "Used",
|
||||
capacityUnit: "GiB",
|
||||
usedValue: Number(totalMemoryUsage.toFixed(2)),
|
||||
maxUsedValue: Number(totalMemoryAllocatable.toFixed(2)),
|
||||
},
|
||||
...(nodeMetricsClient
|
||||
? [
|
||||
{
|
||||
percentageValue: usagePercentageMemoryAllocatable,
|
||||
type: "Used" as const,
|
||||
capacityUnit: "GiB",
|
||||
usedValue: Number(totalMemoryUsage.toFixed(2)),
|
||||
maxUsedValue: Number(totalMemoryAllocatable.toFixed(2)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Pods",
|
||||
resourcesStats: [
|
||||
{
|
||||
percentageValue: Number(usedPodsPercentage.toFixed(2)),
|
||||
percentageValue: usedPodsPercentage,
|
||||
type: "Used",
|
||||
usedValue: listPodForAllNamespaces.items.length,
|
||||
maxUsedValue: totalCapacityPods,
|
||||
@@ -137,8 +148,10 @@ export const clusterRouter = createTRPCRouter({
|
||||
}),
|
||||
getClusterResourceCounts: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.query(async (): Promise<ClusterResourceCount[]> => {
|
||||
const { coreApi, networkingApi } = KubernetesClient.getInstance();
|
||||
.concat(kubernetesMiddleware())
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<ClusterResourceCount[]> => {
|
||||
const { coreApi, networkingApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const [pods, ingresses, services, configMaps, namespaces, nodes, secrets, volumes] = await Promise.all([
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { KubernetesBaseResource } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const configMapsRouter = createTRPCRouter({
|
||||
getConfigMaps: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesBaseResource[]> => {
|
||||
const { coreApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesBaseResource[]> => {
|
||||
const { coreApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const configMaps = await coreApi.listConfigMapForAllNamespaces();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
|
||||
export const contextsRouter = createTRPCRouter({
|
||||
getContexts: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async () => await KubernetesClient.getContextsAsync()),
|
||||
});
|
||||
@@ -5,14 +5,15 @@ import type { KubernetesIngress, KubernetesIngressPath, KubernetesIngressRuleAnd
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const ingressesRouter = createTRPCRouter({
|
||||
getIngresses: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesIngress[]> => {
|
||||
const { networkingApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesIngress[]> => {
|
||||
const { networkingApi } = getKubernetesClient(input.contextId);
|
||||
try {
|
||||
const ingresses = await networkingApi.listIngressForAllNamespaces();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
|
||||
import { createTRPCRouter } from "../../../trpc";
|
||||
|
||||
export const kubernetesRouter = createTRPCRouter({
|
||||
contexts: lazy(() => import("./contexts").then((mod) => mod.contextsRouter)),
|
||||
nodes: lazy(() => import("./nodes").then((mod) => mod.nodesRouter)),
|
||||
cluster: lazy(() => import("./cluster").then((mod) => mod.clusterRouter)),
|
||||
namespaces: lazy(() => import("./namespaces").then((mod) => mod.namespacesRouter)),
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { KubernetesNamespace, KubernetesNamespaceState } from "@homarr/defi
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const namespacesRouter = createTRPCRouter({
|
||||
getNamespaces: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesNamespace[]> => {
|
||||
const { coreApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesNamespace[]> => {
|
||||
const { coreApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const namespaces = await coreApi.listNamespace();
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { KubernetesNode, KubernetesNodeState } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
import { calculateResourcePercentage } from "../resource-percentage";
|
||||
import { CpuResourceParser } from "../resource-parser/cpu-resource-parser";
|
||||
import { MemoryResourceParser } from "../resource-parser/memory-resource-parser";
|
||||
|
||||
@@ -12,12 +13,15 @@ export const nodesRouter = createTRPCRouter({
|
||||
getNodes: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesNode[]> => {
|
||||
const { coreApi, metricsApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesNode[]> => {
|
||||
const { coreApi, metricsApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const nodes = await coreApi.listNode();
|
||||
const nodeMetricsClient = await metricsApi.getNodeMetrics();
|
||||
const [nodes, nodeMetricsClient] = await Promise.all([
|
||||
coreApi.listNode(),
|
||||
metricsApi.getNodeMetrics().catch(() => null),
|
||||
]);
|
||||
const cpuResourceParser = new CpuResourceParser();
|
||||
const memoryResourceParser = new MemoryResourceParser();
|
||||
|
||||
@@ -34,20 +38,26 @@ export const nodesRouter = createTRPCRouter({
|
||||
let cpuUsage = 0;
|
||||
let memoryUsage = 0;
|
||||
|
||||
const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === name);
|
||||
const nodeMetric = nodeMetricsClient?.items.find((metric) => metric.metadata.name === name);
|
||||
if (nodeMetric) {
|
||||
cpuUsage += cpuResourceParser.parse(nodeMetric.usage.cpu);
|
||||
memoryUsage += memoryResourceParser.parse(nodeMetric.usage.memory);
|
||||
}
|
||||
|
||||
const usagePercentageCPUAllocatable = (cpuUsage / cpuAllocatable) * 100;
|
||||
const usagePercentageMemoryAllocatable = (memoryUsage / memoryAllocatable) * 100;
|
||||
const usagePercentageCPUAllocatable = nodeMetric
|
||||
? calculateResourcePercentage(cpuUsage, cpuAllocatable)
|
||||
: null;
|
||||
const usagePercentageMemoryAllocatable = nodeMetric
|
||||
? calculateResourcePercentage(memoryUsage, memoryAllocatable)
|
||||
: null;
|
||||
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
allocatableCpuPercentage: Number(usagePercentageCPUAllocatable.toFixed(0)),
|
||||
allocatableRamPercentage: Number(usagePercentageMemoryAllocatable.toFixed(0)),
|
||||
allocatableCpuPercentage:
|
||||
usagePercentageCPUAllocatable === null ? null : Math.round(usagePercentageCPUAllocatable),
|
||||
allocatableRamPercentage:
|
||||
usagePercentageMemoryAllocatable === null ? null : Math.round(usagePercentageMemoryAllocatable),
|
||||
podsCount: Number(node.status?.capacity?.pods),
|
||||
operatingSystem: node.status?.nodeInfo?.operatingSystem,
|
||||
architecture: node.status?.nodeInfo?.architecture,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { KubernetesPod } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
const logger = createLogger({ module: "podsRouter" });
|
||||
|
||||
@@ -15,8 +15,9 @@ export const podsRouter = createTRPCRouter({
|
||||
getPods: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesPod[]> => {
|
||||
const { coreApi, kubeConfig } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesPod[]> => {
|
||||
const { coreApi, kubeConfig } = getKubernetesClient(input.contextId);
|
||||
try {
|
||||
const podsResp = await coreApi.listPodForAllNamespaces();
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { KubernetesSecret } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const secretsRouter = createTRPCRouter({
|
||||
getSecrets: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesSecret[]> => {
|
||||
const { coreApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesSecret[]> => {
|
||||
const { coreApi } = getKubernetesClient(input.contextId);
|
||||
try {
|
||||
const secrets = await coreApi.listSecretForAllNamespaces();
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { KubernetesService } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const servicesRouter = createTRPCRouter({
|
||||
getServices: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesService[]> => {
|
||||
const { coreApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesService[]> => {
|
||||
const { coreApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const services = await coreApi.listServiceForAllNamespaces();
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { KubernetesVolume } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const volumesRouter = createTRPCRouter({
|
||||
getVolumes: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesVolume[]> => {
|
||||
const { coreApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesVolume[]> => {
|
||||
const { coreApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const volumes = await coreApi.listPersistentVolumeClaimForAllNamespaces();
|
||||
|
||||
@@ -636,7 +636,7 @@ describe("createBoard should create a new board", () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
|
||||
const result = await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
|
||||
|
||||
// Assert
|
||||
const dbBoard = await db.query.boards.findFirst({
|
||||
@@ -663,6 +663,11 @@ describe("createBoard should create a new board", () => {
|
||||
expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
boardId: dbBoard?.id,
|
||||
name: "newBoard",
|
||||
layoutId: dbBoard?.layouts.find((layout) => layout.role === "base")?.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error when user has no board-create permission", async () => {
|
||||
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user