fix: address release v2 reviews
This commit is contained in:
1 parent
4905ea6738
commit
32732680e5
55 files changed
+925
-294
No files matched your search
@@ -98,6 +98,9 @@ jobs:
|
||||
- name: Validate feature contracts
|
||||
run: pnpm check:feature-contracts
|
||||
|
||||
- name: Test feature platform
|
||||
run: pnpm test:feature-platform
|
||||
|
||||
- name: Validate Custom Widget bundle budget
|
||||
run: |
|
||||
pnpm --filter nextjs build
|
||||
|
||||
@@ -86,6 +86,30 @@ Board layout responses identify each layout with a `mobile`, `base`, or `custom`
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
## Docker action payload migration
|
||||
|
||||
Docker lifecycle and removal actions now identify each container with both its Docker endpoint and container ID. This is a
|
||||
breaking change for tRPC and MCP clients that previously sent an `ids` array to the start, stop, restart, or remove action.
|
||||
|
||||
Replace the old payload:
|
||||
|
||||
```json
|
||||
{ "ids": ["container-id"] }
|
||||
```
|
||||
|
||||
with endpoint-qualified targets:
|
||||
|
||||
```json
|
||||
{
|
||||
"targets": [{ "endpointId": "local", "id": "container-id" }]
|
||||
}
|
||||
```
|
||||
|
||||
Read `endpointId` and `id` from `docker.getContainers` (MCP tool `docker_getContainers`) immediately before the action.
|
||||
Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.restartAll`, or `docker.removeAll`; their MCP tool
|
||||
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
|
||||
Docker or Podman endpoints from targeting the wrong service.
|
||||
|
||||
## Partial integration failures
|
||||
|
||||
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
|
||||
|
||||
+13
-2
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox } from "./docker-reconciliation-inbox";
|
||||
import {
|
||||
dismissDockerReconciliationCandidate,
|
||||
filterDockerReconciliationInbox,
|
||||
getValidDockerServiceUrl,
|
||||
} from "./docker-reconciliation-inbox";
|
||||
|
||||
const candidates = [
|
||||
{ candidateKey: "home:new", state: "newRecognized" as const },
|
||||
{ candidateKey: "home:new-app", state: "newApp" as const },
|
||||
{ candidateKey: "remote:moved", state: "moved" as const },
|
||||
{ candidateKey: "home:linked", state: "linked" as const },
|
||||
{ candidateKey: "home:represented", state: "represented" as const },
|
||||
@@ -13,7 +18,7 @@ describe("Docker reconciliation inbox", () => {
|
||||
test("separates attention items from represented services", () => {
|
||||
expect(
|
||||
filterDockerReconciliationInbox(candidates, "attention", []).map(({ candidateKey }) => candidateKey),
|
||||
).toEqual(["home:new", "remote:moved"]);
|
||||
).toEqual(["home:new", "home:new-app", "remote:moved"]);
|
||||
expect(
|
||||
filterDockerReconciliationInbox(candidates, "represented", []).map(({ candidateKey }) => candidateKey),
|
||||
).toEqual(["home:linked", "home:represented"]);
|
||||
@@ -31,4 +36,10 @@ describe("Docker reconciliation inbox", () => {
|
||||
});
|
||||
expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed);
|
||||
});
|
||||
|
||||
test("accepts only HTTP service URLs", () => {
|
||||
expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/");
|
||||
expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull();
|
||||
expect(getValidDockerServiceUrl("service.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -23,3 +23,12 @@ export const filterDockerReconciliationInbox = <Candidate extends DockerReconcil
|
||||
|
||||
export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) =>
|
||||
dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey];
|
||||
|
||||
export const getValidDockerServiceUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
@@ -23,10 +23,15 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox";
|
||||
import { dismissDockerReconciliationCandidate, filterDockerReconciliationInbox } from "./docker-reconciliation-inbox";
|
||||
import {
|
||||
dismissDockerReconciliationCandidate,
|
||||
filterDockerReconciliationInbox,
|
||||
getValidDockerServiceUrl,
|
||||
} from "./docker-reconciliation-inbox";
|
||||
|
||||
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
|
||||
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
|
||||
@@ -44,6 +49,12 @@ export const DockerReconciliation = () => {
|
||||
utils.docker.getServiceHealth.invalidate(),
|
||||
]);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("refreshError.title"),
|
||||
message: t("refreshError.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const [filter, setFilter] = useState<DockerReconciliationInboxFilter>("attention");
|
||||
const [dismissedCandidateKeys, setDismissedCandidateKeys] = useLocalStorage<string[]>({
|
||||
@@ -51,6 +62,19 @@ export const DockerReconciliation = () => {
|
||||
defaultValue: [],
|
||||
});
|
||||
|
||||
if (reconciliation.isError) {
|
||||
return (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("loadError.title")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("loadError.message")}</Text>
|
||||
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void reconciliation.refetch()}>
|
||||
{t("action.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!reconciliation.data) return null;
|
||||
|
||||
const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable");
|
||||
@@ -156,7 +180,16 @@ const DockerReconciliationCandidate = ({
|
||||
const [selectedCandidateId, setSelectedCandidateId] = useState(initialUrlCandidate?.id ?? null);
|
||||
const [url, setUrl] = useState(initialUrlCandidate?.url ?? "");
|
||||
const selectedCandidate = candidate.urlCandidates.find(({ id }) => id === selectedCandidateId);
|
||||
const target = getCandidateTarget(candidate, url);
|
||||
const validatedUrl = getValidDockerServiceUrl(url);
|
||||
const isInvalidUrl = url.length > 0 && validatedUrl === null;
|
||||
const target = getCandidateTarget(candidate, validatedUrl ?? "");
|
||||
const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration";
|
||||
const isActionDisabled = actionNeedsUrl && validatedUrl === null;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCandidateId(initialUrlCandidate?.id ?? null);
|
||||
setUrl(initialUrlCandidate?.url ?? "");
|
||||
}, [initialUrlCandidate?.id, initialUrlCandidate?.url]);
|
||||
|
||||
return (
|
||||
<Card withBorder padding="md">
|
||||
@@ -224,8 +257,11 @@ const DockerReconciliationCandidate = ({
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("url.inputLabel")}
|
||||
aria-label={t("url.inputLabel")}
|
||||
value={url}
|
||||
placeholder="https://service.example.com"
|
||||
error={isInvalidUrl ? t("url.invalid") : undefined}
|
||||
onChange={(event) => setUrl(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
@@ -237,14 +273,27 @@ const DockerReconciliationCandidate = ({
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={() =>
|
||||
openModal({ selectedContainers: [candidate.container], initialUrls: url ? [url] : undefined })
|
||||
openModal({
|
||||
selectedContainers: [candidate.container],
|
||||
initialUrls: validatedUrl ? [validatedUrl] : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("action.createApp")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button component={Link} href={target.href} variant="light" rightSection={<IconArrowRight size={16} />}>
|
||||
<Button
|
||||
component={Link}
|
||||
href={target.href}
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={(event) => {
|
||||
if (isActionDisabled) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t(`action.${target.kind}`)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -24,10 +24,11 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
|
||||
|
||||
const { containerId } = await params;
|
||||
const { endpointId, name } = await searchParams;
|
||||
if (!endpointId) notFound();
|
||||
const resolvedEndpointId = endpointId ?? (await getOnlyDockerEndpointIdAsync());
|
||||
if (!resolvedEndpointId) notFound();
|
||||
|
||||
try {
|
||||
await api.docker.logs({ endpointId, id: containerId, tail: 1 });
|
||||
await api.docker.logs({ endpointId: resolvedEndpointId, id: containerId, tail: 1 });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
@@ -41,8 +42,17 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
|
||||
/>
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideDockerLogsTerminal endpointId={endpointId} containerId={containerId} />
|
||||
<ClientSideDockerLogsTerminal endpointId={resolvedEndpointId} containerId={containerId} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getOnlyDockerEndpointIdAsync = async () => {
|
||||
try {
|
||||
const { endpoints } = await api.docker.getContainers();
|
||||
return endpoints.length === 1 ? endpoints[0]?.id : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
+10
-48
@@ -4,69 +4,31 @@ export type ResponseFixtureId = "loading" | "success" | "failure";
|
||||
|
||||
export interface CatalogStateFixture {
|
||||
id: CatalogFixtureId;
|
||||
label: string;
|
||||
status: string;
|
||||
description: string;
|
||||
color: "green" | "yellow" | "gray" | "red";
|
||||
}
|
||||
|
||||
export const catalogStateFixtures: readonly CatalogStateFixture[] = [
|
||||
{
|
||||
id: "ready",
|
||||
label: "Ready service",
|
||||
status: "Ready",
|
||||
description: "Configured and available to three widgets.",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
id: "needsSetup",
|
||||
label: "Service needing setup",
|
||||
status: "Needs setup",
|
||||
description: "Credentials or a service URL are still required.",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
id: "noConnection",
|
||||
label: "Standalone feature",
|
||||
status: "No connection required",
|
||||
description: "This feature works without a configured integration.",
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
label: "Unreachable service",
|
||||
status: "Connection failed",
|
||||
description: "The saved connection could not be reached.",
|
||||
color: "red",
|
||||
},
|
||||
{ id: "ready", color: "green" },
|
||||
{ id: "needsSetup", color: "yellow" },
|
||||
{ id: "noConnection", color: "gray" },
|
||||
{ id: "error", color: "red" },
|
||||
];
|
||||
|
||||
export const catalogInteractionFixtures: readonly { id: CatalogInteractionId; label: string }[] = [
|
||||
{ id: "default", label: "Default" },
|
||||
{ id: "selected", label: "Selected" },
|
||||
{ id: "loading", label: "Loading" },
|
||||
{ id: "disabled", label: "Disabled" },
|
||||
export const catalogInteractionFixtures: readonly CatalogInteractionId[] = [
|
||||
"default",
|
||||
"selected",
|
||||
"loading",
|
||||
"disabled",
|
||||
];
|
||||
|
||||
export const responseStateFixtures = [
|
||||
{
|
||||
id: "loading",
|
||||
label: "Loading",
|
||||
title: "Checking response",
|
||||
description: "The parser is waiting for the integration response.",
|
||||
},
|
||||
{ id: "loading" },
|
||||
{
|
||||
id: "success",
|
||||
label: "Success",
|
||||
title: "Response accepted",
|
||||
description: "The upstream payload matched the integration contract.",
|
||||
payload: { status: "ok", service: "fixture-service", version: "2.0" },
|
||||
},
|
||||
{
|
||||
id: "failure",
|
||||
label: "Failure",
|
||||
title: "Response rejected",
|
||||
description: "The upstream payload failed schema validation and stays isolated from the widget.",
|
||||
payload: { status: 503, message: "Service unavailable" },
|
||||
},
|
||||
] as const;
|
||||
+15
@@ -10,6 +10,21 @@ import { FeatureStateWorkbench } from "./_feature-state-workbench";
|
||||
import { catalogStateFixtures, responseStateFixtures } from "./_feature-state-fixtures";
|
||||
import { getResponseContractFixtureResultsAsync } from "./_response-contract-fixtures";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) =>
|
||||
({
|
||||
"catalog.state.needsSetup.label": "Service needing setup",
|
||||
"catalog.state.needsSetup.status": "Needs setup",
|
||||
"catalog.interaction.loading": "Loading",
|
||||
"catalog.interaction.disabled": "Disabled",
|
||||
"response.state.failure.label": "Failure",
|
||||
"response.state.failure.title": "Response rejected",
|
||||
"response.contractPassed": "Fixture contract passed",
|
||||
"catalog.loadingAriaLabel": "Loading catalog item",
|
||||
"catalog.loading": "Loading",
|
||||
})[key] ?? key,
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
|
||||
+36
-26
@@ -17,6 +17,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { IconAlertCircle, IconCircleCheck, IconPlugConnected, IconPlugConnectedX } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { CatalogItem } from "@homarr/ui";
|
||||
|
||||
import { catalogInteractionFixtures, catalogStateFixtures, responseStateFixtures } from "./_feature-state-fixtures";
|
||||
@@ -28,6 +29,7 @@ interface FeatureStateWorkbenchProps {
|
||||
}
|
||||
|
||||
export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbenchProps) {
|
||||
const t = useScopedI18n("featureWorkbench");
|
||||
const [catalogFixtureId, setCatalogFixtureId] = useState<CatalogFixtureId>("ready");
|
||||
const [interactionId, setInteractionId] = useState<CatalogInteractionId>("default");
|
||||
const [responseFixtureId, setResponseFixtureId] = useState<ResponseFixtureId>("loading");
|
||||
@@ -38,6 +40,8 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
|
||||
const busy = interactionId === "loading";
|
||||
const disabled = interactionId === "disabled";
|
||||
const selected = interactionId === "selected";
|
||||
const catalogLabel = t(`catalog.state.${catalogFixture.id}.label`);
|
||||
const catalogStatus = busy ? t("catalog.loading") : t(`catalog.state.${catalogFixture.id}.status`);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" maw={960}>
|
||||
@@ -45,34 +49,40 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title id="catalog-workbench-title" order={2} size="h3">
|
||||
Catalog item states
|
||||
{t("catalog.title")}
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" maw={680}>
|
||||
Compare the shared selection surface with stable service and interaction fixtures.
|
||||
{t("catalog.description")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600} id="catalog-fixture-label">
|
||||
Service state
|
||||
{t("catalog.serviceState")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
aria-labelledby="catalog-fixture-label"
|
||||
value={catalogFixtureId}
|
||||
onChange={(value) => setCatalogFixtureId(value as CatalogFixtureId)}
|
||||
data={catalogStateFixtures.map((fixture) => ({ value: fixture.id, label: fixture.status }))}
|
||||
data={catalogStateFixtures.map((fixture) => ({
|
||||
value: fixture.id,
|
||||
label: t(`catalog.state.${fixture.id}.status`),
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600} id="catalog-interaction-label">
|
||||
Interaction state
|
||||
{t("catalog.interactionState")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
aria-labelledby="catalog-interaction-label"
|
||||
value={interactionId}
|
||||
onChange={(value) => setInteractionId(value as CatalogInteractionId)}
|
||||
data={catalogInteractionFixtures.map((fixture) => ({ value: fixture.id, label: fixture.label }))}
|
||||
data={catalogInteractionFixtures.map((id) => ({
|
||||
value: id,
|
||||
label: t(`catalog.interaction.${id}`),
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
@@ -80,15 +90,15 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<CatalogItem
|
||||
height={140}
|
||||
label={catalogFixture.label}
|
||||
status={busy ? "Loading" : catalogFixture.status}
|
||||
label={catalogLabel}
|
||||
status={catalogStatus}
|
||||
selected={selected}
|
||||
busy={busy}
|
||||
disabled={disabled}
|
||||
onSelect={() => setInteractionId(selected ? "default" : "selected")}
|
||||
>
|
||||
{busy ? (
|
||||
<Stack gap="sm" aria-label="Loading catalog item">
|
||||
<Stack gap="sm" aria-label={t("catalog.loadingAriaLabel")}>
|
||||
<Skeleton height={24} width="65%" />
|
||||
<Skeleton height={16} />
|
||||
<Skeleton height={20} width="40%" />
|
||||
@@ -105,15 +115,15 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{catalogFixture.label}
|
||||
{catalogLabel}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{catalogFixture.description}
|
||||
{t(`catalog.state.${catalogFixture.id}.description`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge color={catalogFixture.color} variant="light" size="sm" w="fit-content">
|
||||
{catalogFixture.status}
|
||||
{t(`catalog.state.${catalogFixture.id}.status`)}
|
||||
</Badge>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -121,13 +131,13 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
|
||||
|
||||
<Stack gap="xs" justify="center">
|
||||
<Text size="sm" fw={600}>
|
||||
Semantic output
|
||||
{t("catalog.semanticOutput")}
|
||||
</Text>
|
||||
<Code
|
||||
block
|
||||
>{`aria-label="${catalogFixture.label}, ${busy ? "Loading" : catalogFixture.status}"\naria-pressed=${selected}\ndisabled=${busy || disabled}\naria-busy=${busy}`}</Code>
|
||||
>{`aria-label="${catalogLabel}, ${catalogStatus}"\naria-pressed=${selected}\ndisabled=${busy || disabled}\naria-busy=${busy}`}</Code>
|
||||
<Text size="xs" c="dimmed">
|
||||
Tab to the item to inspect its visible focus ring. Select it with Enter or Space.
|
||||
{t("catalog.instructions")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
@@ -138,23 +148,25 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title id="response-workbench-title" order={2} size="h3">
|
||||
Integration response contract
|
||||
{t("response.title")}
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" maw={680}>
|
||||
Inspect waiting, accepted, and rejected response fixtures using the same parser contract as generated
|
||||
integrations.
|
||||
{t("response.description")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<SegmentedControl
|
||||
aria-label="Response fixture"
|
||||
aria-label={t("response.fixtureLabel")}
|
||||
value={responseFixtureId}
|
||||
onChange={(value) => setResponseFixtureId(value as ResponseFixtureId)}
|
||||
data={responseStateFixtures.map((fixture) => ({ value: fixture.id, label: fixture.label }))}
|
||||
data={responseStateFixtures.map((fixture) => ({
|
||||
value: fixture.id,
|
||||
label: t(`response.state.${fixture.id}.label`),
|
||||
}))}
|
||||
/>
|
||||
|
||||
{responseFixture.id === "loading" ? (
|
||||
<Stack gap="sm" role="status" aria-label="Checking response">
|
||||
<Stack component="output" gap="sm" aria-label={t("response.checking")}>
|
||||
<Group justify="space-between">
|
||||
<Skeleton height={22} width={180} />
|
||||
<Skeleton height={22} width={72} />
|
||||
@@ -165,16 +177,14 @@ export function FeatureStateWorkbench({ responseResults }: FeatureStateWorkbench
|
||||
<Alert
|
||||
color={responseFixture.id === "success" ? "green" : "red"}
|
||||
icon={responseFixture.id === "success" ? <IconCircleCheck /> : <IconAlertCircle />}
|
||||
title={responseFixture.title}
|
||||
title={t(`response.state.${responseFixture.id}.title`)}
|
||||
role={responseFixture.id === "failure" ? "alert" : "status"}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{responseFixture.description}</Text>
|
||||
<Text size="sm">{t(`response.state.${responseFixture.id}.description`)}</Text>
|
||||
<Code block>{JSON.stringify(responseFixture.payload, null, 2)}</Code>
|
||||
<Badge color={responseResult?.passed ? "green" : "red"} variant="light" w="fit-content">
|
||||
{responseResult?.passed
|
||||
? "Fixture contract passed"
|
||||
: (responseResult?.message ?? "Fixture contract failed")}
|
||||
{responseResult?.passed ? t("response.contractPassed") : t("response.contractFailed")}
|
||||
</Badge>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import { Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { FeatureStateWorkbench } from "./_feature-state-workbench";
|
||||
import { getResponseContractFixtureResultsAsync } from "./_response-contract-fixtures";
|
||||
@@ -10,15 +11,15 @@ export default async function FeatureWorkbenchPage() {
|
||||
if (process.env.NODE_ENV === "production") notFound();
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) notFound();
|
||||
const t = await getScopedI18n("featureWorkbench.page");
|
||||
const responseResults = await getResponseContractFixtureResultsAsync();
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title>Native feature state workbench</Title>
|
||||
<Title>{t("title")}</Title>
|
||||
<Text c="dimmed" maw={720}>
|
||||
A development-only fixture surface for reviewing shared catalog and integration response states without live
|
||||
services.
|
||||
{t("description")}
|
||||
</Text>
|
||||
</div>
|
||||
<FeatureStateWorkbench responseResults={responseResults} />
|
||||
|
||||
+15
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Alert, Badge, Group, Select } from "@mantine/core";
|
||||
import { Alert, Badge, Button, Group, Select, Stack, Text } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
@@ -14,7 +14,7 @@ export const KubernetesContextSelector = () => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { data } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
|
||||
const { data, isError, refetch } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
refetchOnReconnect: true,
|
||||
@@ -40,6 +40,19 @@ export const KubernetesContextSelector = () => {
|
||||
router.replace(`${pathname}?${next.toString()}`);
|
||||
}, [contextId, pathname, requestedContextId, router, searchParams]);
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("loadError.title")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("loadError.message")}</Text>
|
||||
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void refetch()}>
|
||||
{t("loadError.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !contextId) return null;
|
||||
|
||||
const selectContext = (value: string | null) => {
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
boardName: "Home",
|
||||
kind: "audioStats",
|
||||
summary: "Show audio statistics",
|
||||
integrationIds: ["navidrome-1", "audiobookshelf-1", "plex-1"],
|
||||
integrationIds: ["navidrome-private", "navidrome-1", "audiobookshelf-1", "plex-1"],
|
||||
},
|
||||
settings,
|
||||
[
|
||||
|
||||
@@ -66,7 +66,10 @@ export const BoardSetupChecklist = () => {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Progress value={(progress.completedCount / progress.totalCount) * 100} aria-label={t("progress")} />
|
||||
<Progress
|
||||
value={progress.totalCount === 0 ? 0 : (progress.completedCount / progress.totalCount) * 100}
|
||||
aria-label={t("progress")}
|
||||
/>
|
||||
<ChecklistStep
|
||||
complete={progress.steps.content}
|
||||
label={t("step.content.label")}
|
||||
|
||||
@@ -86,6 +86,7 @@ const createItemLayouts = (
|
||||
? currentSection.layouts.find((layout) => layout.layoutId === layoutId)
|
||||
: undefined;
|
||||
const lane = currentSection.kind === "empty" ? getRootSectionLane(currentSection.xOffset) : "main";
|
||||
if (currentSection.kind === "container" && !containerLayout) return [];
|
||||
const columnCount = containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, lane) : 0);
|
||||
if (columnCount === 0) return [];
|
||||
const size = {
|
||||
|
||||
@@ -169,6 +169,27 @@ describe("item actions create-item", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("omits layouts where the requested container has no layout entry", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
|
||||
.addEmptySection({ id: "canvas" })
|
||||
.addSection(
|
||||
new ContainerSectionMockBuilder({ id: "media" })
|
||||
.addLayout({ layoutId: "base", parentSectionId: "canvas", width: 4, height: 3 })
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
const baseLayout = board.layouts.find((layout) => layout.role === "base");
|
||||
if (!baseLayout) throw new Error("Expected a base layout");
|
||||
baseLayout.id = "base";
|
||||
|
||||
const result = createItemCallback({ id: "calendar", kind: "clock", targetSectionId: "media" })(board);
|
||||
|
||||
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
|
||||
expect.objectContaining({ layoutId: "base", sectionId: "media" }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("sizes a new item for its requested rail and omits layouts where that rail is hidden", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
|
||||
|
||||
@@ -15,11 +15,12 @@ describe("getBoardRecipeRecommendations", () => {
|
||||
preferredIntegrationKind: "jellyfin",
|
||||
});
|
||||
|
||||
expect(recommendations).toHaveLength(3);
|
||||
expect(recommendations.slice(0, 2).every(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
|
||||
expect(recommendations.at(2)).toEqual(
|
||||
expect.objectContaining({ integrationKind: "piHole", isNewlyAvailable: false }),
|
||||
);
|
||||
const firstFallbackIndex = recommendations.findIndex(({ isNewlyAvailable }) => !isNewlyAvailable);
|
||||
expect(recommendations.some(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
|
||||
if (firstFallbackIndex !== -1) {
|
||||
expect(recommendations.slice(0, firstFallbackIndex).every(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
|
||||
expect(recommendations.slice(firstFallbackIndex).every(({ isNewlyAvailable }) => !isNewlyAvailable)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not recommend widget kinds already present on the board", () => {
|
||||
@@ -29,7 +30,8 @@ describe("getBoardRecipeRecommendations", () => {
|
||||
preferredIntegrationKind: "piHole",
|
||||
});
|
||||
|
||||
expect(recommendations.map(({ widgetKind }) => widgetKind)).toEqual(["dnsHoleControls"]);
|
||||
expect(recommendations.every(({ widgetKind }) => widgetKind !== "dnsHoleSummary")).toBe(true);
|
||||
expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true);
|
||||
});
|
||||
|
||||
test("deduplicates configured kinds and emitted widget capabilities", () => {
|
||||
@@ -39,7 +41,9 @@ describe("getBoardRecipeRecommendations", () => {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(recommendations.map(({ widgetKind }) => widgetKind)).toEqual(["dnsHoleControls", "dnsHoleSummary"]);
|
||||
const widgetKinds = recommendations.map(({ widgetKind }) => widgetKind);
|
||||
expect(new Set(widgetKinds).size).toBe(widgetKinds.length);
|
||||
expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true);
|
||||
});
|
||||
|
||||
test("falls back to other configured integrations when the new service has no missing widgets", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Badge, Box, Button, Center, Divider, Group, Image, Select, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
@@ -94,10 +94,16 @@ const ItemSelectModalContent = ({
|
||||
],
|
||||
[board.sections, currentLayout, mainCanvasSection, t],
|
||||
);
|
||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(mainCanvasSection?.id ?? null);
|
||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const { createItem, removeItem } = useItemActions();
|
||||
|
||||
useEffect(() => {
|
||||
if (placementOptions.some(({ value }) => value === targetSectionId)) return;
|
||||
setTargetSectionId(placementOptions[0]?.value ?? null);
|
||||
}, [placementOptions, targetSectionId]);
|
||||
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const settings = useSettings();
|
||||
@@ -155,14 +161,32 @@ const ItemSelectModalContent = ({
|
||||
});
|
||||
};
|
||||
|
||||
const notifyCreated = (itemId: string, name: string) => {
|
||||
const notifyCreated = (updatedBoard: ReturnType<typeof createItem>, itemId: string, name: string) => {
|
||||
const createdItem = updatedBoard?.items.find((item) => item.id === itemId);
|
||||
if (!createdItem) {
|
||||
showErrorNotification({
|
||||
title: t("item.create.notification.error.title"),
|
||||
message: t("item.create.notification.error.message"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const actualSectionId =
|
||||
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
|
||||
createdItem.layouts[0]?.sectionId;
|
||||
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
|
||||
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
|
||||
const notificationId = `board-item-created:${itemId}`;
|
||||
showSuccessNotification({
|
||||
id: notificationId,
|
||||
title: t("item.create.notification.success.title"),
|
||||
message: (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm">{t("item.create.notification.success.message", { name })}</Text>
|
||||
<Text size="sm">
|
||||
{wasRelocated
|
||||
? t("item.create.notification.success.relocatedMessage", { name, destination: actualDestination })
|
||||
: t("item.create.notification.success.message", { name })}
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
@@ -177,6 +201,7 @@ const ItemSelectModalContent = ({
|
||||
),
|
||||
autoClose: 10_000,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAddCustomWidget = async (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => {
|
||||
@@ -204,7 +229,7 @@ const ItemSelectModalContent = ({
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
createItem({
|
||||
const updatedBoard = createItem({
|
||||
id: itemId,
|
||||
kind: "customApi",
|
||||
integrationIds: [],
|
||||
@@ -212,7 +237,7 @@ const ItemSelectModalContent = ({
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
notifyCreated(itemId, customWidgetDefinition.name);
|
||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
@@ -247,8 +272,8 @@ const ItemSelectModalContent = ({
|
||||
ensureDataAsync: ensureIntegrationDataAsync,
|
||||
});
|
||||
|
||||
const maxIntegrations = (definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity;
|
||||
const integrationIds = matchingIntegrations.slice(0, maxIntegrations).map((i) => i.id);
|
||||
const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
|
||||
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
const openEditor = (
|
||||
@@ -267,7 +292,7 @@ const ItemSelectModalContent = ({
|
||||
integrationIds: selectedIntegrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
createItem({
|
||||
const updatedBoard = createItem({
|
||||
id: itemId,
|
||||
kind,
|
||||
options,
|
||||
@@ -275,7 +300,7 @@ const ItemSelectModalContent = ({
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
notifyCreated(itemId, t(`widget.${kind}.name`));
|
||||
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
|
||||
trackSetup("widget-completed", {
|
||||
entryPoint: "board",
|
||||
intent: kind,
|
||||
@@ -295,7 +320,7 @@ const ItemSelectModalContent = ({
|
||||
const integrationsRequired =
|
||||
hasIntegrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
|
||||
|
||||
if (integrationsRequired && integrationIds.length === 0) {
|
||||
if (integrationsRequired && matchingIntegrationCount === 0) {
|
||||
trackSetup("dependency-blocked", {
|
||||
entryPoint: "board",
|
||||
intent: kind,
|
||||
|
||||
@@ -12,7 +12,15 @@ describe("SetupMetricProperties", () => {
|
||||
"hasBoardContext",
|
||||
"canResolveInline",
|
||||
] satisfies (keyof SetupMetricProperties)[];
|
||||
type ExpectedKey = (typeof allowedKeys)[number];
|
||||
type ActualKey = keyof SetupMetricProperties;
|
||||
const exactContract: [ActualKey] extends [ExpectedKey]
|
||||
? [ExpectedKey] extends [ActualKey]
|
||||
? true
|
||||
: false
|
||||
: false = true;
|
||||
|
||||
expect(exactContract).toBe(true);
|
||||
expect(allowedKeys).not.toContain("url");
|
||||
expect(allowedKeys).not.toContain("query");
|
||||
expect(allowedKeys).not.toContain("recordId");
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export type SetupMetricEvent =
|
||||
| "surface-opened"
|
||||
@@ -30,7 +32,15 @@ export interface SetupMetricProperties {
|
||||
* Never pass record IDs, names, URLs, search text, credentials, or provider responses here.
|
||||
*/
|
||||
export const useSetupAnalytics = () => {
|
||||
const { mutate } = clientApi.analytics.trackFeature.useMutation();
|
||||
const t = useI18n();
|
||||
const { mutate } = clientApi.analytics.trackFeature.useMutation({
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: t("universalCreate.analyticsError"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return useCallback(
|
||||
(event: SetupMetricEvent, properties: SetupMetricProperties) => {
|
||||
|
||||
@@ -173,6 +173,6 @@ export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ ac
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: "universalCreate.title",
|
||||
defaultTitle: (t) => t("universalCreate.title"),
|
||||
size: "lg",
|
||||
});
|
||||
@@ -174,6 +174,10 @@ Every resource uses `{ endpointId, id }` from inventory through query keys, acti
|
||||
|
||||
Bulk lifecycle and removal mutations return a result for every target. Management and Widget surfaces report partial failures instead of treating a resolved request as universal success. Removal uses one shared confirmation contract on both surfaces.
|
||||
|
||||
This changes the tRPC and MCP action input from `{ ids: string[] }` to endpoint-qualified
|
||||
`{ targets: Array<{ endpointId: string; id: string }> }`. Existing automation must read both fields from the container
|
||||
inventory and pass them unchanged to start, stop, restart, and remove actions.
|
||||
|
||||
The continuous reconciliation surface:
|
||||
|
||||
- Preserves multiple instances of the same Integration kind.
|
||||
|
||||
@@ -27,4 +27,37 @@ describe("getTrackedFeatureProperties", () => {
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("discards setup values outside the closed telemetry contract", () => {
|
||||
expect(
|
||||
getTrackedFeatureProperties(
|
||||
"setup:widget-completed",
|
||||
{
|
||||
entryPoint: "https://private.example",
|
||||
intent: "secret-id",
|
||||
outcome: "credential",
|
||||
elapsedMs: Number.POSITIVE_INFINITY,
|
||||
hasBoardContext: "true",
|
||||
canResolveInline: 1,
|
||||
},
|
||||
"session-user",
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test("keeps known widget intents and bounded elapsed times", () => {
|
||||
expect(
|
||||
getTrackedFeatureProperties(
|
||||
"setup:widget-completed",
|
||||
{ entryPoint: "board", intent: "weather", outcome: "completed", elapsedMs: 0, canResolveInline: false },
|
||||
"session-user",
|
||||
),
|
||||
).toEqual({ entryPoint: "board", intent: "weather", outcome: "completed", elapsedMs: 0, canResolveInline: false });
|
||||
});
|
||||
|
||||
test("keeps the app intent used by Universal Create", () => {
|
||||
expect(
|
||||
getTrackedFeatureProperties("setup:intent-selected", { entryPoint: "header", intent: "app" }, "user-1"),
|
||||
).toEqual({ entryPoint: "header", intent: "app" });
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,30 @@
|
||||
const setupPropertyKeys = new Set([
|
||||
"entryPoint",
|
||||
"intent",
|
||||
"outcome",
|
||||
"elapsedMs",
|
||||
"hasBoardContext",
|
||||
"canResolveInline",
|
||||
import { widgetKinds } from "@homarr/definitions";
|
||||
|
||||
const setupEntryPoints = new Set(["header", "spotlight", "board", "assistant", "docker", "management"]);
|
||||
const setupIntents = new Set([
|
||||
...widgetKinds,
|
||||
"widget",
|
||||
"app",
|
||||
"integration",
|
||||
"container",
|
||||
"board",
|
||||
"workshop",
|
||||
"customWidget",
|
||||
"add-compatible-widget",
|
||||
]);
|
||||
const setupOutcomes = new Set(["completed", "blocked", "continued"]);
|
||||
const maximumSetupElapsedMs = 24 * 60 * 60 * 1_000;
|
||||
|
||||
const isValidSetupProperty = (key: string, value: unknown) => {
|
||||
if (key === "entryPoint") return typeof value === "string" && setupEntryPoints.has(value);
|
||||
if (key === "intent") return typeof value === "string" && setupIntents.has(value);
|
||||
if (key === "outcome") return typeof value === "string" && setupOutcomes.has(value);
|
||||
if (key === "elapsedMs") {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= maximumSetupElapsedMs;
|
||||
}
|
||||
if (key === "hasBoardContext" || key === "canResolveInline") return typeof value === "boolean";
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getTrackedFeatureProperties = (
|
||||
feature: string,
|
||||
@@ -15,8 +34,6 @@ export const getTrackedFeatureProperties = (
|
||||
if (!feature.startsWith("setup:")) return { ...properties, userId };
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(properties ?? {}).filter(
|
||||
([key, value]) => setupPropertyKeys.has(key) && ["string", "number", "boolean"].includes(typeof value),
|
||||
),
|
||||
Object.entries(properties ?? {}).filter(([key, value]) => isValidSetupProperty(key, value)),
|
||||
);
|
||||
};
|
||||
@@ -562,7 +562,7 @@ export const boardRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a new board with a name, column count (1-24), and isPublic flag. Returns { boardId }. Requires board-create permission",
|
||||
"Create a new board with a name, column count (1-24), and isPublic flag. Returns { boardId, name, layoutId }. Requires board-create permission",
|
||||
},
|
||||
})
|
||||
.input(boardCreateSchema)
|
||||
|
||||
@@ -26,6 +26,11 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
}),
|
||||
db.query.apps.findMany(),
|
||||
]);
|
||||
const normalizedIntegrations = integrations.map((integration) => ({
|
||||
integration,
|
||||
normalizedUrl: normalizeDockerServiceUrl(integration.url),
|
||||
}));
|
||||
const normalizedApps = apps.map((app) => ({ app, normalizedUrl: normalizeDockerServiceUrl(app.href) }));
|
||||
const containerNameCounts = new Map<string, number>();
|
||||
for (const container of data.containers) {
|
||||
const name = normalizeName(container.name);
|
||||
@@ -44,13 +49,16 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
urlCandidates.map(({ url }) => normalizeDockerServiceUrl(url)).filter((url) => url !== null),
|
||||
);
|
||||
const uniqueName = containerNameCounts.get(normalizeName(container.name)) === 1;
|
||||
const sameKindIntegrations = match ? integrations.filter(({ kind }) => kind === match.kind) : [];
|
||||
const exactIntegrationMatches = sameKindIntegrations.filter(({ url }) => {
|
||||
const normalizedUrl = normalizeDockerServiceUrl(url);
|
||||
return normalizedUrl ? candidateUrls.has(normalizedUrl) : false;
|
||||
});
|
||||
const sameKindIntegrations = match
|
||||
? normalizedIntegrations.filter(({ integration }) => integration.kind === match.kind)
|
||||
: [];
|
||||
const exactIntegrationMatches = sameKindIntegrations
|
||||
.filter(({ normalizedUrl }) => normalizedUrl !== null && candidateUrls.has(normalizedUrl))
|
||||
.map(({ integration }) => integration);
|
||||
const nameIntegrationMatches = uniqueName
|
||||
? sameKindIntegrations.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
|
||||
? sameKindIntegrations
|
||||
.filter(({ integration }) => normalizeName(integration.name) === normalizeName(container.name))
|
||||
.map(({ integration }) => integration)
|
||||
: [];
|
||||
const exactIntegration = exactIntegrationMatches.length === 1 ? exactIntegrationMatches[0] : undefined;
|
||||
const nameIntegration =
|
||||
@@ -58,10 +66,9 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
? nameIntegrationMatches[0]
|
||||
: undefined;
|
||||
const integration = exactIntegration ?? nameIntegration;
|
||||
const exactAppMatches = apps.filter(({ href }) => {
|
||||
const normalizedUrl = normalizeDockerServiceUrl(href);
|
||||
return normalizedUrl ? candidateUrls.has(normalizedUrl) : false;
|
||||
});
|
||||
const exactAppMatches = normalizedApps
|
||||
.filter(({ normalizedUrl }) => normalizedUrl !== null && candidateUrls.has(normalizedUrl))
|
||||
.map(({ app }) => app);
|
||||
const nameAppMatches = uniqueName
|
||||
? apps.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
|
||||
: [];
|
||||
|
||||
@@ -19,18 +19,40 @@ const dockerContainerTargetSchema = z.object({
|
||||
endpointId: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
const dockerContainerTargetsSchema = z.array(dockerContainerTargetSchema).min(1).max(100);
|
||||
|
||||
export const dockerRouter = createTRPCRouter({
|
||||
reconcileServices: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Reconcile Docker container inventory with Homarr integrations and apps, including URL matches and recommended setup or repair actions. Requires admin permission.",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => await getDockerReconciliationAsync(ctx.db)),
|
||||
getServiceHealth: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get Docker endpoint and discovered-service health projected across Docker, integration, app, and widget setup layers. Requires admin permission.",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => await getDockerServiceHealthAsync(ctx.db)),
|
||||
refreshInventory: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Invalidate the cached Docker inventory so the next Docker query performs a fresh user-requested discovery. Requires admin permission.",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.mutation(() => {
|
||||
dockerContainersRequestHandler.invalidateCache();
|
||||
@@ -65,7 +87,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await performDockerContainerActionsAsync(input.targets, "start");
|
||||
}),
|
||||
@@ -78,7 +100,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await performDockerContainerActionsAsync(input.targets, "stop");
|
||||
}),
|
||||
@@ -91,7 +113,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await performDockerContainerActionsAsync(input.targets, "restart");
|
||||
}),
|
||||
@@ -105,7 +127,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.input(z.object({ targets: dockerContainerTargetsSchema }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await performDockerContainerActionsAsync(input.targets, "remove");
|
||||
}),
|
||||
|
||||
@@ -280,7 +280,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a new integration (connection to an external service). REQUIRED fields: name, url (http/https), kind, secrets, attemptSearchEngineCreation. The 'secrets' field is REQUIRED and must be a non-empty array — call integration_getKinds first to see which secret kinds each integration type needs. Example for Radarr: secrets=[{kind:'apiKey', value:'your-radarr-api-key'}]. Example for Proxmox: secrets=[{kind:'tokenId', value:'...'}, {kind:'personalAccessToken', value:'...'}, {kind:'realm', value:'pam'}]. The connection is tested before saving — if secrets are wrong, an error is returned. Set attemptSearchEngineCreation to false unless explicitly requested. The 'app' field is optional — pass {id:'...'} to link to an existing app, or omit it.",
|
||||
"Create a new integration (connection to an external service). REQUIRED fields: name, url (http/https), kind, secrets, attemptSearchEngineCreation. The 'secrets' field is REQUIRED and must be a non-empty array — call integration_getKinds first to see which secret kinds each integration type needs. Example for Radarr: secrets=[{kind:'apiKey', value:'your-radarr-api-key'}]. Example for Proxmox: secrets=[{kind:'tokenId', value:'...'}, {kind:'personalAccessToken', value:'...'}, {kind:'realm', value:'pam'}]. The connection is tested before saving — if secrets are wrong, an error is returned. Set attemptSearchEngineCreation to false unless explicitly requested. The 'app' field is optional — pass {id:'...'} to link to an existing app, or omit it. Returns integration details and appId, which is null when no app is linked or created.",
|
||||
},
|
||||
})
|
||||
.input(integrationCreateSchema)
|
||||
|
||||
@@ -99,6 +99,30 @@ describe("KubernetesClientRegistry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("aborts a timed-out node metrics request", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = createRegistry().getClient("home");
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
vi.spyOn(client.metricsProbeApi, "listClusterCustomObject").mockImplementation(async (_request, options) => {
|
||||
const middleware = (
|
||||
options as { middleware?: { pre?: (request: unknown) => { toPromise: () => Promise<unknown> } }[] }
|
||||
).middleware?.[0];
|
||||
await middleware?.pre?.({ setSignal: (signal: AbortSignal) => (observedSignal = signal) }).toPromise();
|
||||
return await new Promise<never>(() => undefined);
|
||||
});
|
||||
|
||||
const resultPromise = client.getNodeMetricsAsync(50);
|
||||
const rejection = expect(resultPromise).rejects.toThrow("Kubernetes context probe timed out");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
await rejection;
|
||||
expect(observedSignal?.aborted).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown context IDs", () => {
|
||||
expect(() => createRegistry().getClient("missing")).toThrow(KubernetesContextNotFoundError);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from "fs";
|
||||
import type { RequestContext, ResponseContext } from "@kubernetes/client-node";
|
||||
import { CoreV1Api, CustomObjectsApi, KubeConfig, Metrics, NetworkingV1Api, VersionApi } from "@kubernetes/client-node";
|
||||
import type { NodeMetricsList, RequestContext, ResponseContext } from "@kubernetes/client-node";
|
||||
import { CoreV1Api, CustomObjectsApi, KubeConfig, NetworkingV1Api, VersionApi } from "@kubernetes/client-node";
|
||||
import { of } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
|
||||
|
||||
import { env } from "../../env";
|
||||
@@ -55,7 +55,6 @@ export class KubernetesClient {
|
||||
public kubeConfig: KubeConfig;
|
||||
public coreApi: CoreV1Api;
|
||||
public networkingApi: NetworkingV1Api;
|
||||
public metricsApi: Metrics;
|
||||
public metricsProbeApi: CustomObjectsApi;
|
||||
public versionApi: VersionApi;
|
||||
|
||||
@@ -63,11 +62,22 @@ export class KubernetesClient {
|
||||
this.kubeConfig = kubeConfig;
|
||||
this.coreApi = kubeConfig.makeApiClient(CoreV1Api);
|
||||
this.networkingApi = kubeConfig.makeApiClient(NetworkingV1Api);
|
||||
this.metricsApi = new Metrics(kubeConfig);
|
||||
this.metricsProbeApi = kubeConfig.makeApiClient(CustomObjectsApi);
|
||||
this.versionApi = kubeConfig.makeApiClient(VersionApi);
|
||||
}
|
||||
|
||||
public async getNodeMetricsAsync(timeoutMs = kubernetesContextProbeTimeoutMs): Promise<NodeMetricsList> {
|
||||
const result: unknown = await withKubernetesProbeTimeoutAsync(
|
||||
async (signal) =>
|
||||
await this.metricsProbeApi.listClusterCustomObject(
|
||||
{ group: "metrics.k8s.io", version: "v1beta1", plural: "nodes" },
|
||||
abortableKubernetesRequest(signal),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
return result as NodeMetricsList;
|
||||
}
|
||||
|
||||
public static getInstance(contextId: string): KubernetesClient {
|
||||
return KubernetesClient.getRegistry().getClient(contextId);
|
||||
}
|
||||
@@ -125,14 +135,7 @@ export class KubernetesClientRegistry {
|
||||
async (signal) => await client.versionApi.getCode(undefined, abortableKubernetesRequest(signal)),
|
||||
timeoutMs,
|
||||
),
|
||||
withKubernetesProbeTimeoutAsync(
|
||||
async (signal) =>
|
||||
await client.metricsProbeApi.listClusterCustomObject(
|
||||
{ group: "metrics.k8s.io", version: "v1beta1", plural: "nodes" },
|
||||
abortableKubernetesRequest(signal),
|
||||
),
|
||||
timeoutMs,
|
||||
),
|
||||
client.getNodeMetricsAsync(timeoutMs),
|
||||
]);
|
||||
const status =
|
||||
api.status === "rejected" ? "unavailable" : metrics.status === "rejected" ? "degraded" : "available";
|
||||
|
||||
@@ -16,14 +16,15 @@ export const clusterRouter = createTRPCRouter({
|
||||
.concat(kubernetesMiddleware())
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesCluster> => {
|
||||
const { coreApi, metricsApi, versionApi, kubeConfig } = getKubernetesClient(input.contextId);
|
||||
const client = getKubernetesClient(input.contextId);
|
||||
const { coreApi, versionApi, kubeConfig } = client;
|
||||
|
||||
try {
|
||||
const [versionInfo, nodes, listPodForAllNamespaces, nodeMetricsClient] = await Promise.all([
|
||||
versionApi.getCode(),
|
||||
coreApi.listNode(),
|
||||
coreApi.listPodForAllNamespaces(),
|
||||
metricsApi.getNodeMetrics().catch(() => null),
|
||||
client.getNodeMetricsAsync().catch(() => null),
|
||||
]);
|
||||
|
||||
let totalCPUCapacity = 0;
|
||||
|
||||
@@ -5,6 +5,13 @@ import { KubernetesClient } from "../kubernetes-client";
|
||||
export const contextsRouter = createTRPCRouter({
|
||||
getContexts: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List configured Kubernetes contexts with availability, metrics status, and default-context metadata. Requires admin permission.",
|
||||
},
|
||||
})
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async () => await KubernetesClient.getContextsAsync()),
|
||||
});
|
||||
@@ -10,6 +10,13 @@ import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-conte
|
||||
export const ingressesRouter = createTRPCRouter({
|
||||
getIngresses: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List Kubernetes ingress inventory for a context. Get the required contextId from kubernetes_contexts_getContexts. Requires admin permission.",
|
||||
},
|
||||
})
|
||||
.concat(kubernetesMiddleware())
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesIngress[]> => {
|
||||
|
||||
@@ -15,12 +15,13 @@ export const nodesRouter = createTRPCRouter({
|
||||
.concat(kubernetesMiddleware())
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesNode[]> => {
|
||||
const { coreApi, metricsApi } = getKubernetesClient(input.contextId);
|
||||
const client = getKubernetesClient(input.contextId);
|
||||
const { coreApi } = client;
|
||||
|
||||
try {
|
||||
const [nodes, nodeMetricsClient] = await Promise.all([
|
||||
coreApi.listNode(),
|
||||
metricsApi.getNodeMetrics().catch(() => null),
|
||||
client.getNodeMetricsAsync().catch(() => null),
|
||||
]);
|
||||
const cpuResourceParser = new CpuResourceParser();
|
||||
const memoryResourceParser = new MemoryResourceParser();
|
||||
|
||||
@@ -14,6 +14,13 @@ const logger = createLogger({ module: "podsRouter" });
|
||||
export const podsRouter = createTRPCRouter({
|
||||
getPods: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List Kubernetes pod inventory and inferred workload types for a context. Get the required contextId from kubernetes_contexts_getContexts. Requires admin permission.",
|
||||
},
|
||||
})
|
||||
.concat(kubernetesMiddleware())
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesPod[]> => {
|
||||
|
||||
@@ -184,6 +184,20 @@ describe("All procedures should only be accessible for users with admin permissi
|
||||
});
|
||||
|
||||
describe("Docker action targeting", () => {
|
||||
test.each(["startAll", "stopAll", "restartAll", "removeAll"] as const)(
|
||||
"%s requires a bounded non-empty target list",
|
||||
async (procedure) => {
|
||||
const caller = createAdminCaller();
|
||||
|
||||
await expect(caller[procedure]({ targets: [] })).rejects.toThrow();
|
||||
await expect(
|
||||
caller[procedure]({
|
||||
targets: Array.from({ length: 101 }, (_, index) => ({ endpointId: "endpoint", id: String(index) })),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
test("enforces endpoint capabilities before running a lifecycle action", async () => {
|
||||
hasDockerEndpointCapabilityMock.mockReturnValueOnce(false);
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ const parseTransport = (value: unknown, index: number): DockerEndpointTransport
|
||||
};
|
||||
|
||||
const parseCapabilities = (value: unknown, index: number): DockerEndpointCapability[] => {
|
||||
if (value === undefined) return allCapabilities;
|
||||
if (value === undefined) return [...allCapabilities];
|
||||
if (!Array.isArray(value) || value.some((capability) => !dockerEndpointCapabilities.includes(capability))) {
|
||||
throw new Error(`DOCKER_ENDPOINTS[${index}].capabilities contains an unsupported capability`);
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export const createLegacySocketDescriptor = (path: string): DockerEndpointDescri
|
||||
name: path,
|
||||
kind: path.toLowerCase().includes("podman") ? "podman" : "docker",
|
||||
transport: { type: "socket", path },
|
||||
capabilities: allCapabilities,
|
||||
capabilities: [...allCapabilities],
|
||||
scope: "admin",
|
||||
source: "legacy",
|
||||
});
|
||||
@@ -146,7 +146,7 @@ export const createLegacyTcpDescriptor = (host: string, port: number): DockerEnd
|
||||
name: `${host}:${port}`,
|
||||
kind: "docker",
|
||||
transport: { type: "tcp", host, port, allowInsecure: true },
|
||||
capabilities: allCapabilities,
|
||||
capabilities: [...allCapabilities],
|
||||
scope: "admin",
|
||||
source: "legacy",
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type Docker from "dockerode";
|
||||
import type { DockerEndpointCapability } from "./endpoint-descriptor";
|
||||
|
||||
export type { DockerInstance } from "./singleton";
|
||||
export type { DockerEndpointInitializationFailure, DockerInstance } from "./singleton";
|
||||
export { DockerSingleton } from "./singleton";
|
||||
export * from "./endpoint-descriptor";
|
||||
export type { ContainerInfo, Container, Port } from "dockerode";
|
||||
@@ -19,7 +19,7 @@ export interface DockerContainerTarget {
|
||||
export interface DockerEndpointStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "available" | "unavailable";
|
||||
status: "available" | "degraded" | "unavailable";
|
||||
kind?: "docker" | "podman";
|
||||
transport?: "socket" | "tcp" | "tls";
|
||||
capabilities?: DockerEndpointCapability[];
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import Docker from "dockerode";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
import { env } from "./env";
|
||||
import type { DockerEndpointCapability, DockerEndpointDescriptor } from "./endpoint-descriptor";
|
||||
import {
|
||||
@@ -18,11 +21,40 @@ export interface DockerInstance {
|
||||
instance: Docker;
|
||||
}
|
||||
|
||||
export interface DockerEndpointInitializationFailure {
|
||||
descriptor: DockerEndpointDescriptor;
|
||||
host: string;
|
||||
}
|
||||
|
||||
const logger = createLogger({ module: "dockerSingleton" });
|
||||
|
||||
export class DockerSingleton {
|
||||
private static instances: DockerInstance[] | null = null;
|
||||
private static initializationFailures: DockerEndpointInitializationFailure[] = [];
|
||||
|
||||
private createInstanceSafely(descriptor: DockerEndpointDescriptor): DockerInstance[] {
|
||||
try {
|
||||
return [createInstance(descriptor)];
|
||||
} catch (error) {
|
||||
const host = getDockerEndpointHost(descriptor);
|
||||
DockerSingleton.initializationFailures.push({ descriptor, host });
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to initialize Docker endpoint",
|
||||
{ endpointId: descriptor.id, host },
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private createInstances() {
|
||||
if (env.DOCKER_ENDPOINTS) return parseDockerEndpointDescriptors(env.DOCKER_ENDPOINTS).map(createInstance);
|
||||
if (env.DOCKER_ENDPOINTS) {
|
||||
return parseDockerEndpointDescriptors(env.DOCKER_ENDPOINTS).flatMap((descriptor) =>
|
||||
this.createInstanceSafely(descriptor),
|
||||
);
|
||||
}
|
||||
|
||||
const socketPaths = env.DOCKER_SOCKET_PATHS;
|
||||
const hostVariable = env.DOCKER_HOSTNAMES;
|
||||
@@ -30,7 +62,9 @@ export class DockerSingleton {
|
||||
|
||||
// Socket instances from DOCKER_SOCKET_PATHS
|
||||
const socketInstances: DockerInstance[] = socketPaths
|
||||
? socketPaths.split(",").map((socketPath) => createInstance(createLegacySocketDescriptor(socketPath)))
|
||||
? socketPaths
|
||||
.split(",")
|
||||
.flatMap((socketPath) => this.createInstanceSafely(createLegacySocketDescriptor(socketPath)))
|
||||
: [];
|
||||
|
||||
// TCP instances from existing DOCKER_HOSTNAMES/DOCKER_PORTS
|
||||
@@ -43,9 +77,9 @@ export class DockerSingleton {
|
||||
throw new Error("The number of hosts and ports must match");
|
||||
}
|
||||
|
||||
tcpInstances = hostnames.map((host, i) =>
|
||||
tcpInstances = hostnames.flatMap((host, i) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
createInstance(createLegacyTcpDescriptor(host, parseInt(ports[i]!, 10))),
|
||||
this.createInstanceSafely(createLegacyTcpDescriptor(host, parseInt(ports[i]!, 10))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +93,7 @@ export class DockerSingleton {
|
||||
name: "Local Docker",
|
||||
source: "default" as const,
|
||||
};
|
||||
return [{ ...createInstance(descriptor), host: "socket", instance: new Docker() }];
|
||||
return [createInstance(descriptor)];
|
||||
}
|
||||
|
||||
return instances;
|
||||
@@ -78,9 +112,15 @@ export class DockerSingleton {
|
||||
return this.instances;
|
||||
}
|
||||
|
||||
this.initializationFailures = [];
|
||||
this.instances = new DockerSingleton().createInstances();
|
||||
return this.instances;
|
||||
}
|
||||
|
||||
public static getInitializationFailures(): DockerEndpointInitializationFailure[] {
|
||||
this.getInstances();
|
||||
return [...this.initializationFailures];
|
||||
}
|
||||
}
|
||||
|
||||
const createInstance = (descriptor: DockerEndpointDescriptor): DockerInstance => ({
|
||||
|
||||
@@ -57,4 +57,15 @@ describe("parseDockerEndpointDescriptors", () => {
|
||||
),
|
||||
).toThrow("must include inventory");
|
||||
});
|
||||
|
||||
test("returns independent default capability arrays", () => {
|
||||
const [first, second] = parseDockerEndpointDescriptors(
|
||||
JSON.stringify([
|
||||
{ id: "first", name: "First", transport: { type: "socket", path: "/first.sock" } },
|
||||
{ id: "second", name: "Second", transport: { type: "socket", path: "/second.sock" } },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(first?.capabilities).not.toBe(second?.capabilities);
|
||||
});
|
||||
});
|
||||
@@ -40,8 +40,10 @@ describe("DockerSingleton", () => {
|
||||
const instances = DockerSingleton.getInstances();
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]?.host).toBe("socket");
|
||||
expect(instances[0]?.host).toBe("/var/run/docker.sock");
|
||||
expect(instances[0]).toMatchObject({ endpointId: "socket:default", endpointName: "Local Docker" });
|
||||
const options = (instances[0]?.instance as unknown as { opts: { socketPath: string } } | undefined)?.opts;
|
||||
expect(options?.socketPath).toBe("/var/run/docker.sock");
|
||||
});
|
||||
|
||||
test("should return TCP instances only when DOCKER_HOSTNAMES and DOCKER_PORTS set", () => {
|
||||
@@ -131,4 +133,20 @@ describe("DockerSingleton", () => {
|
||||
expect(DockerSingleton.hasCapability("readonly-podman", "logs")).toBe(true);
|
||||
expect(DockerSingleton.hasCapability("readonly-podman", "remove")).toBe(false);
|
||||
});
|
||||
|
||||
test("isolates an unreadable TLS endpoint from healthy endpoints", () => {
|
||||
mockEnv.DOCKER_ENDPOINTS = JSON.stringify([
|
||||
{ id: "local", name: "Local", transport: { type: "socket", path: "/var/run/docker.sock" } },
|
||||
{
|
||||
id: "broken-tls",
|
||||
name: "Broken TLS",
|
||||
transport: { type: "tls", host: "broken.example", port: 2376, caPath: "/missing/docker-ca.pem" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(DockerSingleton.getInstances().map(({ endpointId }) => endpointId)).toEqual(["local"]);
|
||||
expect(DockerSingleton.getInitializationFailures()).toEqual([
|
||||
expect.objectContaining({ descriptor: expect.objectContaining({ id: "broken-tls" }) }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -68,4 +68,41 @@ describe("ConfirmModal", () => {
|
||||
expect(confirmButton.disabled).toBe(false);
|
||||
expect(closeModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("waits for a rejecting confirmation button callback", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
let rejectClick!: (error: Error) => void;
|
||||
const onClick = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectClick = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConfirmModalComponent
|
||||
actions={{ closeModal }}
|
||||
innerProps={{ children: "Remove containers?", confirmProps: { onClick }, onConfirm }}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const confirmButton = host.querySelectorAll("button").item(1);
|
||||
await act(async () => confirmButton.click());
|
||||
expect(confirmButton.disabled).toBe(true);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
rejectClick(new Error("Callback failed"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(confirmButton.disabled).toBe(false);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(closeModal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,7 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
|
||||
setLoading(true);
|
||||
try {
|
||||
if (typeof confirmProps?.onClick === "function") {
|
||||
confirmProps.onClick(event);
|
||||
await confirmProps.onClick(event);
|
||||
}
|
||||
|
||||
if (typeof onConfirm === "function") {
|
||||
|
||||
@@ -41,7 +41,7 @@ const withDockerTimeoutAsync = async <T>(
|
||||
}
|
||||
};
|
||||
|
||||
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
|
||||
const isDemoMode = () => ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
|
||||
|
||||
const port = (privatePort: number, publicPort: number, type: string): Port => ({
|
||||
IP: "0.0.0.0",
|
||||
@@ -197,7 +197,7 @@ const mockContainers: {
|
||||
|
||||
export const dockerContainersRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync() {
|
||||
if (isDemoMode) {
|
||||
if (isDemoMode()) {
|
||||
return {
|
||||
containers: mockContainers.map((container) => ({
|
||||
...container,
|
||||
@@ -228,7 +228,10 @@ const extractImage = (container: ContainerInfo) => extractContainerImageName(con
|
||||
export const hasDockerEndpointCapability = (
|
||||
endpointId: string,
|
||||
capability: "inventory" | "logs" | "lifecycle" | "remove",
|
||||
) => DockerSingleton.hasCapability(endpointId, capability);
|
||||
) =>
|
||||
isDemoMode()
|
||||
? endpointId === "demo" && capability === "inventory"
|
||||
: DockerSingleton.hasCapability(endpointId, capability);
|
||||
|
||||
export const findDockerContainerAsync = async (
|
||||
{ endpointId, id }: DockerContainerTarget,
|
||||
@@ -302,6 +305,7 @@ export const streamContainerLogsAsync = async (
|
||||
|
||||
export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpointTimeoutMs) {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const initializationFailures = DockerSingleton.getInitializationFailures();
|
||||
const results = await Promise.allSettled(
|
||||
dockerInstances.map(async ({ instance, host, endpointId, endpointName }) => {
|
||||
const instanceContainers = await withDockerTimeoutAsync(
|
||||
@@ -315,20 +319,32 @@ export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpoi
|
||||
}),
|
||||
);
|
||||
|
||||
const endpoints = results.map((result, index) => {
|
||||
const dockerInstance = dockerInstances.at(index);
|
||||
if (!dockerInstance) throw new Error("Docker endpoint result did not match a configured endpoint");
|
||||
return {
|
||||
id: dockerInstance.endpointId,
|
||||
name: dockerInstance.endpointName,
|
||||
status: result.status === "fulfilled" ? "available" : "unavailable",
|
||||
kind: dockerInstance.descriptor.kind,
|
||||
transport: dockerInstance.descriptor.transport.type,
|
||||
capabilities: dockerInstance.descriptor.capabilities,
|
||||
source: dockerInstance.descriptor.source,
|
||||
scope: dockerInstance.descriptor.scope,
|
||||
} satisfies DockerEndpointStatus;
|
||||
});
|
||||
const endpoints = [
|
||||
...results.map((result, index) => {
|
||||
const dockerInstance = dockerInstances.at(index);
|
||||
if (!dockerInstance) throw new Error("Docker endpoint result did not match a configured endpoint");
|
||||
return {
|
||||
id: dockerInstance.endpointId,
|
||||
name: dockerInstance.endpointName,
|
||||
status: result.status === "fulfilled" ? ("available" as const) : ("unavailable" as const),
|
||||
kind: dockerInstance.descriptor.kind,
|
||||
transport: dockerInstance.descriptor.transport.type,
|
||||
capabilities: dockerInstance.descriptor.capabilities,
|
||||
source: dockerInstance.descriptor.source,
|
||||
scope: dockerInstance.descriptor.scope,
|
||||
} satisfies DockerEndpointStatus;
|
||||
}),
|
||||
...initializationFailures.map(({ descriptor }) => ({
|
||||
id: descriptor.id,
|
||||
name: descriptor.name,
|
||||
status: "unavailable" as const,
|
||||
kind: descriptor.kind,
|
||||
transport: descriptor.transport.type,
|
||||
capabilities: descriptor.capabilities,
|
||||
source: descriptor.source,
|
||||
scope: descriptor.scope,
|
||||
})),
|
||||
] satisfies DockerEndpointStatus[];
|
||||
|
||||
const containers = results.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
@@ -354,24 +370,35 @@ export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpoi
|
||||
})
|
||||
: [];
|
||||
|
||||
const degradedEndpointIds = new Set<string>();
|
||||
const containerStatsPromises = containers.map(async (container) => {
|
||||
const instance = DockerSingleton.findInstance(container.endpointId)?.instance;
|
||||
if (!instance) return null;
|
||||
|
||||
const stats = await withDockerTimeoutAsync(
|
||||
async (signal) => {
|
||||
const options = { stream: false as const, "one-shot": true, abortSignal: signal };
|
||||
return await instance.getContainer(container.Id).stats(options);
|
||||
},
|
||||
`Timed out reading Docker container stats for ${container.Id}`,
|
||||
timeoutMs,
|
||||
).catch(
|
||||
() =>
|
||||
({
|
||||
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
|
||||
memory_stats: { usage: 0 },
|
||||
}) as ContainerStats,
|
||||
);
|
||||
let stats: ContainerStats;
|
||||
try {
|
||||
stats = await withDockerTimeoutAsync(
|
||||
async (signal) => {
|
||||
const options = { stream: false as const, "one-shot": true, abortSignal: signal };
|
||||
return await instance.getContainer(container.Id).stats(options);
|
||||
},
|
||||
`Timed out reading Docker container stats for ${container.Id}`,
|
||||
timeoutMs,
|
||||
);
|
||||
} catch (error) {
|
||||
degradedEndpointIds.add(container.endpointId);
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to read Docker container stats",
|
||||
{ endpointId: container.endpointId, containerId: container.Id, host: container.instance },
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
stats = {
|
||||
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
|
||||
memory_stats: { usage: 0 },
|
||||
} as ContainerStats;
|
||||
}
|
||||
|
||||
const cpuUsage = calculateCpuUsage(stats);
|
||||
const memoryUsage = calculateMemoryUsage(stats);
|
||||
@@ -392,9 +419,14 @@ export async function getContainersWithStatsAsync(timeoutMs = dockerWidgetEndpoi
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedContainers = (await Promise.all(containerStatsPromises)).filter((container) => container !== null);
|
||||
return {
|
||||
containers: (await Promise.all(containerStatsPromises)).filter((container) => container !== null),
|
||||
endpoints,
|
||||
containers: resolvedContainers,
|
||||
endpoints: endpoints.map((endpoint) =>
|
||||
endpoint.status === "available" && degradedEndpointIds.has(endpoint.id)
|
||||
? { ...endpoint, status: "degraded" as const }
|
||||
: endpoint,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { DockerSingleton } from "@homarr/docker";
|
||||
|
||||
import { calculateCpuUsage, calculateMemoryUsage, getContainersWithStatsAsync } from "../docker";
|
||||
import {
|
||||
calculateCpuUsage,
|
||||
calculateMemoryUsage,
|
||||
getContainersWithStatsAsync,
|
||||
hasDockerEndpointCapability,
|
||||
} from "../docker";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
@@ -144,7 +149,7 @@ describe("getContainersWithStatsAsync", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a container with zeroed stats when its stats request times out", async () => {
|
||||
test("marks an endpoint degraded and returns zeroed stats when its stats request times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const dockerInstance = createDockerInstance("healthy", async () => [
|
||||
@@ -173,11 +178,50 @@ describe("getContainersWithStatsAsync", () => {
|
||||
|
||||
expect(observedSignal?.aborted).toBe(true);
|
||||
expect(result.containers).toEqual([expect.objectContaining({ id: "container-1", cpuUsage: 0, memoryUsage: 0 })]);
|
||||
expect(result.endpoints).toEqual([expect.objectContaining({ id: "healthy", status: "degraded" })]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test("includes endpoints that failed during TLS initialization", async () => {
|
||||
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([]);
|
||||
vi.spyOn(DockerSingleton, "getInitializationFailures").mockReturnValue([
|
||||
{
|
||||
host: "broken.example:2376",
|
||||
descriptor: {
|
||||
id: "broken-tls",
|
||||
name: "Broken TLS",
|
||||
kind: "docker",
|
||||
transport: { type: "tls", host: "broken.example", port: 2376, caPath: "/missing/ca.pem" },
|
||||
capabilities: ["inventory"],
|
||||
scope: "admin",
|
||||
source: "environment",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await getContainersWithStatsAsync(50);
|
||||
|
||||
expect(result.endpoints).toEqual([
|
||||
expect.objectContaining({ id: "broken-tls", status: "unavailable", transport: "tls" }),
|
||||
]);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
test("demo capabilities match the advertised inventory-only endpoint", () => {
|
||||
const previousDemoMode = process.env.DEMO_MODE;
|
||||
process.env.DEMO_MODE = "true";
|
||||
try {
|
||||
expect(hasDockerEndpointCapability("demo", "inventory")).toBe(true);
|
||||
expect(hasDockerEndpointCapability("demo", "logs")).toBe(false);
|
||||
expect(hasDockerEndpointCapability("other", "inventory")).toBe(false);
|
||||
} finally {
|
||||
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = previousDemoMode;
|
||||
}
|
||||
});
|
||||
|
||||
const createDockerInstance = (endpointId: string, listContainers: () => Promise<unknown[]>) => ({
|
||||
|
||||
@@ -1,4 +1,73 @@
|
||||
{
|
||||
"featureWorkbench": {
|
||||
"page": {
|
||||
"title": "Native feature state workbench",
|
||||
"description": "A development-only fixture surface for reviewing shared catalog and integration response states without live services."
|
||||
},
|
||||
"catalog": {
|
||||
"title": "Catalog item states",
|
||||
"description": "Compare the shared selection surface with stable service and interaction fixtures.",
|
||||
"serviceState": "Service state",
|
||||
"interactionState": "Interaction state",
|
||||
"loading": "Loading",
|
||||
"loadingAriaLabel": "Loading catalog item",
|
||||
"semanticOutput": "Semantic output",
|
||||
"instructions": "Tab to the item to inspect its visible focus ring. Select it with Enter or Space.",
|
||||
"interaction": {
|
||||
"default": "Default",
|
||||
"selected": "Selected",
|
||||
"loading": "Loading",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"state": {
|
||||
"ready": {
|
||||
"label": "Ready service",
|
||||
"status": "Ready",
|
||||
"description": "Configured and available to three widgets."
|
||||
},
|
||||
"needsSetup": {
|
||||
"label": "Service needing setup",
|
||||
"status": "Needs setup",
|
||||
"description": "Credentials or a service URL are still required."
|
||||
},
|
||||
"noConnection": {
|
||||
"label": "Standalone feature",
|
||||
"status": "No connection required",
|
||||
"description": "This feature works without a configured integration."
|
||||
},
|
||||
"error": {
|
||||
"label": "Unreachable service",
|
||||
"status": "Connection failed",
|
||||
"description": "The saved connection could not be reached."
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"title": "Integration response contract",
|
||||
"description": "Inspect waiting, accepted, and rejected response fixtures using the same parser contract as generated integrations.",
|
||||
"fixtureLabel": "Response fixture",
|
||||
"checking": "Checking response",
|
||||
"contractPassed": "Fixture contract passed",
|
||||
"contractFailed": "Fixture contract failed",
|
||||
"state": {
|
||||
"loading": {
|
||||
"label": "Loading",
|
||||
"title": "Checking response",
|
||||
"description": "The parser is waiting for the integration response."
|
||||
},
|
||||
"success": {
|
||||
"label": "Success",
|
||||
"title": "Response accepted",
|
||||
"description": "The upstream payload matched the integration contract."
|
||||
},
|
||||
"failure": {
|
||||
"label": "Failure",
|
||||
"title": "Response rejected",
|
||||
"description": "The upstream payload failed schema validation and stays isolated from the widget."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"studio": {
|
||||
"navigationLabel": "Onboarding sections",
|
||||
@@ -241,6 +310,7 @@
|
||||
},
|
||||
"universalCreate": {
|
||||
"title": "Create",
|
||||
"analyticsError": "Usage metrics could not be recorded. Your action was not affected.",
|
||||
"trigger": {
|
||||
"label": "Create something",
|
||||
"description": "Add content, connect a service, or create a board from one place."
|
||||
@@ -2703,7 +2773,8 @@
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Widget added",
|
||||
"message": "{name} was added to this board."
|
||||
"message": "{name} was added to this board.",
|
||||
"relocatedMessage": "{name} was added to {destination} because the selected destination was unavailable."
|
||||
},
|
||||
"error": {
|
||||
"title": "Unable to add item",
|
||||
@@ -7051,7 +7122,9 @@
|
||||
},
|
||||
"url": {
|
||||
"label": "Service address",
|
||||
"manual": "Enter an address manually"
|
||||
"inputLabel": "Service URL",
|
||||
"manual": "Enter an address manually",
|
||||
"invalid": "Enter a valid HTTP or HTTPS URL"
|
||||
},
|
||||
"reason": {
|
||||
"publishedAddress": "Published address reported by Docker",
|
||||
@@ -7083,12 +7156,21 @@
|
||||
},
|
||||
"action": {
|
||||
"refresh": "Refresh",
|
||||
"retry": "Try again",
|
||||
"dismiss": "Dismiss",
|
||||
"restoreDismissed": "Restore dismissed ({count})",
|
||||
"setupIntegration": "Set up integration",
|
||||
"createApp": "Create app",
|
||||
"reviewIntegration": "Review integration",
|
||||
"viewRepresentation": "View setup"
|
||||
},
|
||||
"loadError": {
|
||||
"title": "Service setup could not be loaded",
|
||||
"message": "Docker service information is unavailable. Try again without leaving this page."
|
||||
},
|
||||
"refreshError": {
|
||||
"title": "Containers not refreshed",
|
||||
"message": "Something went wrong while refreshing the containers"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -7187,8 +7269,8 @@
|
||||
"label": "Remove",
|
||||
"confirm": "Click again to remove permanently",
|
||||
"confirmation": {
|
||||
"title": "Remove {count} container(s)?",
|
||||
"message": "This permanently removes {count} container(s): {names}"
|
||||
"title": "{count, plural, one {Remove # container?} other {Remove # containers?}}",
|
||||
"message": "{count, plural, one {This permanently removes # container: {names}} other {This permanently removes # containers: {names}}}"
|
||||
},
|
||||
"notification": {
|
||||
"success": {
|
||||
@@ -7204,7 +7286,7 @@
|
||||
"result": {
|
||||
"failure": {
|
||||
"title": "Some container actions failed",
|
||||
"message": "{failed} of {total} action(s) failed: {names}"
|
||||
"message": "{total, plural, one {{failed} of # action failed: {names}} other {{failed} of # actions failed: {names}}}"
|
||||
}
|
||||
},
|
||||
"refresh": {
|
||||
@@ -7239,13 +7321,18 @@
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch Docker containers",
|
||||
"endpointUnavailable": "{count} Docker endpoint(s) unavailable: {names}",
|
||||
"endpointUnavailable": "{count, plural, one {# Docker endpoint unavailable: {names}} other {# Docker endpoints unavailable: {names}}}",
|
||||
"noColumns": "Select at least one column in the widget settings"
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
"context": {
|
||||
"label": "Kubernetes context",
|
||||
"loadError": {
|
||||
"title": "Kubernetes contexts could not be loaded",
|
||||
"message": "Context information is unavailable. Try again without leaving this page.",
|
||||
"retry": "Try again"
|
||||
},
|
||||
"status": {
|
||||
"available": "Available",
|
||||
"degraded": "Metrics unavailable",
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("CatalogItem", () => {
|
||||
expect(getCatalogKeyboardDirection("Enter")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers arrow-key movement without replacing native Enter and Space activation", async () => {
|
||||
it("moves focus only for navigation keys", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onMoveFocus = vi.fn();
|
||||
await act(async () =>
|
||||
@@ -102,6 +102,7 @@ describe("CatalogItem", () => {
|
||||
expect(onMoveFocus).toHaveBeenCalledWith("next");
|
||||
|
||||
await act(async () => button?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
|
||||
await act(async () => button?.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true })));
|
||||
expect(onMoveFocus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -259,6 +259,12 @@ export default function DockerWidget({
|
||||
utils.docker.getServiceHealth.invalidate(),
|
||||
]);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("action.refresh.notification.error.title"),
|
||||
message: t("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
|
||||
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Button, Group, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { schemaResolver } from "@mantine/form";
|
||||
@@ -70,7 +70,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
const integrationsRequired =
|
||||
innerProps.integrationSupport &&
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
|
||||
const maxIntegrations = (definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity;
|
||||
const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
|
||||
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
|
||||
const optionsSuperRefine = (options as Record<symbol, unknown>)[OPTIONS_SUPER_REFINE] as
|
||||
| ((data: Record<string, unknown>, ctx: z.RefinementCtx) => void)
|
||||
@@ -120,6 +120,12 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds);
|
||||
const showIntegrationTab = Boolean(innerProps.onEditIntegration) && selectedIntegrations.length > 0;
|
||||
const showResourceTabs = showAppTab || showIntegrationTab;
|
||||
const [activeTab, setActiveTab] = useState<string | null>("widget");
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "integration" && !showIntegrationTab) setActiveTab("widget");
|
||||
if (activeTab === "app" && !showAppTab) setActiveTab("widget");
|
||||
}, [activeTab, showAppTab, showIntegrationTab]);
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
setIsSubmitting(true);
|
||||
@@ -223,7 +229,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
<FormProvider form={form}>
|
||||
{showResourceTabs ? (
|
||||
<Stack>
|
||||
<Tabs defaultValue="widget">
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
|
||||
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
|
||||
|
||||
@@ -141,7 +141,7 @@ export const WidgetIntegrationSelect = ({
|
||||
combobox.closeDropdown();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Backspace" && multiSelectValues.length > 0) {
|
||||
if (event.key === "Backspace" && canSelectMultiple && multiSelectValues.length > 0) {
|
||||
event.preventDefault();
|
||||
const lastValue = multiSelectValues.at(-1);
|
||||
if (lastValue) handleValueRemove(lastValue);
|
||||
|
||||
@@ -155,10 +155,22 @@ export const createFeatureCheckPlan = (root: string, kind: string): FeatureCheck
|
||||
executable: "node",
|
||||
args: ["--experimental-strip-types", "scripts/check-feature-contracts.mts"],
|
||||
},
|
||||
{ label: "Focused fixture and unit tests", executable: "pnpm", args: ["exec", "vitest", "run", ...sortedTests] },
|
||||
...(sortedTests.length > 0
|
||||
? [
|
||||
{
|
||||
label: "Focused fixture and unit tests",
|
||||
executable: "pnpm",
|
||||
args: ["exec", "vitest", "run", ...sortedTests],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
packageCommand("Affected package typechecks", sortedPackages),
|
||||
{ label: "Affected file lint", executable: "pnpm", args: ["exec", "oxlint", ...lintFiles] },
|
||||
{ label: "Affected file formatting", executable: "pnpm", args: ["exec", "oxfmt", "--check", ...sortedFiles] },
|
||||
...(lintFiles.length > 0
|
||||
? [{ label: "Affected file lint", executable: "pnpm", args: ["exec", "oxlint", ...lintFiles] }]
|
||||
: []),
|
||||
...(sortedFiles.length > 0
|
||||
? [{ label: "Affected file formatting", executable: "pnpm", args: ["exec", "oxfmt", "--check", ...sortedFiles] }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return {
|
||||
|
||||
@@ -74,10 +74,12 @@ const compareKeys = (
|
||||
});
|
||||
};
|
||||
|
||||
const ignoredDirectories = new Set([".git", ".turbo", ".next", "node_modules", "dist", "build", "coverage"]);
|
||||
|
||||
const walkFiles = (directory: string): string[] => {
|
||||
if (!existsSync(directory)) return [];
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (entry.isDirectory() && [".git", ".turbo", "node_modules"].includes(entry.name)) return [];
|
||||
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) return [];
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory() ? walkFiles(path) : [path];
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test } from "node:test";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { checkFeatureContracts } from "./contracts.mts";
|
||||
import { formatFeatureChanges, planFeatureGeneration } from "./generate-feature.mts";
|
||||
import { formatFeatureChanges, getIntegrationGeneratorChoices, planFeatureGeneration } from "./generate-feature.mts";
|
||||
// oxlint-disable-next-line import/no-unassigned-import -- Registers shared contract tests in this suite.
|
||||
import "./check-feature.contract.mts";
|
||||
|
||||
@@ -13,6 +13,55 @@ test("the checked-in feature contracts are consistent", () => {
|
||||
assert.deepEqual(checkFeatureContracts(root), []);
|
||||
});
|
||||
|
||||
test("reads generator choices from the canonical integration definitions", () => {
|
||||
const choices = getIntegrationGeneratorChoices(root);
|
||||
|
||||
assert.ok(choices.categories.includes("miscellaneous"));
|
||||
assert.ok(choices.secretKinds.includes("apiKey"));
|
||||
});
|
||||
|
||||
test("adds translations to empty JSON registries without a leading comma", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-feature-generator-"));
|
||||
try {
|
||||
for (const sourcePath of [
|
||||
"packages/definitions/src/integration.ts",
|
||||
"packages/definitions/src/widget.ts",
|
||||
"packages/definitions/src/widget-integration-map.ts",
|
||||
"packages/definitions/src/docs/widget-doc-slugs.ts",
|
||||
"packages/widgets/src/manifest.ts",
|
||||
"packages/widgets/src/catalog.ts",
|
||||
"packages/widgets/src/index.tsx",
|
||||
]) {
|
||||
const target = path.join(temporaryRoot, sourcePath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, sourcePath), target);
|
||||
}
|
||||
const translationPath = path.join(temporaryRoot, "packages/translation/src/lang/en.json");
|
||||
fs.mkdirSync(path.dirname(translationPath), { recursive: true });
|
||||
fs.writeFileSync(translationPath, '{\n "integration": {},\n "widget": {}\n}\n');
|
||||
|
||||
const changes = planFeatureGeneration(temporaryRoot, {
|
||||
widget: {
|
||||
kind: "exampleEmptyRegistry",
|
||||
name: "Example empty registry",
|
||||
slug: "example-empty-registry",
|
||||
description: "Verifies empty translation registry insertion.",
|
||||
icon: "IconBox",
|
||||
supportedIntegrations: [],
|
||||
},
|
||||
});
|
||||
const translation = changes.find((change) => change.path === "packages/translation/src/lang/en.json");
|
||||
|
||||
assert.ok(translation);
|
||||
assert.doesNotThrow(() => JSON.parse(translation.content));
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("paired generation plans every formatter-clean contract without writing", async () => {
|
||||
const description =
|
||||
"A deliberately long feature description that verifies generated source stays formatter-clean when contributor input exceeds the configured print width.";
|
||||
|
||||
@@ -91,7 +91,20 @@ const addJsonProperty = (source: string, property: "integration" | "widget", nam
|
||||
.join("\n");
|
||||
const beforeClose = source.slice(0, end);
|
||||
const trimmed = beforeClose.trimEnd();
|
||||
return `${trimmed},\n ${JSON.stringify(name)}: ${serialized}\n ${source.slice(end)}`;
|
||||
const separator = body.trim().length === 0 ? "" : ",";
|
||||
return `${trimmed}${separator}\n ${JSON.stringify(name)}: ${serialized}\n ${source.slice(end)}`;
|
||||
};
|
||||
|
||||
export const getIntegrationGeneratorChoices = (root: string) => {
|
||||
const source = read(root, "packages/definitions/src/integration.ts");
|
||||
const categoryRange = containerRange(source, "const integrationCategories", "[");
|
||||
const secretRange = containerRange(source, "const integrationSecretKindObject", "{");
|
||||
return {
|
||||
categories: [...source.slice(categoryRange.start, categoryRange.end).matchAll(/"([^"]+)"/g)].map(
|
||||
(match) => match[1],
|
||||
),
|
||||
secretKinds: [...source.slice(secretRange.start, secretRange.end).matchAll(/^ (\w+):/gm)].map((match) => match[1]),
|
||||
};
|
||||
};
|
||||
|
||||
const addChange = (
|
||||
@@ -123,19 +136,11 @@ const validate = (request: FeatureRequest) => {
|
||||
|
||||
const planIntegration = (root: string, feature: IntegrationFeature, changes: PlannedChange[]) => {
|
||||
const definitionSource = read(root, "packages/definitions/src/integration.ts");
|
||||
const categoryRange = containerRange(definitionSource, "const integrationCategories", "[");
|
||||
const categories = new Set(
|
||||
[...definitionSource.slice(categoryRange.start, categoryRange.end).matchAll(/"([^"]+)"/g)].map((match) => match[1]),
|
||||
);
|
||||
if (!categories.has(feature.category)) {
|
||||
const choices = getIntegrationGeneratorChoices(root);
|
||||
if (!choices.categories.includes(feature.category)) {
|
||||
throw new Error(`Generator stopped: ${feature.category} is not a current integration category.`);
|
||||
}
|
||||
const secretObject = definitionSource.slice(
|
||||
definitionSource.indexOf("const integrationSecretKindObject"),
|
||||
definitionSource.indexOf("satisfies Record<string", definitionSource.indexOf("const integrationSecretKindObject")),
|
||||
);
|
||||
const validSecrets = new Set([...secretObject.matchAll(/^ (\w+):/gm)].map((match) => match[1]));
|
||||
const unknownSecrets = feature.secretKinds.filter((kind) => !validSecrets.has(kind));
|
||||
const unknownSecrets = feature.secretKinds.filter((kind) => !choices.secretKinds.includes(kind));
|
||||
if (unknownSecrets.length > 0) {
|
||||
throw new Error(`Generator stopped: unknown credential kind(s): ${unknownSecrets.join(", ")}.`);
|
||||
}
|
||||
|
||||
+11
-66
@@ -1,79 +1,22 @@
|
||||
import type { PlopTypes } from "@turbo/gen";
|
||||
|
||||
import type { FeatureRequest } from "../../scripts/feature-platform/generate-feature.mts";
|
||||
import { generateFeature } from "../../scripts/feature-platform/generate-feature.mts";
|
||||
import { generateFeature, getIntegrationGeneratorChoices } from "../../scripts/feature-platform/generate-feature.mts";
|
||||
|
||||
type Answers = Record<string, unknown> & { turbo: { paths: { root: string } } };
|
||||
|
||||
const text = (value: unknown) => String(value ?? "").trim();
|
||||
const list = (value: unknown) =>
|
||||
text(value)
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
(Array.isArray(value) ? value : text(value).split(",")).map((item) => text(item)).filter(Boolean);
|
||||
const lowerCamel = (value: string) =>
|
||||
/^[a-z][A-Za-z0-9]*$/.test(value) || "Use lower camel case (for example, mediaServer).";
|
||||
const kebab = (value: string) =>
|
||||
/^[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*$/.test(value) ||
|
||||
"Start with a lowercase letter and use kebab-case (for example, media-server).";
|
||||
const required = (value: string) => value.trim().length > 0 || "This value is required.";
|
||||
const integrationCategories = [
|
||||
"dnsHole",
|
||||
"mediaService",
|
||||
"calendar",
|
||||
"mediaOrganizer",
|
||||
"mediaSearch",
|
||||
"mediaRelease",
|
||||
"mediaRequest",
|
||||
"downloadClient",
|
||||
"usenet",
|
||||
"torrent",
|
||||
"miscellaneous",
|
||||
"smartHomeServer",
|
||||
"indexerManager",
|
||||
"healthMonitoring",
|
||||
"beszel",
|
||||
"search",
|
||||
"mediaTranscoding",
|
||||
"networkController",
|
||||
"notifications",
|
||||
"firewall",
|
||||
"timetable",
|
||||
"photoService",
|
||||
"notes",
|
||||
"mediaMonitoring",
|
||||
"speedtest",
|
||||
"analytics",
|
||||
"vpn",
|
||||
"archiving",
|
||||
"ups",
|
||||
"documents",
|
||||
"mediaLibrary",
|
||||
"uptimeMonitoring",
|
||||
"subtitleManager",
|
||||
"reverseProxy",
|
||||
];
|
||||
const integrationSecretKinds = [
|
||||
"apiKey",
|
||||
"username",
|
||||
"password",
|
||||
"tokenId",
|
||||
"realm",
|
||||
"personalAccessToken",
|
||||
"topic",
|
||||
"opnsenseApiKey",
|
||||
"opnsenseApiSecret",
|
||||
"patchmonApiKey",
|
||||
"patchmonApiSecret",
|
||||
"url",
|
||||
"privateKey",
|
||||
"githubAppId",
|
||||
"githubInstallationId",
|
||||
"slug",
|
||||
];
|
||||
|
||||
const integrationPrompts = (prefix = "") =>
|
||||
[
|
||||
const integrationPrompts = (root: string, prefix = "") => {
|
||||
const choices = getIntegrationGeneratorChoices(root);
|
||||
return [
|
||||
{ type: "input", name: `${prefix}Kind`, message: "Integration kind", validate: lowerCamel },
|
||||
{ type: "input", name: `${prefix}Name`, message: "Integration display name", validate: required },
|
||||
{ type: "input", name: `${prefix}Slug`, message: "Integration docs and folder slug", validate: kebab },
|
||||
@@ -87,14 +30,14 @@ const integrationPrompts = (prefix = "") =>
|
||||
type: "list",
|
||||
name: `${prefix}Category`,
|
||||
message: "Existing integration category",
|
||||
choices: integrationCategories,
|
||||
choices: choices.categories,
|
||||
default: "miscellaneous",
|
||||
},
|
||||
{
|
||||
type: "checkbox",
|
||||
name: `${prefix}SecretKinds`,
|
||||
message: "Credential kinds (leave empty for an unauthenticated integration)",
|
||||
choices: integrationSecretKinds,
|
||||
choices: choices.secretKinds,
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
@@ -105,6 +48,7 @@ const integrationPrompts = (prefix = "") =>
|
||||
validate: required,
|
||||
},
|
||||
] satisfies PlopTypes.PromptQuestion[];
|
||||
};
|
||||
|
||||
const widgetPrompts = (prefix = "") =>
|
||||
[
|
||||
@@ -149,9 +93,10 @@ const action =
|
||||
};
|
||||
|
||||
export default function generator(plop: PlopTypes.NodePlopAPI) {
|
||||
const root = plop.getDestBasePath();
|
||||
plop.setGenerator("native-integration", {
|
||||
description: "Scaffold a native integration, tests, docs, translations, and current explicit registries",
|
||||
prompts: integrationPrompts(),
|
||||
prompts: integrationPrompts(root),
|
||||
actions: [action((answers) => ({ integration: integrationFrom(answers) }))],
|
||||
});
|
||||
plop.setGenerator("widget", {
|
||||
@@ -161,7 +106,7 @@ export default function generator(plop: PlopTypes.NodePlopAPI) {
|
||||
});
|
||||
plop.setGenerator("integration-widget", {
|
||||
description: "Scaffold a paired native integration and widget contract",
|
||||
prompts: [...integrationPrompts("integration"), ...widgetPrompts("widget")],
|
||||
prompts: [...integrationPrompts(root, "integration"), ...widgetPrompts("widget")],
|
||||
actions: [
|
||||
action((answers) => {
|
||||
const integration = integrationFrom(answers, "integration");
|
||||
|
||||
@@ -53,6 +53,15 @@ export default defineConfig({
|
||||
include: ["packages/request-handler/**/*.spec.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "docker-node",
|
||||
environment: "node",
|
||||
setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"],
|
||||
include: ["packages/docker/**/*.spec.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
@@ -65,6 +74,7 @@ export default defineConfig({
|
||||
"packages/api/**",
|
||||
"packages/custom-widgets/**",
|
||||
"packages/db/**",
|
||||
"packages/docker/**",
|
||||
"packages/request-handler/**",
|
||||
"e2e/**",
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user