Compare commits

...
Author SHA1 Message Date
Thomas Camlong 4040f28048 fix(onboarding): avoid mutating emptyResult in docker discovery catch path 2026-07-02 10:47:50 +02:00
ajnart be3d522659 style: auto-fix lint and format 2026-07-02 08:47:21 +00:00
Thomas Camlong 2dcd81676c fix(onboarding): address PR review feedback on docker label discovery
Fix app-only continue flow, handle stale label sync, per-layout yOffset,
blank label normalization, updatedAt on source upsert, docs link, and
forceDisableStatus for ping-enabled apps.
2026-07-02 10:45:47 +02:00
ajnart cf655b8c8e style: auto-fix lint and format 2026-07-01 20:32:06 +00:00
Thomas Camlong 05e7d8c529 feat(onboarding): add Docker label discovery during init
Port label-based container discovery from config-portability and wire it
into the onboarding integrations step. Labeled containers take priority
over image matching, sync into category sections, and preserve board
layout when placing integration widgets.
2026-07-01 22:30:44 +02:00
homarr-renovate[bot] dd2eb3aa66 chore(deps): update dependency posthog-js to ^1.395.0 (#6150)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-01 15:51:52 +00:00
homarr-renovate[bot] aec6bb18c5 chore(deps): update dependency @scalar/api-reference-react to ^0.9.49 (#6146)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-01 12:21:19 +00:00
Thomas f9da71a19b fix: quick wins — calendar, about, glances, admin UX, releases, iframe, notes (#6116) 2026-07-01 13:57:39 +02:00
homarr-renovate[bot] b83c8b6546 chore(deps): update dependency posthog-js to ^1.393.5 (#6135)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 21:53:10 +00:00
homarr-renovate[bot] 0e4189f693 chore(deps): update dependency dockerode to ^5.0.1 (#6130)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 20:09:36 +00:00
homarr-renovate[bot] 1cf010e9fb chore(deps): update node.js to v24.18.0 (#6107)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 18:11:58 +00:00
Thomasandgithub-actions[bot] 7e58858353 feat(widgets): right-click context menu with auto-generated toggle actions (#6126)
* docs(helm): Update Helm chart documentation

* feat(widgets): add right-click context menu with auto-generated toggle actions

- Add WidgetContextMenu component using Mantine 9.3 Menu.ContextMenu
- Extend WidgetDefinition with contextActions API for widget-specific actions
- Auto-generate context menu toggle items for switch-type widget options
- Add 'Search' context action to media-requests widgets (list + stats)
- Keep existing dots-button menu for discoverability in edit mode
- Add widgetStateRef to WidgetComponentProps for client-side-only state
- Add skipContextMenu opt-out on switch option definitions

* style: auto-fix lint and format

* fix(widgets): address CodeRabbit review — badge in context menu target, edit-only pending gate

- Move title badge inside WidgetContextMenu wrapper so right-clicking the title overlay also triggers the context menu
- Remove full-menu hide during integration loading; only disable 'Edit' action while isPending
- Add isPending to allActions useMemo dependency array

* style: auto-fix lint and format

* fix(widgets): gate context menu behind authenticated session

- Only show right-click context menu for logged-in users
- Unauthenticated users see plain children without menu wrapper
- Matches existing BoardItemMenu session gating pattern

* style: auto-fix lint and format

* chore: revert unrelated sitemap change from auto-fix

* style: auto-fix lint and format

* fix(context-menu): persist widget option changes to server

Toggle switches and edit modal changes from the context menu now call
saveBoard mutation to persist to DB. Previously only the client cache
was updated, losing changes on reload. Removes unnecessary invalidation
that caused cascading refetch loops.

* style: auto-fix lint and format

* fix: use explicit React type imports instead of global namespace

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-30 18:10:08 +02:00
9ed55d57dd Main (#6138)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-30 17:52:37 +02:00
62 changed files with 2544 additions and 315 deletions

No files matched your search

+62
View File
@@ -163,3 +163,65 @@ jobs:
run: cp .env.example .env
- name: Build
run: pnpm turbo build --affected
memory-regression:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Docker image
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-memory-test
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Start container and measure memory
run: |
docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test
# Wait for container to be healthy (up to 60s)
healthy=false
for i in $(seq 1 30); do
if curl -sf http://localhost:17575/api/health 2>/dev/null; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs homarr-mem-test --tail 50
exit 1
fi
# Warm up: hit the dashboard page a few times
for i in $(seq 1 5); do
curl -sf http://localhost:17575/ > /dev/null 2>&1 || true
sleep 2
done
# Measure RSS after warmup
MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||')
echo "Raw memory: $MEM_KB"
# Convert to numeric MB (handles GiB and MiB suffixes)
MEM_NUM=$(echo "$MEM_KB" | awk '{
if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 }
else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 }
else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 }
else { print $1 / 1048576 }
}')
echo "Memory usage: ${MEM_NUM} MB"
MAX_MB=700
if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then
echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold"
docker logs homarr-mem-test --tail 50
exit 1
fi
echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB"
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.17.0
node-version: 24.18.0
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
+1 -1
View File
@@ -1,2 +1,2 @@
[tools]
node = "24.17.0"
node = "24.18.0"
+1 -1
View File
@@ -1 +1 @@
24.17.0
24.18.0
+1 -1
View File
@@ -1,7 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"oxc.oxc-vscode",
"yoavbls.pretty-ts-errors",
"million.million-lint",
"lokalise.i18n-ally"
+7
View File
@@ -1,3 +1,10 @@
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
### Bug Fixes
* make source maps opt-in to restore v1.67.0 memory baseline ([57ef0af](https://github.com/homarr-labs/homarr/commit/57ef0af4a252ea39727caeba7e13587dabc6254e)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
* revert production source maps to restore v1.67.0 memory baseline ([60a69e3](https://github.com/homarr-labs/homarr/commit/60a69e31efc7168b8f4309daf75954d7730d883b)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
## [1.68.0](https://github.com/homarr-labs/homarr/compare/v1.67.0...v1.68.0) (2026-06-26)
### Features
+2 -15
View File
@@ -1,4 +1,4 @@
FROM node:24.17.0-alpine AS base
FROM node:24.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -20,17 +20,6 @@ ARG DISABLE_REDIS_LOGS='true'
RUN corepack enable pnpm && pnpm build
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
# See https://github.com/homarr-labs/homarr/issues/5891
RUN cd apps/nextjs/.next/server && \
find . -name '*.map' | while IFS= read -r map; do \
dest="../standalone/apps/nextjs/.next/server/$map"; \
mkdir -p "$(dirname "$dest")"; \
cp "$map" "$dest"; \
done
FROM base AS runner
WORKDIR /app
@@ -53,6 +42,7 @@ RUN mkdir -p /var/cache/nginx && \
COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
@@ -75,9 +65,6 @@ ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
ENV NODE_OPTIONS='--enable-source-maps'
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"]
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.19.0](https://img.shields.io/badge/Version-8.19.0-informational?style=flat)
![Version: 8.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.60.0](https://img.shields.io/badge/AppVersion-v1.60.0-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -383,6 +383,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. |
| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). |
| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql |
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server |
| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups |
| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups |
@@ -395,7 +396,6 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user search filter (& based) |
| env.AUTH_LDAP_USER_MAIL_ATTRIBUTE | string | `"mail"` | Attribute used for mail field |
| env.AUTH_LOGOUT_REDIRECT_URL | string | `nil` | URL to redirect to after clicking logging out. |
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_OIDC_AUTO_LOGIN | string | `"false"` | Automatically redirect to OIDC login |
| env.AUTH_OIDC_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) |
| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim |
@@ -0,0 +1,54 @@
---
title: 'Docker labels'
description: 'Configure Homarr apps automatically using Docker container labels during onboarding.'
hide_title: true
---
# Docker label discovery
Homarr can read `homarr.*` labels on your containers to pre-populate apps during onboarding. Homepage-style `homepage.*` labels are supported as a fallback when no `homarr.name` label is set.
## Required labels
| Label | Description |
| --- | --- |
| `homarr.name` | Display name for the app |
| `homarr.group` | Category section name on the board |
| `homarr.href` | URL opened when clicking the app |
## Optional labels
| Label | Description |
| --- | --- |
| `homarr.icon` | Icon URL |
| `homarr.description` | App description |
| `homarr.ping` | Ping URL for status checks |
| `homarr.id` | Stable external ID (defaults to container ID) |
| `homarr.board` | Target board name |
| `homarr.integration` | Integration kind (e.g. `sonarr`) for onboarding integration setup |
| `homarr.widget` | Widget kind hint (parsed, not auto-placed yet) |
| `homarr.hide` | Hide container from discovery |
## Homepage fallback
When `homarr.name` is absent, Homarr reads `homepage.name`, `homepage.group`, `homepage.href`, `homepage.icon`, and `homepage.description` if homepage label reading is enabled (default: on).
## Example
```yaml
services:
sonarr:
image: lscr.io/linuxserver/sonarr:latest
labels:
homarr.name: "Sonarr"
homarr.group: "Media"
homarr.href: "http://sonarr:8989"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg"
homarr.integration: "sonarr"
```
## Onboarding behavior
During the integrations step, Homarr scans labeled containers first. Labeled services appear with their configured name, group, and URL. Unlabeled running containers still use image-based discovery as a fallback.
When you finish onboarding, selected label-based apps are synced into category sections on your board. Integration widgets are placed separately in the default section.
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const dockerLabelsIntegration: IntegrationDefinition = {
name: "Docker labels",
description: "Configure Homarr apps automatically using Docker container labels.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg",
path: "../../integrations/docker-labels",
};
+4 -2
View File
@@ -93,9 +93,11 @@ They are then available in the app list and can be used as normally added apps.
### Configuration
Currently Homarr supports one label for configuration of the containers:
Homarr supports Docker labels for automatic app discovery during onboarding. See [Docker labels](../docker-labels/index.mdx) for the full reference.
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget and tools page.
Currently Homarr supports one label for hiding containers:
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget, tools page, and label discovery.
### Security
Mounting docker sockets can be risky, as they permit full control over your docker service.
-4
View File
@@ -31,8 +31,6 @@ const nextConfig: NextConfig = {
},
output: "standalone",
reactStrictMode: true,
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
productionBrowserSourceMaps: true,
// react compiler breaks mantine-react-table, so disabled for now
//reactCompiler: true,
/** We already do typechecking as separate tasks in CI */
@@ -43,8 +41,6 @@ const nextConfig: NextConfig = {
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
serverSourceMaps: true,
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
@@ -50,7 +50,7 @@ interface IntegrationDraft {
initialUrl: string;
initialName?: string;
dockerPort?: number | null;
source: "manual" | "docker";
source: "manual" | "docker" | "label";
}
export const InitIntegrations = () => {
@@ -65,6 +65,7 @@ export const InitIntegrations = () => {
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
const { mutateAsync: syncLabelApps } = clientApi.onboard.syncDockerLabelApps.useMutation();
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
undefined,
@@ -98,7 +99,7 @@ export const InitIntegrations = () => {
initialUrl: item.suggestedUrl,
initialName: item.containerName,
dockerPort: item.publishedPort,
source: "docker",
source: item.source,
});
}
newKinds.add(item.kind);
@@ -132,7 +133,8 @@ export const InitIntegrations = () => {
const newDrafts = new Map(drafts);
for (const kind of selectedKinds) {
const existing = newDrafts.get(kind);
if (baseHost) {
const useUrlTemplate = baseHost && existing?.source !== "label";
if (useUrlTemplate) {
newDrafts.set(kind, {
kind,
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
@@ -152,10 +154,32 @@ export const InitIntegrations = () => {
const finishSetupAsync = async () => {
setPhase("done");
try {
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
if (appsToCreate.length > 0) {
const selectedApps = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
const labelApps = selectedApps.filter((app) => app.source === "label");
const imageApps = selectedApps.filter((app) => app.source !== "label");
if (labelApps.length > 0) {
const labelAppsWithHost = labelApps.filter((app): app is typeof app & { host: string } => Boolean(app.host));
const missingHostCount = labelApps.length - labelAppsWithHost.length;
if (missingHostCount > 0) {
throw new Error("Some selected Docker label apps are missing host metadata");
}
if (labelAppsWithHost.length > 0) {
const syncResult = await syncLabelApps(
labelAppsWithHost.map((app) => ({
containerId: app.containerId,
host: app.host,
})),
);
if (syncResult.notFound > 0) {
throw new Error("Some selected Docker label apps were not found");
}
}
}
if (imageApps.length > 0) {
await createApps(
appsToCreate.map((app) => {
imageApps.map((app) => {
const href = baseHost
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
: app.suggestedUrl;
@@ -167,6 +191,7 @@ export const InitIntegrations = () => {
}),
);
}
await setupIntegrations();
router.refresh();
} catch {
@@ -336,9 +361,16 @@ export const InitIntegrations = () => {
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={500} lineClamp={1}>
{app.containerName}
</Text>
{app.group && (
<Text size="xs" c="dimmed" lineClamp={1}>
{app.group}
</Text>
)}
</Stack>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
@@ -359,8 +391,14 @@ export const InitIntegrations = () => {
{t("action.skip")}
</Button>
<Button
onClick={handleContinueToConfig}
disabled={selectedKinds.length === 0 || undefined}
onClick={() => {
if (selectedKinds.length === 0) {
void finishSetupAsync();
return;
}
handleContinueToConfig();
}}
disabled={(selectedKinds.length === 0 && selectedAppCount === 0) || undefined}
rightSection={<IconArrowRight size={16} stroke={1.5} />}
suppressHydrationWarning
>
@@ -404,7 +442,8 @@ const ConfigurePhase = ({
const currentKind = selectedKinds[currentIndex]!;
const draft = drafts.get(currentKind);
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
const computedUrl = baseHost
const useUrlTemplate = baseHost && draft?.source !== "label";
const computedUrl = useUrlTemplate
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
: draft?.initialUrl;
@@ -10,6 +10,7 @@ import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
@@ -79,17 +80,20 @@ export default async function ApiPage() {
const toolGroups = getMcpToolGroups();
return (
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
<>
<DynamicBreadcrumb />
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
</>
);
}
@@ -6,6 +6,7 @@ import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
@@ -31,10 +32,13 @@ export default async function BackupPage() {
const t = await getScopedI18n("management.page.tool.backup");
return (
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
<>
<DynamicBreadcrumb />
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
</>
);
}
@@ -7,6 +7,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NavigationLink } from "./_navigation";
interface LayoutProps {
@@ -24,6 +25,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
return (
<ManageContainer size="xl">
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, group.name]])} />
<Grid>
<GridCol span={12}>
<Group justify="space-between" align="center">
@@ -1,3 +1,5 @@
import type { MutableRefObject } from "react";
import { useRef } from "react";
import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -16,6 +18,7 @@ import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
interface BoardItemContentProps {
item: SectionItem;
@@ -30,30 +33,33 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
return (
<>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} />
</Card>
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
</WidgetContextMenu>
{item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
@@ -83,6 +89,7 @@ interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
@@ -0,0 +1,250 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { Group, Menu, Switch } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
interface WidgetContextMenuProps {
item: SectionItem;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
children: ReactNode;
}
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const settings = useSettings();
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, settings, item.options],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
}, [currentDefinition, settings]);
const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>;
if (typeof def.contextActions !== "function") return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions = (def.contextActions as any)({
options,
setOptions: (partial: Record<string, unknown>) => {
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
},
integrationIds: item.integrationIds,
context: { isEditMode, boardId: board.id, itemId: item.id },
widgetStateRef,
});
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
const persistBoard = useCallback(
(updatedItems: typeof board.items) => {
saveBoard({ ...board, items: updatedItems });
},
[board, saveBoard],
);
const openEditModal = useCallback(() => {
openModal(
{
kind: item.kind,
value: {
advancedOptions: item.advancedOptions,
options: item.options,
integrationIds: item.integrationIds,
},
onSuccessfulEdit: (editResult) => {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
if (!isEditMode) {
persistBoard(
board.items.map((boardItem) =>
boardItem.id !== item.id
? boardItem
: {
...boardItem,
options: editResult.options,
advancedOptions: editResult.advancedOptions,
integrationIds: editResult.integrationIds,
},
),
);
}
},
integrationData: (integrationData ?? []).filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
);
}, [
openModal,
item,
updateItemOptions,
updateItemAdvancedOptions,
updateItemIntegrations,
integrationData,
currentDefinition,
settings,
isEditMode,
persistBoard,
board,
]);
const handleToggle = useCallback(
(key: string) => (checked: boolean) => {
const newOptions = { ...options, [key]: checked };
updateItemOptions({ itemId: item.id, newOptions });
persistBoard(
board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
);
},
[options, item.id, updateItemOptions, persistBoard, board],
);
if (!session) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
return (
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Group wrap="nowrap">
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
</Group>
</Menu.Item>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{String(translateIfNecessary(t, action.label))}
</Menu.Item>
);
})}
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconLayoutKanban size={16} />}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
</>
)}
<>
{!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
disabled={isPending}
>
{tMenu("settings")}
</Menu.Item>
</>
{isEditMode && (
<>
<Menu.Divider />
<Menu.Item
closeMenuOnClick
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
>
{tItem("action.remove")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
);
};
@@ -10,3 +10,8 @@
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.itemCard:global(.iframe-wrapper) {
background-color: transparent !important;
border-color: transparent !important;
}
@@ -14,6 +14,8 @@ interface DynamicBreadcrumbProps {
nonInteractable?: string[];
}
const categorySegments = new Set(["tools"]);
/**
* Breadcrumb is client side rendered. Elements are automatically
* calculated and translated using dynamic keys.
@@ -60,8 +62,11 @@ export const DynamicBreadcrumb = ({
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
const mappedValue = dynamicMappings?.get(pathnamePart);
const isNonInteractable =
nonInteractable?.includes(pathnamePart) === true || categorySegments.has(pathnamePart);
if (mappedValue) {
if (nonInteractable?.includes(pathnamePart)) {
if (isNonInteractable) {
return <Text key={href}>{mappedValue}</Text>;
}
return (
@@ -71,7 +76,7 @@ export const DynamicBreadcrumb = ({
);
}
if (nonInteractable?.includes(pathnamePart)) {
if (isNonInteractable) {
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
}
+15 -7
View File
@@ -35,14 +35,22 @@ export const getDependenciesAsync = async (): Promise<PackageJsonDependencies> =
type DependencyCatalog = Map<string, string>;
const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const workspaceConfigContent = await fsPromises.readFile(
path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"),
"utf-8",
);
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml");
return new Map(Object.entries(parseResult.catalog ?? {}));
try {
const workspaceConfigContent = await fsPromises.readFile(workspaceConfigPath, "utf-8");
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
return new Map(Object.entries(parseResult.catalog ?? {}));
} catch (error) {
// The workspace config is absent in Docker (only the runner stage is shipped),
// so a missing file is expected. Surface any other failure (e.g. malformed YAML).
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return new Map();
}
throw error;
}
};
const workspaceConfigCatalogSchema = z.object({
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.17.0-trixie AS base
FROM node:24.18.0-trixie AS base
WORKDIR /app
COPY package.json .
COPY pnpm-lock.yaml .
+1 -1
View File
@@ -5,7 +5,7 @@
"better-sqlite3": "^12.10.0"
},
"engines": {
"node": ">=24.17.0",
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
},
"packageManager": "pnpm@11.9.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.68.0",
"version": "1.68.1",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -61,7 +61,7 @@
"vitest": "catalog:"
},
"engines": {
"node": ">=24.17.0",
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
},
"packageManager": "pnpm@11.9.0",
+170 -39
View File
@@ -3,10 +3,13 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { eq, like, or } from "@homarr/db";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, inArray, like, or } from "@homarr/db";
import { placeAllWidgetsAsync } from "@homarr/db/queries";
import { getServerSettingsAsync } from "@homarr/db/queries";
import {
apps,
boards,
groups,
icons,
integrationItems,
@@ -33,6 +36,8 @@ import { MissingSecretError, testConnectionAsync } from "../integration/integrat
import { mapTestConnectionError } from "../integration/map-test-connection-error";
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
const logger = createLogger({ module: "onboardDockerDiscovery" });
interface PortInfo {
IP?: string;
PublicPort?: number;
@@ -43,6 +48,30 @@ interface SuggestedUrlResult {
publishedPort: number | null;
}
type DockerDiscoverySource = "label" | "docker";
interface DiscoveredIntegration {
containerId: string;
containerName: string;
kind: IntegrationKind;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
source: DockerDiscoverySource;
group?: string;
}
interface DiscoveredApp {
containerId: string;
containerName: string;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
source: DockerDiscoverySource;
group?: string;
host?: string;
}
const buildSuggestedUrl = (ports: PortInfo[] | undefined, host: string): SuggestedUrlResult => {
const port = ports?.[0];
if (port?.PublicPort) {
@@ -145,51 +174,91 @@ export const onboardRouter = createTRPCRouter({
}),
discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => {
const emptyResult = {
integrations: [] as {
containerId: string;
containerName: string;
kind: IntegrationKind;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}[],
apps: [] as {
containerId: string;
containerName: string;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}[],
integrations: [] as DiscoveredIntegration[],
apps: [] as DiscoveredApp[],
};
try {
const { DockerSingleton, dockerLabels } = await import("@homarr/docker");
const { DockerSingleton, dockerLabels, listDiscoveredContainersAsync } = await import("@homarr/docker");
const serverSettings = await getServerSettingsAsync(ctx.db);
const labeledServices = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
const labeledContainerIds = new Set(labeledServices.map((service) => service.containerId));
const seenKinds = new Set<IntegrationKind>();
const discoveredIntegrations: DiscoveredIntegration[] = [];
const discoveredApps: DiscoveredApp[] = [];
for (const service of labeledServices) {
if (service.integrationKind && !seenKinds.has(service.integrationKind)) {
seenKinds.add(service.integrationKind);
discoveredIntegrations.push({
containerId: service.containerId,
containerName: service.name,
kind: service.integrationKind,
suggestedUrl: service.href,
publishedPort: null,
iconUrl: service.icon ?? null,
source: "label",
group: service.group,
});
continue;
}
discoveredApps.push({
containerId: service.containerId,
containerName: service.name,
suggestedUrl: service.href,
publishedPort: null,
iconUrl: service.icon ?? null,
source: "label",
group: service.group,
host: service.host,
});
}
const dockerInstances = DockerSingleton.getInstances();
if (dockerInstances.length === 0) {
return { status: "empty" as const, ...emptyResult };
const hasResults = discoveredIntegrations.length > 0 || discoveredApps.length > 0;
return {
status: hasResults ? ("success" as const) : ("empty" as const),
integrations: discoveredIntegrations,
apps: discoveredApps,
};
}
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const containerList = await instance.listContainers({ all: false });
return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host }));
return containerList
.filter((container) => !(dockerLabels.hide in container.Labels))
.filter((container) => !labeledContainerIds.has(container.Id))
.map((container) => ({ ...container, host }));
}),
);
const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
const containers = results.flatMap((result, index) => {
if (result.status === "fulfilled") {
return result.value;
}
if (containers.length === 0) {
logger.warn("Failed to list containers for onboarding discovery", {
host: dockerInstances[index]?.host,
cause: result.reason,
});
return [];
});
if (containers.length === 0 && discoveredIntegrations.length === 0 && discoveredApps.length === 0) {
return { status: "empty" as const, ...emptyResult };
}
const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`));
const likeQueries = containers.map((container) =>
like(icons.name, `%${extractContainerImageName(container.Image)}%`),
);
const dbIcons = likeQueries.length > 0 ? await ctx.db.query.icons.findMany({ where: or(...likeQueries) }) : [];
const seenKinds = new Set<IntegrationKind>();
const discoveredIntegrations = emptyResult.integrations;
const discoveredApps = emptyResult.apps;
const cdnIconUrl = (slug: string) =>
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`;
@@ -223,6 +292,7 @@ export const onboardRouter = createTRPCRouter({
suggestedUrl,
publishedPort,
iconUrl,
source: "docker",
});
} else if (!kind && dbIcon) {
discoveredApps.push({
@@ -231,19 +301,61 @@ export const onboardRouter = createTRPCRouter({
suggestedUrl,
publishedPort,
iconUrl,
source: "docker",
host: container.host,
});
}
}
const hasResults = discoveredIntegrations.length > 0 || discoveredApps.length > 0;
return {
status: "success" as const,
status: hasResults ? ("success" as const) : ("empty" as const),
integrations: discoveredIntegrations,
apps: discoveredApps,
};
} catch {
} catch (error) {
logger.warn("Docker discovery failed during onboarding", { cause: error });
return { status: "unavailable" as const, ...emptyResult };
}
}),
syncDockerLabelApps: onboardingProcedure
.requiresStep("integrations")
.input(
z.array(
z.object({
containerId: z.string(),
host: z.string(),
}),
),
)
.mutation(async ({ ctx, input }) => {
if (input.length === 0) {
return { created: 0, updated: 0, skipped: 0, notFound: 0 };
}
const { listDiscoveredContainersAsync, syncDiscoveredServicesAsync } = await import("@homarr/docker");
const serverSettings = await getServerSettingsAsync(ctx.db);
const labeledServices = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
const selectedKeys = new Set(input.map((entry) => `${entry.host}/${entry.containerId}`));
const selectedServices = labeledServices.filter((service) =>
selectedKeys.has(`${service.host}/${service.containerId}`),
);
const syncResult = await syncDiscoveredServicesAsync(selectedServices, {
targetBoardName: serverSettings.docker.targetBoardName,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
defaultItemWidth: serverSettings.docker.defaultItemWidth,
defaultItemHeight: serverSettings.docker.defaultItemHeight,
});
return {
...syncResult,
notFound: input.length - selectedServices.length,
};
}),
createAppsFromDiscovery: onboardingProcedure
.requiresStep("integrations")
.input(
@@ -257,9 +369,17 @@ export const onboardRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
if (input.length === 0) return;
const existingApps = await ctx.db.query.apps.findMany({ columns: { href: true } });
const existingHrefs = new Set(
existingApps.map((app) => app.href).filter((href): href is string => Boolean(href)),
);
const appsToCreate = input.filter((app) => !app.href || !existingHrefs.has(app.href));
if (appsToCreate.length === 0) return;
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
await ctx.db.insert(apps).values(
input.map((app) => ({
appsToCreate.map((app) => ({
id: createId(),
name: app.name,
iconUrl: app.iconUrl ?? defaultIcon,
@@ -270,16 +390,26 @@ export const onboardRouter = createTRPCRouter({
}),
setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
const db = ctx.db;
const serverSettings = await getServerSettingsAsync(db);
const allIntegrations = await db.query.integrations.findMany();
const allApps = await db.query.apps.findMany();
const labelSyncedAppIds = new Set(
(await db.query.dockerAppSources.findMany({ columns: { appId: true } })).map((source) => source.appId),
);
const appsForWidgetPlacement = allApps.filter((app) => !labelSyncedAppIds.has(app.id));
const board = await db.query.boards.findFirst({
const boardList = await db.query.boards.findMany({
orderBy: asc(boards.name),
with: {
sections: true,
layouts: true,
},
});
const targetBoardName = serverSettings.docker.targetBoardName;
const board = targetBoardName
? (boardList.find((entry) => entry.name === targetBoardName) ?? boardList.at(0))
: boardList.at(0);
if (!board) {
await nextOnboardingStepAsync(db, undefined);
@@ -297,15 +427,16 @@ export const onboardRouter = createTRPCRouter({
const layout = board.layouts[0];
if (section && layout) {
const existingItems = await db.query.items.findMany({
where: eq(items.boardId, board.id),
const emptySectionLayouts = await db.query.itemLayouts.findMany({
where: eq(itemLayouts.sectionId, section.id),
columns: { itemId: true },
});
for (const item of existingItems) {
await db.delete(integrationItems).where(eq(integrationItems.itemId, item.id));
await db.delete(itemLayouts).where(eq(itemLayouts.itemId, item.id));
}
if (existingItems.length > 0) {
await db.delete(items).where(eq(items.boardId, board.id));
const emptySectionItemIds = emptySectionLayouts.map((entry) => entry.itemId);
if (emptySectionItemIds.length > 0) {
await db.delete(integrationItems).where(inArray(integrationItems.itemId, emptySectionItemIds));
await db.delete(itemLayouts).where(inArray(itemLayouts.itemId, emptySectionItemIds));
await db.delete(items).where(inArray(items.id, emptySectionItemIds));
}
await placeAllWidgetsAsync(
@@ -317,7 +448,7 @@ export const onboardRouter = createTRPCRouter({
columnCount: layout.columnCount,
},
allIntegrations,
allApps,
appsForWidgetPlacement,
);
}
@@ -0,0 +1,17 @@
CREATE TABLE `docker_app_source` (
`host` varchar(512) NOT NULL,
`container_id` varchar(128) NOT NULL,
`external_id` varchar(512) NOT NULL,
`app_id` varchar(64) NOT NULL,
`board_id` varchar(64) NOT NULL,
`item_id` varchar(64),
`integration_id` varchar(64),
`updated_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `docker_app_source_host_container_id_pk` PRIMARY KEY(`host`,`container_id`)
);
--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_app_id_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
@@ -295,6 +295,13 @@
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1780764600000,
"tag": "0042_add_docker_app_source",
"breakpoints": true
}
]
}
@@ -0,0 +1,17 @@
CREATE TABLE "docker_app_source" (
"host" varchar(512) NOT NULL,
"container_id" varchar(128) NOT NULL,
"external_id" varchar(512) NOT NULL,
"app_id" varchar(64) NOT NULL,
"board_id" varchar(64) NOT NULL,
"item_id" varchar(64),
"integration_id" varchar(64),
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "docker_app_source_host_container_id_pk" PRIMARY KEY("host","container_id")
);
--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_app_id_app_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."app"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "docker_app_source__external_id_idx" ON "docker_app_source" USING btree ("host","external_id");
@@ -57,6 +57,13 @@
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780764600000,
"tag": "0008_add_docker_app_source",
"breakpoints": true
}
]
}
@@ -0,0 +1,17 @@
CREATE TABLE `docker_app_source` (
`host` text NOT NULL,
`container_id` text NOT NULL,
`external_id` text NOT NULL,
`app_id` text NOT NULL,
`board_id` text NOT NULL,
`item_id` text,
`integration_id` text,
`updated_at` integer NOT NULL,
PRIMARY KEY(`host`, `container_id`),
FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
@@ -281,6 +281,13 @@
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1780764600000,
"tag": "0040_add_docker_app_source",
"breakpoints": true
}
]
}
+1
View File
@@ -51,6 +51,7 @@ export const {
cronJobConfigurations,
customWidgetDefinitions,
customWidgetSecrets,
dockerAppSources,
} = schema;
export type User = InferSelectModel<typeof schema.users>;
+46
View File
@@ -547,6 +547,33 @@ export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = mysqlTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -826,3 +853,22 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
+46
View File
@@ -546,6 +546,33 @@ export const cronJobConfigurations = pgTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = pgTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -825,3 +852,22 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
+46
View File
@@ -536,6 +536,33 @@ export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
isEnabled: int({ mode: "boolean" }).default(true).notNull(),
});
export const dockerAppSources = sqliteTable(
"docker_app_source",
{
host: text().notNull(),
containerId: text().notNull(),
externalId: text().notNull(),
appId: text()
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: text()
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: text().references(() => items.id, { onDelete: "set null" }),
integrationId: text().references(() => integrations.id, { onDelete: "set null" }),
updatedAt: int({ mode: "timestamp" })
.$onUpdateFn(() => new Date())
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -815,3 +842,22 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
+6 -2
View File
@@ -25,11 +25,15 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"dockerode": "catalog:"
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"dockerode": "catalog:",
"superjson": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
}
}
@@ -0,0 +1,472 @@
import type { ContainerInfo } from "dockerode";
import { describe, expect, test } from "vitest";
import { dockerLabels, homepageLabels } from "../../labels";
import { parseContainerLabels } from "../parse-container-labels";
const createContainer = (
labels: Record<string, string>,
id = "container-abc123",
): Pick<ContainerInfo, "Id" | "Labels"> => ({
Id: id,
Labels: labels,
});
describe("parseContainerLabels", () => {
describe("homarr native labels", () => {
test("parses all homarr labels into DiscoveredService", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Jellyfin",
[dockerLabels.href]: "https://jellyfin.local",
[dockerLabels.icon]: "https://cdn.example/icon.png",
[dockerLabels.description]: "Media server",
[dockerLabels.ping]: "https://jellyfin.local/health",
[dockerLabels.id]: "jellyfin-1",
[dockerLabels.board]: "homelab",
[dockerLabels.integration]: "jellyfin",
[dockerLabels.widget]: "mediaServer",
}),
"socket",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "socket",
group: "Media",
name: "Jellyfin",
href: "https://jellyfin.local",
icon: "https://cdn.example/icon.png",
description: "Media server",
pingUrl: "https://jellyfin.local/health",
externalId: "jellyfin-1",
boardName: "homelab",
integrationKind: "jellyfin",
widgetKind: "mediaServer",
});
});
test("parses minimal required homarr labels (name, group, href)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Networking",
[dockerLabels.name]: "Pi-hole",
[dockerLabels.href]: "http://pihole:80/admin",
}),
"socket",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "socket",
group: "Networking",
name: "Pi-hole",
href: "http://pihole:80/admin",
externalId: "container-abc123",
});
});
test("uses container ID as externalId when homarr.id is absent", () => {
const result = parseContainerLabels(
createContainer(
{
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Plex",
[dockerLabels.href]: "http://plex:32400",
},
"sha256:deadbeef1234",
),
"socket",
);
expect(result?.externalId).toBe("sha256:deadbeef1234");
});
test("uses homarr.id as externalId when present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Plex",
[dockerLabels.href]: "http://plex:32400",
[dockerLabels.id]: "plex-server-main",
}),
"socket",
);
expect(result?.externalId).toBe("plex-server-main");
});
});
describe("homepage fallback labels", () => {
test("uses homepage labels when homarr.name is absent", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
[homepageLabels.icon]: "https://cdn.example/sonarr.png",
[homepageLabels.description]: "TV shows",
}),
"192.168.1.10:2375",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "192.168.1.10:2375",
group: "Downloads",
name: "Sonarr",
href: "https://sonarr.local",
icon: "https://cdn.example/sonarr.png",
description: "TV shows",
externalId: "container-abc123",
});
});
test("does not use homepage fallback when readHomepageLabels is false", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
}),
"socket",
{ readHomepageLabels: false },
);
expect(result).toBeNull();
});
test("homepage labels have no ping, board, integration, or widget support", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Media",
[homepageLabels.name]: "Emby",
[homepageLabels.href]: "http://emby:8096",
}),
"socket",
);
expect(result?.pingUrl).toBeUndefined();
expect(result?.boardName).toBeUndefined();
expect(result?.integrationKind).toBeUndefined();
expect(result?.widgetKind).toBeUndefined();
});
});
describe("label priority and mixing", () => {
test("prefers homarr labels over homepage when both are present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Homarr Group",
[dockerLabels.name]: "Homarr Name",
[dockerLabels.href]: "https://homarr.local",
[homepageLabels.group]: "Homepage Group",
[homepageLabels.name]: "Homepage Name",
[homepageLabels.href]: "https://homepage.local",
}),
"socket",
);
expect(result?.group).toBe("Homarr Group");
expect(result?.name).toBe("Homarr Name");
expect(result?.href).toBe("https://homarr.local");
});
test("disables homepage fallback entirely when homarr.name is present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.name]: "MyApp",
[dockerLabels.href]: "http://myapp:3000",
[homepageLabels.group]: "FromHomepage",
}),
"socket",
);
expect(result).toBeNull();
});
test("uses homarr icon with homepage name/group/href when homarr.name absent but homarr.icon present", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Media",
[homepageLabels.name]: "Jellyfin",
[homepageLabels.href]: "http://jellyfin:8096",
[dockerLabels.icon]: "https://custom-icon.example/jf.png",
}),
"socket",
);
expect(result?.name).toBe("Jellyfin");
expect(result?.icon).toBe("https://custom-icon.example/jf.png");
});
});
describe("homarr.hide", () => {
test("skips container when homarr.hide is present with value 'true'", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "true",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("skips container when homarr.hide is present with empty value", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("skips container when homarr.hide is any string (key presence is enough)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "anything",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("missing required labels", () => {
test("returns null when group is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.name]: "NoGroup",
[dockerLabels.href]: "https://nogroup.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when name is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.href]: "https://noname.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when href is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "NoHref",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when homepage labels are incomplete (missing group)", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.name]: "Incomplete",
[homepageLabels.href]: "http://incomplete.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("whitespace handling", () => {
test("trims whitespace from all label values", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: " Media ",
[dockerLabels.name]: " Jellyfin ",
[dockerLabels.href]: " https://jellyfin.local ",
[dockerLabels.icon]: " https://icon.png ",
[dockerLabels.description]: " A media server ",
}),
"socket",
);
expect(result?.group).toBe("Media");
expect(result?.name).toBe("Jellyfin");
expect(result?.href).toBe("https://jellyfin.local");
expect(result?.icon).toBe("https://icon.png");
expect(result?.description).toBe("A media server");
});
test("treats whitespace-only values as empty (missing)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: " ",
[dockerLabels.href]: "https://example.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("integration and widget kind validation", () => {
test("parses valid integration kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Radarr",
[dockerLabels.href]: "http://radarr:7878",
[dockerLabels.integration]: "radarr",
}),
"socket",
);
expect(result?.integrationKind).toBe("radarr");
});
test("ignores invalid integration kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Custom App",
[dockerLabels.href]: "http://custom:3000",
[dockerLabels.integration]: "nonExistentIntegration",
}),
"socket",
);
expect(result?.integrationKind).toBeUndefined();
});
test("parses valid widget kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Jellyfin",
[dockerLabels.href]: "http://jellyfin:8096",
[dockerLabels.widget]: "mediaServer",
}),
"socket",
);
expect(result?.widgetKind).toBe("mediaServer");
});
test("ignores invalid widget kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Custom",
[dockerLabels.href]: "http://custom:3000",
[dockerLabels.widget]: "totallyFakeWidget",
}),
"socket",
);
expect(result?.widgetKind).toBeUndefined();
});
test("handles empty integration kind gracefully", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "App",
[dockerLabels.href]: "http://app:3000",
[dockerLabels.integration]: "",
}),
"socket",
);
expect(result?.integrationKind).toBeUndefined();
});
});
describe("real-world scenarios", () => {
test("homepage-only service (common migration use case)", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Monitoring",
[homepageLabels.name]: "Grafana",
[homepageLabels.href]: "http://grafana:3000",
[homepageLabels.icon]: "grafana",
[homepageLabels.description]: "Metrics and dashboards",
}),
"tcp://192.168.1.50:2375",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "tcp://192.168.1.50:2375",
group: "Monitoring",
name: "Grafana",
href: "http://grafana:3000",
icon: "grafana",
description: "Metrics and dashboards",
externalId: "container-abc123",
});
});
test("service targeting a specific board", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Infrastructure",
[dockerLabels.name]: "Traefik",
[dockerLabels.href]: "http://traefik:8080",
[dockerLabels.board]: "networking-board",
[dockerLabels.icon]: "https://cdn.example/traefik.svg",
}),
"socket",
);
expect(result?.boardName).toBe("networking-board");
});
test("service with all optional fields empty uses defaults", () => {
const result = parseContainerLabels(
createContainer(
{
[dockerLabels.group]: "Tools",
[dockerLabels.name]: "Portainer",
[dockerLabels.href]: "http://portainer:9000",
},
"abc123def456",
),
"socket",
);
expect(result?.icon).toBeUndefined();
expect(result?.description).toBeUndefined();
expect(result?.pingUrl).toBeUndefined();
expect(result?.boardName).toBeUndefined();
expect(result?.integrationKind).toBeUndefined();
expect(result?.widgetKind).toBeUndefined();
expect(result?.externalId).toBe("abc123def456");
});
test("container with no labels returns null", () => {
const result = parseContainerLabels(createContainer({}), "socket");
expect(result).toBeNull();
});
});
});
+8
View File
@@ -0,0 +1,8 @@
export type { DiscoveredService } from "./types";
export { parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels";
export { listDiscoveredContainersAsync } from "./list-discovered-containers";
export {
syncDiscoveredServicesAsync,
type SyncDiscoveredServicesOptions,
type SyncDiscoveredServicesResult,
} from "./sync-discovered-services";
@@ -0,0 +1,45 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { dockerLabels, homepageLabels } from "../labels";
import { DockerSingleton } from "../singleton";
import type { ParseContainerLabelsOptions } from "./parse-container-labels";
import { parseContainerLabels } from "./parse-container-labels";
import type { DiscoveredService } from "./types";
const logger = createLogger({ module: "dockerDiscovery" });
const hasDiscoveryLabels = (labels: Record<string, string>) =>
Object.values(dockerLabels).some((label) => label !== dockerLabels.hide && label in labels) ||
Object.values(homepageLabels).some((label) => label in labels);
export const listDiscoveredContainersAsync = async (
options: ParseContainerLabelsOptions = {},
): Promise<DiscoveredService[]> => {
const dockerInstances = DockerSingleton.getInstances();
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const containers = await instance.listContainers({ all: true });
return containers
.filter((container) => hasDiscoveryLabels(container.Labels ?? {}))
.map((container) => parseContainerLabels(container, host, options))
.filter((service): service is DiscoveredService => service !== null);
}),
);
return results.flatMap((result, index) => {
if (result.status === "fulfilled") {
return result.value;
}
logger.warn(
new ErrorWithMetadata(
"Failed to list containers for discovery",
{ host: dockerInstances[index]?.host },
{ cause: result.reason },
),
);
return [];
});
};
@@ -0,0 +1,116 @@
import type { ContainerInfo } from "dockerode";
import { integrationKinds, widgetKinds } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { dockerLabels, homepageLabels } from "../labels";
import type { DiscoveredService } from "./types";
export interface ParseContainerLabelsOptions {
readHomepageLabels?: boolean;
}
const homarrLabelKeys = {
group: dockerLabels.group,
name: dockerLabels.name,
href: dockerLabels.href,
icon: dockerLabels.icon,
description: dockerLabels.description,
ping: dockerLabels.ping,
id: dockerLabels.id,
board: dockerLabels.board,
integration: dockerLabels.integration,
widget: dockerLabels.widget,
} as const;
const homepageLabelKeys = {
group: homepageLabels.group,
name: homepageLabels.name,
href: homepageLabels.href,
icon: homepageLabels.icon,
description: homepageLabels.description,
} as const;
const integrationKindSet = new Set<string>(integrationKinds);
const widgetKindSet = new Set<string>(widgetKinds);
const readLabel = (labels: Record<string, string>, key: string) => {
const value = labels[key]?.trim();
if (!value) {
return undefined;
}
return value;
};
const resolveLabelValue = (
labels: Record<string, string>,
homarrKey: string,
homepageKey: string,
useHomepageFallback: boolean,
) => readLabel(labels, homarrKey) ?? (useHomepageFallback ? readLabel(labels, homepageKey) : undefined);
export const parseContainerLabels = (
container: Pick<ContainerInfo, "Id" | "Labels">,
host: string,
options: ParseContainerLabelsOptions = {},
): DiscoveredService | null => {
const labels = container.Labels ?? {};
if (dockerLabels.hide in labels) {
return null;
}
const readHomepageLabels = options.readHomepageLabels ?? true;
const hasHomarrName = Boolean(readLabel(labels, dockerLabels.name));
const useHomepageFallback = readHomepageLabels && !hasHomarrName;
const name = resolveLabelValue(labels, homarrLabelKeys.name, homepageLabelKeys.name, useHomepageFallback);
const href = resolveLabelValue(labels, homarrLabelKeys.href, homepageLabelKeys.href, useHomepageFallback);
const group = resolveLabelValue(labels, homarrLabelKeys.group, homepageLabelKeys.group, useHomepageFallback);
if (!name || !href || !group) {
return null;
}
const icon =
readLabel(labels, homarrLabelKeys.icon) ??
(useHomepageFallback ? readLabel(labels, homepageLabelKeys.icon) : undefined);
const description =
readLabel(labels, homarrLabelKeys.description) ??
(useHomepageFallback ? readLabel(labels, homepageLabelKeys.description) : undefined);
const pingUrl = readLabel(labels, homarrLabelKeys.ping);
const boardName = readLabel(labels, homarrLabelKeys.board);
const externalId = readLabel(labels, homarrLabelKeys.id) ?? container.Id;
const integrationKind = parseIntegrationKind(readLabel(labels, homarrLabelKeys.integration));
const widgetKind = parseWidgetKind(readLabel(labels, homarrLabelKeys.widget));
return {
containerId: container.Id,
host,
group,
name,
href,
icon,
description,
pingUrl,
externalId,
boardName,
integrationKind,
widgetKind,
};
};
const parseIntegrationKind = (value: string | undefined): IntegrationKind | undefined => {
if (!value || !integrationKindSet.has(value)) {
return undefined;
}
return value as IntegrationKind;
};
const parseWidgetKind = (value: string | undefined): WidgetKind | undefined => {
if (!value || !widgetKindSet.has(value)) {
return undefined;
}
return value as WidgetKind;
};
@@ -0,0 +1,471 @@
import SuperJSON from "superjson";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { emptySuperJSON } from "@homarr/definitions";
import type { InferInsertModel, InferSelectModel } from "@homarr/db";
import { and, asc, db, eq, handleTransactionsAsync } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { apps, boards, dockerAppSources, itemLayouts, items, layouts, sections } from "@homarr/db/schema";
import type { DiscoveredService } from "./types";
const logger = createLogger({ module: "dockerDiscoverySync" });
export interface SyncDiscoveredServicesOptions {
targetBoardName?: string | null;
enableStatusByDefault?: boolean;
forceDisableStatus?: boolean;
defaultItemWidth?: number;
defaultItemHeight?: number;
}
export interface SyncDiscoveredServicesResult {
created: number;
updated: number;
skipped: number;
}
interface SyncMetadata {
appId: string;
itemId?: string;
}
interface AppItemOptions {
appId: string;
openInNewTab: boolean;
showTitle: boolean;
descriptionDisplayMode: "hidden";
layout: "column";
pingEnabled: boolean;
}
type AppRow = InferSelectModel<typeof apps>;
type BoardWithRelations = NonNullable<Awaited<ReturnType<typeof loadBoardsWithRelationsAsync>>>[number];
type BoardItem = BoardWithRelations["items"][number];
const createSyncKey = (service: DiscoveredService) => `${service.host}/${service.containerId}`;
const appsMatchByHref = (app: Pick<AppRow, "href">, href: string) => app.href === href;
export const syncDiscoveredServicesAsync = async (
discoveredServices: DiscoveredService[],
options: SyncDiscoveredServicesOptions = {},
): Promise<SyncDiscoveredServicesResult> => {
const itemWidth = options.defaultItemWidth ?? 1;
const itemHeight = options.defaultItemHeight ?? 1;
const result: SyncDiscoveredServicesResult = { created: 0, updated: 0, skipped: 0 };
const syncMetadata = new Map<string, SyncMetadata>();
const existingApps = await db.query.apps.findMany();
const existingSources = await db.query.dockerAppSources.findMany();
const boardList = await loadBoardsWithRelationsAsync();
const appsToInsert: InferInsertModel<typeof apps>[] = [];
const appsToUpdate: { id: string; values: Partial<InferInsertModel<typeof apps>> }[] = [];
const sectionsToInsert: InferInsertModel<typeof sections>[] = [];
const itemsToInsert: InferInsertModel<typeof items>[] = [];
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
const pendingSectionsByBoard = new Map<string, InferInsertModel<typeof sections>[]>();
const sectionYOffsetByBoard = new Map<string, number>();
const itemYOffsetBySection = new Map<string, number>();
const pendingItemLayouts: InferInsertModel<typeof itemLayouts>[] = [];
for (const service of discoveredServices) {
const syncKey = createSyncKey(service);
if (syncMetadata.has(syncKey)) {
result.skipped += 1;
continue;
}
const board = resolveTargetBoard(boardList, service, options.targetBoardName);
if (!board) {
logger.warn("No target board found for discovered service", { syncKey, boardName: service.boardName });
result.skipped += 1;
continue;
}
const firstLayout = getFirstLayout(board.layouts);
if (!firstLayout) {
logger.warn("Board has no layouts for discovered service", { boardId: board.id, syncKey });
result.skipped += 1;
continue;
}
const existingSource = existingSources.find(
(source) => source.host === service.host && source.externalId === service.externalId,
);
const existingApp = existingSource
? existingApps.find((app) => app.id === existingSource.appId)
: existingApps.find((app) => appsMatchByHref(app, service.href));
let appId: string;
let wasCreated = false;
let wasUpdated = false;
if (existingApp) {
appId = existingApp.id;
const updateValues = buildAppUpdateValues(service, existingApp);
if (updateValues) {
appsToUpdate.push({ id: appId, values: updateValues });
wasUpdated = true;
Object.assign(existingApp, updateValues);
}
} else {
appId = createId();
const newApp = buildAppInsert(service, appId);
appsToInsert.push(newApp);
existingApps.push(newApp as AppRow);
wasCreated = true;
}
const categorySection = findOrCreateCategorySection(
board,
service.group,
sectionsToInsert,
pendingSectionsByBoard,
sectionYOffsetByBoard,
);
const existingItem = findAppItemForApp(board.items, itemsToInsert, pendingItemLayouts, appId, categorySection.id);
if (!existingItem) {
const newItemId = createId();
const newItemLayouts = board.layouts.map((layout) => ({
itemId: newItemId,
sectionId: categorySection.id,
layoutId: layout.id,
xOffset: 0,
yOffset: getNextItemYOffset(
board,
categorySection.id,
layout.id,
itemYOffsetBySection,
pendingItemLayouts,
itemHeight,
),
width: itemWidth,
height: itemHeight,
}));
itemsToInsert.push({
id: newItemId,
boardId: board.id,
kind: "app",
options: SuperJSON.stringify(buildAppItemOptions(appId, service, options)),
advancedOptions: SuperJSON.stringify({
title: null,
customCssClasses: [],
borderColor: "",
}),
});
itemLayoutsToInsert.push(...newItemLayouts);
pendingItemLayouts.push(...newItemLayouts);
syncMetadata.set(syncKey, { appId, itemId: newItemId });
wasCreated = true;
} else {
syncMetadata.set(syncKey, { appId, itemId: existingItem.id });
}
if (wasCreated) {
result.created += 1;
} else if (wasUpdated) {
result.updated += 1;
} else {
result.skipped += 1;
}
}
const sourcesToUpsert: InferInsertModel<typeof dockerAppSources>[] = [];
for (const [syncKey, meta] of syncMetadata.entries()) {
const service = discoveredServices.find((svc) => createSyncKey(svc) === syncKey);
if (!service) continue;
const board = resolveTargetBoard(boardList, service, options.targetBoardName);
if (!board) continue;
sourcesToUpsert.push({
host: service.host,
containerId: service.containerId,
externalId: service.externalId,
appId: meta.appId,
boardId: board.id,
itemId: meta.itemId ?? null,
});
}
if (
appsToInsert.length === 0 &&
appsToUpdate.length === 0 &&
sectionsToInsert.length === 0 &&
itemsToInsert.length === 0 &&
sourcesToUpsert.length === 0
) {
return result;
}
const insertCollection = createDbInsertCollectionForTransaction(["apps", "sections", "items", "itemLayouts"]);
insertCollection.apps.push(...appsToInsert);
insertCollection.sections.push(...sectionsToInsert);
insertCollection.items.push(...itemsToInsert);
insertCollection.itemLayouts.push(...itemLayoutsToInsert);
await handleTransactionsAsync(db, {
async handleAsync(innerDb) {
for (const { id, values } of appsToUpdate) {
await innerDb
.update(apps as never)
.set(values)
.where(eq(apps.id, id));
}
await insertCollection.insertAllAsync(innerDb);
const table = dockerAppSources as never;
for (const source of sourcesToUpsert) {
const existing = await innerDb.query.dockerAppSources.findFirst({
where: and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)),
});
if (existing) {
await innerDb
.update(table)
.set({
appId: source.appId,
boardId: source.boardId,
itemId: source.itemId,
externalId: source.externalId,
updatedAt: new Date(),
})
.where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)));
} else {
await innerDb.insert(table).values(source as never);
}
}
},
handleSync(innerDb) {
for (const { id, values } of appsToUpdate) {
innerDb
.update(apps as never)
.set(values)
.where(eq(apps.id, id))
.run();
}
insertCollection.insertAll(innerDb);
for (const source of sourcesToUpsert) {
try {
innerDb.insert(dockerAppSources).values(source).run();
} catch {
innerDb
.update(dockerAppSources)
.set({
appId: source.appId,
boardId: source.boardId,
itemId: source.itemId,
externalId: source.externalId,
updatedAt: new Date(),
})
.where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)))
.run();
}
}
},
});
return result;
};
const loadBoardsWithRelationsAsync = async () =>
db.query.boards.findMany({
orderBy: asc(boards.name),
with: {
layouts: true,
sections: true,
items: {
with: {
layouts: true,
},
},
},
});
const resolveTargetBoard = (
boardList: BoardWithRelations[],
service: DiscoveredService,
defaultBoardName: string | null | undefined,
) => {
const boardName = service.boardName ?? defaultBoardName ?? undefined;
if (boardName) {
return boardList.find((board) => board.name === boardName) ?? boardList.at(0);
}
return boardList.at(0);
};
const getFirstLayout = (boardLayouts: InferSelectModel<typeof layouts>[]) =>
[...boardLayouts].sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint).at(0);
const buildAppInsert = (service: DiscoveredService, appId: string): InferInsertModel<typeof apps> => ({
id: appId,
name: service.name,
description: service.description ?? null,
iconUrl: service.icon ?? "",
href: service.href,
pingUrl: service.pingUrl ?? null,
});
const buildAppUpdateValues = (
service: DiscoveredService,
existingApp: AppRow,
): Partial<InferInsertModel<typeof apps>> | null => {
const values: Partial<InferInsertModel<typeof apps>> = {};
if (existingApp.name !== service.name) {
values.name = service.name;
}
if (existingApp.href !== service.href) {
values.href = service.href;
}
if (service.icon !== undefined && existingApp.iconUrl !== service.icon) {
values.iconUrl = service.icon;
}
if (service.description !== undefined && existingApp.description !== service.description) {
values.description = service.description;
}
if (service.pingUrl !== undefined && existingApp.pingUrl !== service.pingUrl) {
values.pingUrl = service.pingUrl;
}
return Object.keys(values).length > 0 ? values : null;
};
const buildAppItemOptions = (
appId: string,
service: DiscoveredService,
options: SyncDiscoveredServicesOptions,
): AppItemOptions => ({
appId,
openInNewTab: true,
showTitle: true,
descriptionDisplayMode: "hidden",
layout: "column",
pingEnabled:
!(options.forceDisableStatus ?? false) && (Boolean(service.pingUrl) || (options.enableStatusByDefault ?? true)),
});
const findOrCreateCategorySection = (
board: BoardWithRelations,
groupName: string,
sectionsToInsert: InferInsertModel<typeof sections>[],
pendingSectionsByBoard: Map<string, InferInsertModel<typeof sections>[]>,
sectionYOffsetByBoard: Map<string, number>,
): InferInsertModel<typeof sections> & { id: string } => {
const pendingForBoard = pendingSectionsByBoard.get(board.id) ?? [];
const pendingMatch = pendingForBoard.find((section) => section.kind === "category" && section.name === groupName);
if (pendingMatch) {
return pendingMatch as InferInsertModel<typeof sections> & { id: string };
}
const existingSection = board.sections.find((section) => section.kind === "category" && section.name === groupName);
if (existingSection) {
return existingSection;
}
const categorySections = board.sections.filter((section) => section.kind === "category");
const pendingCategorySections = pendingForBoard.filter((section) => section.kind === "category");
const maxYOffset = Math.max(
0,
...categorySections.map((section) => section.yOffset ?? 0),
...pendingCategorySections.map((section) => section.yOffset ?? 0),
sectionYOffsetByBoard.get(board.id) ?? 0,
);
const yOffset = maxYOffset + 1;
sectionYOffsetByBoard.set(board.id, yOffset);
const newSection: InferInsertModel<typeof sections> = {
id: createId(),
boardId: board.id,
kind: "category",
xOffset: 0,
yOffset,
name: groupName,
options: emptySuperJSON,
};
sectionsToInsert.push(newSection);
board.sections.push(newSection as BoardWithRelations["sections"][number]);
pendingSectionsByBoard.set(board.id, [...pendingForBoard, newSection]);
return newSection as InferInsertModel<typeof sections> & { id: string };
};
const findAppItemForApp = (
boardItems: BoardItem[],
pendingItems: InferInsertModel<typeof items>[],
pendingLayouts: InferInsertModel<typeof itemLayouts>[],
appId: string,
sectionId: string,
) => {
const dbMatch = boardItems.find((item) => itemMatchesAppInSection(item, appId, sectionId));
if (dbMatch) {
return dbMatch;
}
const pendingMatch = pendingItems.find((item) => {
if (item.kind !== "app") {
return false;
}
const itemOptions = SuperJSON.parse<AppItemOptions>(item.options ?? emptySuperJSON);
if (itemOptions.appId !== appId) {
return false;
}
return pendingLayouts.some((layout) => layout.itemId === item.id && layout.sectionId === sectionId);
});
return pendingMatch ? { id: pendingMatch.id } : undefined;
};
const itemMatchesAppInSection = (item: BoardItem, appId: string, sectionId: string) => {
if (item.kind !== "app") {
return false;
}
const itemOptions = SuperJSON.parse<AppItemOptions>(item.options);
if (itemOptions.appId !== appId) {
return false;
}
return item.layouts.some((layout) => layout.sectionId === sectionId);
};
const getNextItemYOffset = (
board: BoardWithRelations,
sectionId: string,
layoutId: string,
itemYOffsetBySection: Map<string, number>,
pendingLayouts: InferInsertModel<typeof itemLayouts>[],
itemHeight: number,
) => {
const sectionKey = `${board.id}/${sectionId}/${layoutId}`;
const trackedYOffset = itemYOffsetBySection.get(sectionKey);
if (trackedYOffset !== undefined) {
const nextYOffset = trackedYOffset + itemHeight;
itemYOffsetBySection.set(sectionKey, nextYOffset);
return trackedYOffset;
}
const dbLayouts = board.items.flatMap((item) =>
item.layouts.filter((layout) => layout.sectionId === sectionId && layout.layoutId === layoutId),
);
const pendingSectionLayouts = pendingLayouts.filter(
(layout) => layout.sectionId === sectionId && layout.layoutId === layoutId,
);
const maxYOffset = [...dbLayouts, ...pendingSectionLayouts].reduce(
(max, layout) => Math.max(max, layout.yOffset + layout.height),
0,
);
itemYOffsetBySection.set(sectionKey, maxYOffset + itemHeight);
return maxYOffset;
};
+16
View File
@@ -0,0 +1,16 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
export interface DiscoveredService {
containerId: string;
host: string;
group: string;
name: string;
href: string;
icon?: string;
description?: string;
pingUrl?: string;
externalId: string;
boardName?: string;
integrationKind?: IntegrationKind;
widgetKind?: WidgetKind;
}
+1
View File
@@ -9,3 +9,4 @@ export const containerStates = ["created", "running", "paused", "restarting", "e
export type ContainerState = (typeof containerStates)[number];
export * from "./labels";
export * from "./discovery";
+18 -1
View File
@@ -1,4 +1,21 @@
export const dockerLabels = {
// Label to hide a container from Homarrs docker integration
hide: "homarr.hide",
group: "homarr.group",
name: "homarr.name",
href: "homarr.href",
icon: "homarr.icon",
description: "homarr.description",
ping: "homarr.ping",
id: "homarr.id",
board: "homarr.board",
integration: "homarr.integration",
widget: "homarr.widget",
} as const;
export const homepageLabels = {
group: "homepage.group",
name: "homepage.name",
href: "homepage.href",
icon: "homepage.icon",
description: "homepage.description",
} as const;
@@ -35,7 +35,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
if (session == null) throw new Error("Session was unexpectitly null");
const stats = await this.getAllStatsAsync();
const [stats, cpuTemp] = await Promise.all([this.getAllStatsAsync(), this.getCpuTempAsync()]);
return {
cpuUtilization: stats.cpu.total,
@@ -58,7 +58,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
cpuModelName: stats.quicklook?.cpu_name ?? "Unknown",
loadAverage: null,
smart: [],
cpuTemp: undefined,
cpuTemp,
gpu: stats.gpu.map((gpu) => ({
gpuId: gpu.gpu_id,
name: gpu.name,
@@ -80,6 +80,23 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
return await response.text();
}
private async getCpuTempAsync(): Promise<number | undefined> {
// CPU temperature is optional data, so a transport error or payload drift on
// /api/4/sensors must not fail the whole getSystemInfoAsync() call.
try {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/sensors"));
if (!response.ok) {
return undefined;
}
const sensors = await sensorsSchema.parseAsync(await response.json());
return parseGlancesCpuTempFromSensors(sensors);
} catch {
return undefined;
}
}
private async getAllStatsAsync() {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/all"));
@@ -173,3 +190,33 @@ const allSchema = z.object({
)
.default([]),
});
const sensorsSchema = z.array(
z.object({
label: z.string(),
value: z.number(),
type: z.string().optional(),
}),
);
const cpuTempLabelPriority = ["CPU", "Package id 0"] as const;
export const parseGlancesCpuTempFromSensors = (sensors: z.infer<typeof sensorsSchema>): number | undefined => {
const temperatureSensors = sensors.filter(
(sensor) => sensor.type === "temperature_core" || sensor.type === "temperature",
);
for (const label of cpuTempLabelPriority) {
const match = temperatureSensors.find((sensor) => sensor.label === label);
if (match) {
return match.value;
}
}
const firstCore = temperatureSensors[0];
if (firstCore) {
return firstCore.value;
}
return undefined;
};
@@ -69,36 +69,24 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => {
const links: CalendarLink[] = [];
// titleSlug is only present when Lidarr returns it; guard so a missing value
// neither breaks the link nor drops the event.
if (event.artist.titleSlug) {
links.push({
href: this.externalUrl(`/artist/${event.artist.titleSlug}`).toString(),
name: "Lidarr",
logo: "/images/apps/lidarr.svg",
color: undefined,
isDark: true,
});
}
for (const link of event.artist.links) {
switch (link.name) {
case "vgmdb":
links.push({
href: link.url,
name: "VgmDB",
color: "#f5c518",
isDark: false,
logo: "/images/apps/vgmdb.svg",
});
break;
case "imdb":
links.push({
href: link.url,
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.png",
});
break;
case "last":
links.push({
href: link.url,
name: "LastFM",
color: "#cf222a",
isDark: false,
logo: "/images/apps/lastfm.svg",
});
break;
const externalLink = lidarrExternalCalendarLinks[link.name];
if (!externalLink) {
continue;
}
links.push({ ...externalLink, href: link.url });
}
return links;
@@ -109,7 +97,7 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
): z.infer<typeof lidarrCalendarEventSchema>["images"][number] | undefined => {
const flatImages = [...event.images];
const sortedImages = flatImages.sort(
const sortedImages = flatImages.toSorted(
(imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
);
@@ -149,6 +137,16 @@ const lidarrCalendarEventSchema = z.object({
title: z.string(),
overview: z.string().optional(),
images: lidarrCalendarEventImageSchema,
artist: z.object({ links: z.array(z.object({ url: z.string().url(), name: z.string() })), artistName: z.string() }),
artist: z.object({
links: z.array(z.object({ url: z.string().url(), name: z.string() })),
artistName: z.string(),
titleSlug: z.string().optional(),
}),
releaseDate: z.string().transform((value) => new Date(value)),
});
const lidarrExternalCalendarLinks: Record<string, Omit<CalendarLink, "href">> = {
vgmdb: { name: "VgmDB", color: "#f5c518", isDark: false, logo: "/images/apps/vgmdb.svg" },
imdb: { name: "IMDb", color: "#f5c518", isDark: false, logo: "/images/apps/imdb.png" },
last: { name: "LastFM", color: "#cf222a", isDark: false, logo: "/images/apps/lastfm.svg" },
};
@@ -1,11 +1,11 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { GlancesIntegration } from "../src/glances/glances-integration";
import { GlancesIntegration, parseGlancesCpuTempFromSensors } from "../src/glances/glances-integration";
// ─── module mocks ────────────────────────────────────────────────────────────
@@ -64,6 +64,19 @@ function makeResponse(body: unknown) {
} as never;
}
function mockGlancesFetch(statsBody: unknown, sensorsBody: unknown) {
mockFetch.mockImplementation(((url: string | URL) => {
const urlString = url.toString();
if (urlString.endsWith("/api/4/all")) {
return Promise.resolve(makeResponse(statsBody));
}
if (urlString.endsWith("/api/4/sensors")) {
return Promise.resolve(makeResponse(sensorsBody));
}
return Promise.reject(new Error(`Unexpected fetch URL: ${urlString}`));
}) as typeof fetchWithTrustedCertificatesAsync);
}
const integrationInput = {
id: "test-glances",
name: "Glances",
@@ -75,8 +88,12 @@ const integrationInput = {
// ─── tests ───────────────────────────────────────────────────────────────────
describe("GlancesIntegration schema", () => {
beforeEach(() => {
mockFetch.mockReset();
});
test("parses stats when quicklook is present", async () => {
mockFetch.mockResolvedValueOnce(makeResponse({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }));
mockGlancesFetch({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }, []);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
@@ -86,7 +103,7 @@ describe("GlancesIntegration schema", () => {
});
test("returns Unknown cpuModelName when quicklook is absent (macOS scenario)", async () => {
mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
mockGlancesFetch(baseStats, []);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
@@ -95,9 +112,41 @@ describe("GlancesIntegration schema", () => {
});
test("does not throw a Zod parse error when quicklook is missing", async () => {
mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
mockGlancesFetch(baseStats, []);
const integration = new GlancesIntegration(integrationInput);
await expect(integration.getSystemInfoAsync()).resolves.not.toThrow();
});
test("reads CPU temperature from sensors", async () => {
mockGlancesFetch(baseStats, [
{ label: "CPU", unit: "C", value: 58, type: "temperature_core" },
{ label: "Core 0", unit: "C", value: 54, type: "temperature_core" },
]);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
expect(result.cpuTemp).toBe(58);
});
});
describe("parseGlancesCpuTempFromSensors", () => {
test("prefers CPU label over core sensors", () => {
const result = parseGlancesCpuTempFromSensors([
{ label: "Core 0", value: 54, type: "temperature_core" },
{ label: "CPU", value: 58, type: "temperature_core" },
]);
expect(result).toBe(58);
});
test("falls back to Package id 0", () => {
const result = parseGlancesCpuTempFromSensors([
{ label: "Core 0", value: 54, type: "temperature_core" },
{ label: "Package id 0", value: 62, type: "temperature_core" },
]);
expect(result).toBe(62);
});
});
@@ -0,0 +1,76 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { LidarrIntegration } from "../src/media-organizer/lidarr/lidarr-integration";
vi.mock("@homarr/db", async (importActual) => {
const actual = await importActual<typeof import("@homarr/db")>();
return { ...actual, db: createDb() };
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
};
});
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integrationInput = {
id: "test-lidarr",
name: "Lidarr",
url: "http://localhost:8686",
decryptedSecrets: [{ kind: "apiKey" as const, value: "test-key" }],
externalUrl: null,
};
describe("LidarrIntegration calendar", () => {
test("includes Lidarr artist link in calendar events", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{
title: "Test Album",
overview: "Overview",
images: [],
artist: {
artistName: "Test Artist",
titleSlug: "test-artist",
links: [{ name: "last", url: "https://last.fm/music/Test+Artist" }],
},
releaseDate: "2026-01-01T00:00:00Z",
},
]),
} as never);
const integration = new LidarrIntegration(integrationInput);
const events = await integration.getCalendarEventsAsync(new Date("2026-01-01"), new Date("2026-01-31"));
expect(events[0]?.links[0]).toMatchObject({
name: "Lidarr",
href: "http://localhost:8686/artist/test-artist",
logo: "/images/apps/lidarr.svg",
});
// The existing external artist link must survive the Lidarr-link prepend.
expect(events[0]?.links).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "LastFM",
href: "https://last.fm/music/Test+Artist",
logo: "/images/apps/lastfm.svg",
}),
]),
);
});
});
+9
View File
@@ -5,6 +5,7 @@ export const defaultServerSettingsKeys = [
"analytics",
"crawlingAndIndexing",
"board",
"docker",
"user",
"appearance",
"culture",
@@ -30,6 +31,14 @@ export const defaultServerSettings = {
enableStatusByDefault: true,
forceDisableStatus: false,
},
docker: {
targetBoardName: null as string | null,
readHomepageLabels: true,
pruneRemoved: false,
createIntegrations: true,
defaultItemWidth: 1,
defaultItemHeight: 1,
},
user: {
enableGravatar: true,
},
+4 -2
View File
@@ -1598,7 +1598,9 @@
},
"menu": {
"label": {
"settings": "Settings"
"settings": "Settings",
"options": "Options",
"actions": "Actions"
}
},
"create": {
@@ -4311,7 +4313,7 @@
"notStarted": "Not started"
},
"name": {
"management": "Management",
"management": "Management",
"dashboard": "Dashboard"
}
}
+2
View File
@@ -8,6 +8,8 @@
transition: scale 0.2s ease-in-out;
user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
touch-action: manipulation;
}
.appWithUrl:hover > .appIcon {
+1
View File
@@ -87,6 +87,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
{options.showTitle && (
+32 -1
View File
@@ -1,3 +1,4 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
@@ -9,7 +10,35 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
export interface WidgetContextMenuAction {
key: string;
label: stringOrTranslation;
icon?: TablerIcon;
onClick: () => void;
hidden?: boolean;
disabled?: boolean;
color?: string;
}
export interface WidgetContextMenuContext {
isEditMode: boolean;
boardId: string | undefined;
itemId: string | undefined;
}
export interface WidgetContextActionProps<
TKind extends WidgetKind,
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
> {
kind: TKind;
options: inferOptionsFromDefinition<TOptions>;
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
integrationIds: string[];
context: WidgetContextMenuContext;
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
}
const createWithDynamicImport =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
@@ -56,6 +85,7 @@ export interface WidgetDefinition {
}
>
>;
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
}
export interface WidgetProps<TKind extends WidgetKind> {
@@ -70,6 +100,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
};
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
+6 -1
View File
@@ -61,7 +61,12 @@ import * as video from "./video";
import * as weather from "./weather";
import * as customApi from "./custom-api";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type {
WidgetDefinition,
WidgetContextMenuAction,
WidgetContextActionProps,
WidgetOptionsSettings,
} from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
@@ -1,6 +1,7 @@
import { IconZoomQuestion } from "@tabler/icons-react";
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -14,5 +15,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
}),
}));
},
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));
@@ -1,6 +1,7 @@
import { IconChartBar } from "@tabler/icons-react";
import { IconChartBar, IconSearch } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
@@ -9,5 +10,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
createOptions() {
return {};
},
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));
+15 -1
View File
@@ -1,5 +1,6 @@
"use client";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActionIcon,
@@ -263,8 +264,21 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
setIsEditing(handleEditToggleCallback);
}, [setIsEditing, handleEditToggleCallback]);
const handleDoubleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!canChange || isEditing) return;
// Ignore double-clicks bubbling up from interactive controls (e.g. the
// edit/save ActionIcon), which would otherwise toggle edit mode twice.
if (event.target instanceof Element && event.target.closest("button, a")) {
return;
}
setIsEditing(handleEditToggleCallback);
},
[canChange, isEditing, setIsEditing, handleEditToggleCallback],
);
return (
<Box h="100%">
<Box h="100%" onDoubleClick={handleDoubleClick}>
<RichTextEditor
p={0}
mt={0}
+2
View File
@@ -13,6 +13,7 @@ import type { ReleasesRepository } from "./releases/releases-repository";
interface CommonInput<TType> {
defaultValue?: TType;
withDescription?: boolean;
skipContextMenu?: boolean;
}
interface TextInput extends CommonInput<string> {
@@ -91,6 +92,7 @@ const optionsFactory = {
type: "switch" as const,
defaultValue: input?.defaultValue ?? false,
withDescription: input?.withDescription ?? false,
skipContextMenu: input?.skipContextMenu ?? false,
}),
text: (input?: TextInput) => ({
type: "text" as const,
+2 -2
View File
@@ -247,7 +247,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
!hasError &&
formatter.relativeTime(repository.latestReleaseAt, {
now,
style: "narrow",
style: "long",
})}
</Text>
{hasError ? (
@@ -564,7 +564,7 @@ const ExpandedDisplay = ({
<Text className="releases-repository-expanded-createdAt-date" span fw={700}>
{formatter.relativeTime(repository.createdAt, {
now,
style: "narrow",
style: "long",
})}
</Text>
</Text>
+171 -132
View File
@@ -106,8 +106,8 @@ catalogs:
specifier: ^1.61.1
version: 1.61.1
'@scalar/api-reference-react':
specifier: ^0.9.48
version: 0.9.48
specifier: ^0.9.49
version: 0.9.49
'@semantic-release/changelog':
specifier: ^6.0.3
version: 6.0.3
@@ -337,8 +337,8 @@ catalogs:
specifier: ^0.2.9
version: 0.2.9
dockerode:
specifier: ^5.0.0
version: 5.0.0
specifier: ^5.0.1
version: 5.0.1
dotenv:
specifier: ^17.4.2
version: 17.4.2
@@ -445,8 +445,8 @@ catalogs:
specifier: ^1.18.0
version: 1.18.0
posthog-js:
specifier: ^1.393.4
version: 1.393.4
specifier: ^1.395.0
version: 1.395.0
posthog-node:
specifier: ^4.18.0
version: 4.18.0
@@ -705,7 +705,7 @@ importers:
version: 0.99.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@scalar/api-reference-react':
specifier: 'catalog:'
version: 0.9.48(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
version: 0.9.49(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@signalwire/docusaurus-plugin-llms-txt':
specifier: ^1.2.2
version: 1.2.2(@docusaurus/core@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@swc/helpers@0.5.15)(esbuild@0.28.1)(postcss@8.5.15))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(@rspack/core@1.7.11(@swc/helpers@0.5.15))(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))
@@ -916,7 +916,7 @@ importers:
version: 1.29.0(zod@4.4.3)
'@scalar/api-reference-react':
specifier: 'catalog:'
version: 0.9.48(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
version: 0.9.49(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
@@ -1012,7 +1012,7 @@ importers:
version: 1.18.0(postcss@8.5.15)
posthog-js:
specifier: 'catalog:'
version: 1.393.4
version: 1.395.0
prismjs:
specifier: 'catalog:'
version: 1.30.0
@@ -1769,9 +1769,18 @@ importers:
'@homarr/core':
specifier: workspace:^0.1.0
version: link:../core
'@homarr/db':
specifier: workspace:^0.1.0
version: link:../db
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
dockerode:
specifier: 'catalog:'
version: 5.0.0
version: 5.0.1
superjson:
specifier: 'catalog:'
version: 2.2.6
devDependencies:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
@@ -1782,6 +1791,9 @@ importers:
typescript:
specifier: 'catalog:'
version: 6.0.3
vitest:
specifier: 'catalog:'
version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
packages/form:
dependencies:
@@ -6418,8 +6430,8 @@ packages:
'@polka/url@1.0.0-next.25':
resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
'@posthog/core@1.37.3':
resolution: {integrity: sha512-Dvw4CTlRVH4K5ag/B8YIHgG4E27w43MCUsXpn1iKQHIggIMN3Shq9whG4Omm3OvXPF+VikBTmpyMKUjckPxw+g==}
'@posthog/core@1.38.0':
resolution: {integrity: sha512-QLrJh0hMVpEJXHNyiJR9YhvIO5tzLDedw4UHdvB+ub4fXHMtHCA8H44qgl11HWR3ajpjxtJ8hs3HyOGVF3Zc1w==}
'@posthog/types@1.391.1':
resolution: {integrity: sha512-ASwd7Nf4pViqdYRYaNRyPYRVKWa1CcHUAUWR0XeQJLGdNnsWACBwe0sSieb/cHnKsRXjRwO/23KIY83lm/Ccpw==}
@@ -6707,46 +6719,50 @@ packages:
'@rvf/set-get@7.0.1':
resolution: {integrity: sha512-GkTSn9K1GrTYoTUqlUs36k6nJnzjQaFBTTEIqUYmzBcsGsoJM8xG7EAx2WLHWAA4QzFjcwWUSHQ3vM3Fbw50Tg==}
'@scalar/agent-chat@0.12.12':
resolution: {integrity: sha512-9PxyiNnSXuMDXcjPXgvFAPgfvUUsH/iS80Fpa0GGl3lO3ThsGDL2Lr+Y1UjxG1Tc2huGE/EDiUuT5osz9RwzMw==}
'@scalar/agent-chat@0.12.13':
resolution: {integrity: sha512-JPjwnOqIRb/TF+0bVNL7rg9SSjce3p0G++GF8uNvoGthixWuQxzo2pems4npZwe6Aw57icRTsZ3GBFgs9VmJvw==}
engines: {node: '>=22'}
'@scalar/api-client@3.11.0':
resolution: {integrity: sha512-6nxj8Saw/sfUM6xDxbMDfKKLDC933Vtig6m8hB0pSfXSlwXEvA5C9mUNO8ytodGPqBc/wphgFqTpfahOxp7R0w==}
'@scalar/api-client@3.12.0':
resolution: {integrity: sha512-NBZTMuqG9Qu+C0bUtMJPQEbGBLC5skXj55A/oDSzPwvTmWKV6I9Xc4wcrqVZMMidx315uC/WWyrB9WCUMgNi3Q==}
engines: {node: '>=22'}
'@scalar/api-reference-react@0.9.48':
resolution: {integrity: sha512-NmdQcmMDXIhKPIDRlA5m7cxGUAuiK42jOhaRsTT05TOcPXTF1lazcuN25o3ghevKtznIDqT+2/y/IYhbWS/UBw==}
'@scalar/api-reference-react@0.9.49':
resolution: {integrity: sha512-dtOrWZ++rFfsihNZKZ2pJ722bSkI3qp5CZKdiOY98PoJUWwPoSxT7C3NAn4NX59jQ0kuLhWDEELDpAcA779cvA==}
engines: {node: '>=22'}
peerDependencies:
react: ^18.0.0 || ^19.0.0
'@scalar/api-reference@1.61.0':
resolution: {integrity: sha512-r0U9Nqh3yV0BnU5mV2J/wDUcqwWorAUzXThHq0pbT09OzoKYO7asNB+d+bX5xhlLSvIuCMe5lAppQRfjlXzxWg==}
'@scalar/api-reference@1.62.0':
resolution: {integrity: sha512-QYPqMPfy4vyVLysHeSFZsD+717ua/gHIhYYu7UjqPzJUOVh2R+/9hINKKzvoP13lQWCQTmuzs2VEl8rQOIfDjw==}
engines: {node: '>=22'}
'@scalar/code-highlight@0.3.6':
resolution: {integrity: sha512-G/EtLF6a3IFGfIv5b7Uj5v/yXFY9+ktViVkfAkSoWJdHNBNk0S/Rb2OgRq24kp0D2TDXhLYF3E9OSHzquS7WqQ==}
'@scalar/blocks@0.1.0':
resolution: {integrity: sha512-oFnIa6dAkCt0N5yw5aqCABbP7r7YsI7ERNrGtlTDdrCteDRatMJg9y03LT+9Vhf2/K3VHWrdCZMruUdfU1uPzQ==}
engines: {node: '>=22'}
'@scalar/components@0.27.3':
resolution: {integrity: sha512-WiCMId4MFsbne1iy5N36Ej4fM+1TX6gg4DkbjWSNPTDtVWLMQ6VHdl46fxC6yOCtO59awJ7Ex6oKpbaXQsZiCA==}
'@scalar/code-highlight@0.4.0':
resolution: {integrity: sha512-ajUQ9oq5MwVHrXGze0SZVatgSXP/WbN4qgE0usYDFt1XuJ+O56TaBsCWb043r3Y9ZnHSnK9GHreVaLOxXsbf1A==}
engines: {node: '>=22'}
'@scalar/helpers@0.8.2':
resolution: {integrity: sha512-qNbqUjSB3S4Gr4A0oANcm5G1Ip+EqBxICYKhe9YzmnaBpbmW6shxqpiivApTvvuDf+uIhR3uMwWyVQbYcGLsxA==}
'@scalar/components@0.27.4':
resolution: {integrity: sha512-1sbsMYGJcWiQ+5GouI1hHCn1VrytHPhzhHhCAZTXxi1Tw4SzsPSQuACARehXca+w2p7RK9ayGRYQ+K5bX18SaQ==}
engines: {node: '>=22'}
'@scalar/helpers@0.9.0':
resolution: {integrity: sha512-M34CLRCttqC1bXthI/QSzQj0s5C6nrU2PFWf/vOT3RpycbiGDGQbqR+5RfFzpOIQvRqbHfNdcRbeiZBw+vCbkQ==}
engines: {node: '>=22'}
'@scalar/icons@0.7.3':
resolution: {integrity: sha512-5uSUvumj6yJEAZT7/MKpgrkNl76waDXUpu0kUBBFJ83GhZirBIK+Z9SktShEvJT0+rk/j9Zaer3BHoEhYwAEBQ==}
engines: {node: '>=22'}
'@scalar/json-magic@0.12.16':
resolution: {integrity: sha512-w8cDbZhHCzmIblWx92IVWoAXsbI4Fz3m++jiBANTSO1hgphF6UqEPQiCt3wnMPaxaanjMQxjS/iBk1UGXR2EGA==}
'@scalar/json-magic@0.12.17':
resolution: {integrity: sha512-Vw2nrUDIjhvMP6vxFtkiiFlabJ6SyTtfn1BsOxgnr1hIB+/rkngMguiDzl5em21VjyfFGIoADia+QWKM2hdcdA==}
engines: {node: '>=22'}
'@scalar/oas-utils@0.19.0':
resolution: {integrity: sha512-t9CBiH0we2rKlFaFJUt+dOAKYQPNhHdrrfX9Wx1Yfg9og1NM2H2BQSO7NoCaUN/+G86GNWIm+fr5R8iCM+/psw==}
'@scalar/oas-utils@0.19.1':
resolution: {integrity: sha512-FflVWiFDm0s4a+FqKJ0uYsVhDzbAtVyXSyUWsGe9BGPPQSXYYvVEVp9H2ir4xueaw4TZb1cDEZbteHLx7h7vqg==}
engines: {node: '>=22'}
'@scalar/openapi-types@0.9.1':
@@ -6757,27 +6773,27 @@ packages:
resolution: {integrity: sha512-D5b0rGLLZgmkO9mdW2j/ND1KBlH1u3RCpr87HPxv9P9ZSr6PtM5iLqFOJq0ACiaHjY2mikCrxgDmnUEhTzRpHQ==}
engines: {node: '>=22'}
'@scalar/schemas@0.6.0':
resolution: {integrity: sha512-Oacaq7RTLhe3vWOoNc8tZufqthetu1VjWkdrGMMYoVSyMBQpvDI48gwBBQO6+iySeNpk9y8NHaGxqhHeoLbDiQ==}
'@scalar/schemas@0.7.0':
resolution: {integrity: sha512-Roj0e7S29yTbGojwdx/b8C1H5arVN4h/VSPrtQ5vb9NT2ZAbrfPQVmpQYrF845rKOWX7kSzIvyBwrKNEMIAkxA==}
engines: {node: '>=22'}
'@scalar/sidebar@0.9.24':
resolution: {integrity: sha512-whfvictmBQrtdNANLzDe4cXNlhj3p1RUtSbwiSERf5TSYCQwU5J2JAG2zUPUJhNdpNUzg1wdQnE5Q3lp+MAcHQ==}
'@scalar/sidebar@0.9.25':
resolution: {integrity: sha512-RoMKTvCo801kj5J59Myqhc6YYtvjlXGNvP7qbj/TY2hfQ4XS5dH5S7t8JKJje+78MLC2Xg9OsEmCjv+gb8ta4w==}
engines: {node: '>=22'}
'@scalar/snippetz@0.9.18':
resolution: {integrity: sha512-C0lflx3Az9epM4dFsDl73w5fxgyw68ZMTr8W0fkLHJvWTM8q26jjDDrgDCDivG0SBc2UUs3xRKSjjsfC6TkZKw==}
'@scalar/snippetz@0.9.19':
resolution: {integrity: sha512-RVTZQqUxOiBhAuPywDmser1xOatVj7HCNmg+qpW8172MsMpx00lRGZ940WVAhjHgKglTkwHPaTdVX7gQeDrFUQ==}
engines: {node: '>=22'}
'@scalar/themes@0.16.1':
resolution: {integrity: sha512-yTMjqlTAYZrlJsHIuDiCVJjMJZ1UzL33C/60c88JYuvuuwSXm15TAEcLXkRrPa7u8PPz/llKGgWjMEiMhsOAGA==}
'@scalar/themes@0.16.2':
resolution: {integrity: sha512-4mAn7z2W5/ASi1OF06dqf04xjxTHnMzESC2E+qVMukJsEsV4kDlYSIfm/g5hwWxhqWsBMjorF9Dtlqd0ycJ92g==}
engines: {node: '>=22'}
'@scalar/typebox@0.1.3':
resolution: {integrity: sha512-lU055AUccECZMIfGA0z/C1StYmboAYIPJLDFBzOO81yXBi35Pxdq+I4fWX6iUZ8qcoHneiLGk9jAUM1rA93iEg==}
'@scalar/types@0.15.0':
resolution: {integrity: sha512-Rn9XAFrnQL4cAINCyNlPdvSbd3+Cvqgv2SBX1J8WgtZLxqC32+8Ptmns51Lvn6zKeiX+mFHk1UdvIWmqiG1xRg==}
'@scalar/types@0.16.0':
resolution: {integrity: sha512-26OSrvZjKWZ7F236wWmJajBGDVFJuvXFJqKPFqbt/PxlgZKXQXfJsZorASRQmdNogT576nxYalQ7oaYWEnQwfw==}
engines: {node: '>=22'}
'@scalar/use-codemirror@0.14.12':
@@ -6796,8 +6812,8 @@ packages:
resolution: {integrity: sha512-tpmmG+/xRE2Kn9RpflU3AIyZv08v10+E1ZrJCx7z6+/91zHVxy0M73kC1LT4/8PbYNt85ywyC8+n+D99JdMcGA==}
engines: {node: '>=20'}
'@scalar/workspace-store@0.54.5':
resolution: {integrity: sha512-MMaELRxKNwJ1dxbDcFNRwh229jAX5DQhkQpHgDSbr+oAt6AqFf2BTr9Fr/8kbLIJK+L4P2C8rANE8xrnqjKGLg==}
'@scalar/workspace-store@0.55.0':
resolution: {integrity: sha512-4EkaLfONW8qu2vigJopbjg9ahE/Z9Q+IInnVoUTZfxLp4rb2Z3ztx8LmQOym8DPpv62BvuMtJcNdQOnQ6tVLHA==}
engines: {node: '>=22'}
'@schummar/icu-type-parser@1.21.5':
@@ -9678,6 +9694,10 @@ packages:
resolution: {integrity: sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ==}
engines: {node: '>= 14.17'}
dockerode@5.0.1:
resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==}
engines: {node: '>= 14.17'}
docusaurus-plugin-image-zoom@3.0.1:
resolution: {integrity: sha512-mQrqA99VpoMQJNbi02qkWAMVNC4+kwc6zLLMNzraHAJlwn+HrlUmZSEDcTwgn+H4herYNxHKxveE2WsYy73eGw==}
peerDependencies:
@@ -12009,11 +12029,6 @@ packages:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
nanoid@5.1.15:
resolution: {integrity: sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==}
engines: {node: ^18 || >=20}
hasBin: true
nanoid@5.1.16:
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
engines: {node: ^18 || >=20}
@@ -13116,8 +13131,8 @@ packages:
resolution: {integrity: sha512-Ray65LYEJrMMqDtsBUBXunEVP/g4wtATvq/xz9rchUoLy/9mSkkFgUko/8DVtGxgiP3vivpFMgfb9HpCuDrBHg==}
engines: {node: '>=10.15.1'}
posthog-js@1.393.4:
resolution: {integrity: sha512-mOBSVQczEfyLnW3aFRbLXZEuE0Rfbmhqm9UCnuLeMFUUz0uYTkbnyhl5+Uc+BLSGfyiXHLf3nm2BqxYC+G/PXA==}
posthog-js@1.395.0:
resolution: {integrity: sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==}
posthog-node@4.18.0:
resolution: {integrity: sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==}
@@ -17951,7 +17966,7 @@ snapshots:
'@esbuild-kit/core-utils@3.3.2':
dependencies:
esbuild: 0.28.0
esbuild: 0.28.1
source-map-support: 0.5.21
'@esbuild-kit/esm-loader@2.6.5':
@@ -19897,7 +19912,7 @@ snapshots:
'@polka/url@1.0.0-next.25': {}
'@posthog/core@1.37.3':
'@posthog/core@1.38.0':
dependencies:
'@posthog/types': 1.391.1
@@ -20116,21 +20131,21 @@ snapshots:
'@rvf/set-get@7.0.1': {}
'@scalar/agent-chat@0.12.12(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
'@scalar/agent-chat@0.12.13(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
dependencies:
'@ai-sdk/vue': 3.0.33(vue@3.5.35(typescript@6.0.3))(zod@4.4.3)
'@scalar/api-client': 3.11.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/components': 0.27.3(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.8.2
'@scalar/api-client': 3.12.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/components': 0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/json-magic': 0.12.16
'@scalar/json-magic': 0.12.17
'@scalar/openapi-types': 0.9.1
'@scalar/schemas': 0.6.0
'@scalar/themes': 0.16.1
'@scalar/types': 0.15.0
'@scalar/schemas': 0.7.0
'@scalar/themes': 0.16.2
'@scalar/types': 0.16.0
'@scalar/use-toasts': 0.10.2(typescript@6.0.3)
'@scalar/validation': 0.6.0
'@scalar/workspace-store': 0.54.5(typescript@6.0.3)
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
'@vueuse/core': 13.9.0(vue@3.5.35(typescript@6.0.3))
ai: 6.0.33(zod@4.4.3)
js-base64: 3.7.8
@@ -20154,33 +20169,32 @@ snapshots:
- universal-cookie
- zod
'@scalar/api-client@3.11.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)':
'@scalar/api-client@3.12.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)':
dependencies:
'@headlessui/tailwindcss': 0.2.2(tailwindcss@4.3.1)
'@headlessui/vue': 1.7.23(vue@3.5.35(typescript@6.0.3))
'@scalar/components': 0.27.3(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.8.2
'@scalar/blocks': 0.1.0(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/components': 0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/json-magic': 0.12.16
'@scalar/oas-utils': 0.19.0(typescript@6.0.3)
'@scalar/oas-utils': 0.19.1(typescript@6.0.3)
'@scalar/openapi-types': 0.9.1
'@scalar/sidebar': 0.9.24(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/snippetz': 0.9.18
'@scalar/themes': 0.16.1
'@scalar/sidebar': 0.9.25(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/snippetz': 0.9.19
'@scalar/themes': 0.16.2
'@scalar/typebox': 0.1.3
'@scalar/types': 0.15.0
'@scalar/types': 0.16.0
'@scalar/use-codemirror': 0.14.12(typescript@6.0.3)
'@scalar/use-hooks': 0.4.7(typescript@6.0.3)
'@scalar/use-toasts': 0.10.2(typescript@6.0.3)
'@scalar/workspace-store': 0.54.5(typescript@6.0.3)
'@types/har-format': 1.2.16
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
'@vueuse/core': 13.9.0(vue@3.5.35(typescript@6.0.3))
'@vueuse/integrations': 13.9.0(focus-trap@7.8.0)(fuse.js@7.4.2)(nprogress@0.2.0)(vue@3.5.35(typescript@6.0.3))
focus-trap: 7.8.0
fuse.js: 7.4.2
js-base64: 3.7.8
jsonc-parser: 3.3.1
nanoid: 5.1.15
nanoid: 5.1.16
pretty-ms: 9.3.0
radix-vue: 1.9.17(vue@3.5.35(typescript@6.0.3))
set-cookie-parser: 3.1.0
@@ -20203,10 +20217,10 @@ snapshots:
- typescript
- universal-cookie
'@scalar/api-reference-react@0.9.48(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
'@scalar/api-reference-react@0.9.49(nprogress@0.2.0)(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
dependencies:
'@scalar/api-reference': 1.61.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@scalar/types': 0.15.0
'@scalar/api-reference': 1.62.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@scalar/types': 0.16.0
react: 19.2.7
transitivePeerDependencies:
- '@vue/composition-api'
@@ -20225,30 +20239,31 @@ snapshots:
- universal-cookie
- zod
'@scalar/api-reference@1.61.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
'@scalar/api-reference@1.62.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)':
dependencies:
'@headlessui/vue': 1.7.23(vue@3.5.35(typescript@6.0.3))
'@scalar/agent-chat': 0.12.12(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@scalar/api-client': 3.11.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/code-highlight': 0.3.6
'@scalar/components': 0.27.3(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.8.2
'@scalar/agent-chat': 0.12.13(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)
'@scalar/api-client': 3.12.0(nprogress@0.2.0)(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/blocks': 0.1.0(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/code-highlight': 0.4.0
'@scalar/components': 0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/oas-utils': 0.19.0(typescript@6.0.3)
'@scalar/schemas': 0.6.0
'@scalar/sidebar': 0.9.24(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/snippetz': 0.9.18
'@scalar/themes': 0.16.1
'@scalar/types': 0.15.0
'@scalar/oas-utils': 0.19.1(typescript@6.0.3)
'@scalar/schemas': 0.7.0
'@scalar/sidebar': 0.9.25(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/snippetz': 0.9.19
'@scalar/themes': 0.16.2
'@scalar/types': 0.16.0
'@scalar/use-hooks': 0.4.7(typescript@6.0.3)
'@scalar/use-toasts': 0.10.2(typescript@6.0.3)
'@scalar/validation': 0.6.0
'@scalar/workspace-store': 0.54.5(typescript@6.0.3)
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
'@unhead/vue': 2.1.15(vue@3.5.35(typescript@6.0.3))
'@vueuse/core': 13.9.0(vue@3.5.35(typescript@6.0.3))
fuse.js: 7.4.2
microdiff: 1.5.0
nanoid: 5.1.15
nanoid: 5.1.16
vue: 3.5.35(typescript@6.0.3)
yaml: 2.9.0
transitivePeerDependencies:
@@ -20268,7 +20283,25 @@ snapshots:
- universal-cookie
- zod
'@scalar/code-highlight@0.3.6':
'@scalar/blocks@0.1.0(tailwindcss@4.3.1)(typescript@6.0.3)':
dependencies:
'@scalar/components': 0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/snippetz': 0.9.19
'@scalar/themes': 0.16.2
'@scalar/types': 0.16.0
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
'@types/har-format': 1.2.16
js-base64: 3.7.8
vue: 3.5.35(typescript@6.0.3)
transitivePeerDependencies:
- '@vue/composition-api'
- supports-color
- tailwindcss
- typescript
'@scalar/code-highlight@0.4.0':
dependencies:
hast-util-to-text: 4.0.2
highlight.js: 11.11.1
@@ -20288,16 +20321,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@scalar/components@0.27.3(tailwindcss@4.3.1)(typescript@6.0.3)':
'@scalar/components@0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/vue': 1.1.9(vue@3.5.35(typescript@6.0.3))
'@headlessui/tailwindcss': 0.2.2(tailwindcss@4.3.1)
'@headlessui/vue': 1.7.23(vue@3.5.35(typescript@6.0.3))
'@scalar/code-highlight': 0.3.6
'@scalar/helpers': 0.8.2
'@scalar/code-highlight': 0.4.0
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/themes': 0.16.1
'@scalar/themes': 0.16.2
'@scalar/use-hooks': 0.4.7(typescript@6.0.3)
'@vueuse/core': 13.9.0(vue@3.5.35(typescript@6.0.3))
cva: 1.0.0-beta.4(typescript@6.0.3)
@@ -20310,7 +20343,7 @@ snapshots:
- tailwindcss
- typescript
'@scalar/helpers@0.8.2': {}
'@scalar/helpers@0.9.0': {}
'@scalar/icons@0.7.3(typescript@6.0.3)':
dependencies:
@@ -20321,18 +20354,18 @@ snapshots:
transitivePeerDependencies:
- typescript
'@scalar/json-magic@0.12.16':
'@scalar/json-magic@0.12.17':
dependencies:
'@scalar/helpers': 0.8.2
'@scalar/helpers': 0.9.0
pathe: 2.0.3
yaml: 2.9.0
'@scalar/oas-utils@0.19.0(typescript@6.0.3)':
'@scalar/oas-utils@0.19.1(typescript@6.0.3)':
dependencies:
'@scalar/helpers': 0.8.2
'@scalar/themes': 0.16.1
'@scalar/types': 0.15.0
'@scalar/workspace-store': 0.54.5(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/themes': 0.16.2
'@scalar/types': 0.16.0
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
flatted: 3.4.2
vue: 3.5.35(typescript@6.0.3)
yaml: 2.9.0
@@ -20345,19 +20378,19 @@ snapshots:
dependencies:
'@scalar/openapi-types': 0.9.1
'@scalar/schemas@0.6.0':
'@scalar/schemas@0.7.0':
dependencies:
'@scalar/helpers': 0.8.2
'@scalar/helpers': 0.9.0
'@scalar/validation': 0.6.0
'@scalar/sidebar@0.9.24(tailwindcss@4.3.1)(typescript@6.0.3)':
'@scalar/sidebar@0.9.25(tailwindcss@4.3.1)(typescript@6.0.3)':
dependencies:
'@scalar/components': 0.27.3(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.8.2
'@scalar/components': 0.27.4(tailwindcss@4.3.1)(typescript@6.0.3)
'@scalar/helpers': 0.9.0
'@scalar/icons': 0.7.3(typescript@6.0.3)
'@scalar/themes': 0.16.1
'@scalar/themes': 0.16.2
'@scalar/use-hooks': 0.4.7(typescript@6.0.3)
'@scalar/workspace-store': 0.54.5(typescript@6.0.3)
'@scalar/workspace-store': 0.55.0(typescript@6.0.3)
vue: 3.5.35(typescript@6.0.3)
transitivePeerDependencies:
- '@vue/composition-api'
@@ -20365,24 +20398,24 @@ snapshots:
- tailwindcss
- typescript
'@scalar/snippetz@0.9.18':
'@scalar/snippetz@0.9.19':
dependencies:
'@scalar/helpers': 0.8.2
'@scalar/types': 0.15.0
'@scalar/helpers': 0.9.0
'@scalar/types': 0.16.0
js-base64: 3.7.8
stringify-object: 6.0.0
'@scalar/themes@0.16.1':
'@scalar/themes@0.16.2':
dependencies:
nanoid: 5.1.15
nanoid: 5.1.16
'@scalar/typebox@0.1.3': {}
'@scalar/types@0.15.0':
'@scalar/types@0.16.0':
dependencies:
'@scalar/helpers': 0.8.2
nanoid: 5.1.15
type-fest: 5.6.0
'@scalar/helpers': 0.9.0
nanoid: 5.1.16
type-fest: 5.7.0
zod: 4.4.3
'@scalar/use-codemirror@0.14.12(typescript@6.0.3)':
@@ -20425,18 +20458,18 @@ snapshots:
'@scalar/validation@0.6.0': {}
'@scalar/workspace-store@0.54.5(typescript@6.0.3)':
'@scalar/workspace-store@0.55.0(typescript@6.0.3)':
dependencies:
'@scalar/helpers': 0.8.2
'@scalar/json-magic': 0.12.16
'@scalar/helpers': 0.9.0
'@scalar/json-magic': 0.12.17
'@scalar/openapi-upgrader': 0.2.9
'@scalar/schemas': 0.6.0
'@scalar/snippetz': 0.9.18
'@scalar/schemas': 0.7.0
'@scalar/snippetz': 0.9.19
'@scalar/typebox': 0.1.3
'@scalar/types': 0.15.0
'@scalar/types': 0.16.0
'@scalar/validation': 0.6.0
js-base64: 3.7.8
type-fest: 5.6.0
type-fest: 5.7.0
vue: 3.5.35(typescript@6.0.3)
yaml: 2.9.0
transitivePeerDependencies:
@@ -21764,7 +21797,6 @@ snapshots:
optionalDependencies:
msw: 2.14.6(@types/node@24.13.2)(typescript@6.0.3)
vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
optional: true
'@vitest/pretty-format@4.1.9':
dependencies:
@@ -23479,6 +23511,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
dockerode@5.0.1:
dependencies:
'@balena/dockerignore': 1.0.2
'@grpc/grpc-js': 1.12.5
'@grpc/proto-loader': 0.7.13
docker-modem: 5.0.7
protobufjs: 7.4.0
tar-fs: 2.1.4
transitivePeerDependencies:
- supports-color
docusaurus-plugin-image-zoom@3.0.1(@docusaurus/theme-classic@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@swc/helpers@0.5.15)(esbuild@0.28.1)(postcss@8.5.15))(@rspack/core@1.7.11(@swc/helpers@0.5.15))(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)):
dependencies:
'@docusaurus/theme-classic': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@swc/helpers@0.5.15)(esbuild@0.28.1)(postcss@8.5.15))(@rspack/core@1.7.11(@swc/helpers@0.5.15))(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
@@ -26327,8 +26370,6 @@ snapshots:
nanoid@3.3.12: {}
nanoid@5.1.15: {}
nanoid@5.1.16: {}
nanostores@1.3.0: {}
@@ -27405,9 +27446,9 @@ snapshots:
posthog-docusaurus@2.0.5: {}
posthog-js@1.393.4:
posthog-js@1.395.0:
dependencies:
'@posthog/core': 1.37.3
'@posthog/core': 1.38.0
'@posthog/types': 1.391.1
core-js: 3.49.0
dompurify: 3.4.11
@@ -29600,7 +29641,6 @@ snapshots:
terser: 5.46.1
tsx: 4.21.0
yaml: 2.9.0
optional: true
vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
@@ -29663,7 +29703,6 @@ snapshots:
jsdom: 29.1.1(@noble/hashes@2.0.1)
transitivePeerDependencies:
- msw
optional: true
vue-component-type-helpers@3.3.4: {}
+3 -3
View File
@@ -88,11 +88,11 @@ catalog:
"@types/prismjs": ^1.26.6
"@types/react": 19.2.17
"@types/react-dom": 19.2.3
"@scalar/api-reference-react": ^0.9.48
"@scalar/api-reference-react": ^0.9.49
"@types/video.js": ^7.3.58
"@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14
posthog-js: ^1.393.4
posthog-js: ^1.395.0
posthog-node: ^4.18.0
"@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.9
@@ -118,7 +118,7 @@ catalog:
dayjs: ^1.11.21
deepmerge: 4.3.1
dns-caching: ^0.2.9
dockerode: ^5.0.0
dockerode: ^5.0.1
dotenv: ^17.4.2
dotenv-cli: ^11.0.0
drizzle-kit: ^0.31.10
+1 -1
View File
@@ -7,7 +7,7 @@ runs:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24.17.0
node-version: 24.18.0
cache: "pnpm"
- shell: bash