enhance release v2 experience
This commit is contained in:
1 parent
9b4970583e
commit
ef2ed6c602
152 files changed
+7236
-764
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getTrackedFeatureProperties } from "./analytics-properties";
|
||||
|
||||
describe("getTrackedFeatureProperties", () => {
|
||||
test("keeps setup telemetry anonymous and strips configuration-shaped properties", () => {
|
||||
expect(
|
||||
getTrackedFeatureProperties(
|
||||
"setup:widget-completed",
|
||||
{
|
||||
entryPoint: "board",
|
||||
outcome: "completed",
|
||||
elapsedMs: 1200,
|
||||
hasBoardContext: true,
|
||||
userId: "supplied-user",
|
||||
url: "https://private.example",
|
||||
recordId: "secret-id",
|
||||
},
|
||||
"session-user",
|
||||
),
|
||||
).toEqual({ entryPoint: "board", outcome: "completed", elapsedMs: 1200, hasBoardContext: true });
|
||||
});
|
||||
|
||||
test("preserves the existing identified contract for non-setup events", () => {
|
||||
expect(getTrackedFeatureProperties("theme-changed", { theme: "dark" }, "user-1")).toEqual({
|
||||
theme: "dark",
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const setupPropertyKeys = new Set([
|
||||
"entryPoint",
|
||||
"intent",
|
||||
"outcome",
|
||||
"elapsedMs",
|
||||
"hasBoardContext",
|
||||
"canResolveInline",
|
||||
]);
|
||||
|
||||
export const getTrackedFeatureProperties = (
|
||||
feature: string,
|
||||
properties: Record<string, unknown> | undefined,
|
||||
userId: string,
|
||||
) => {
|
||||
if (!feature.startsWith("setup:")) return { ...properties, userId };
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(properties ?? {}).filter(
|
||||
([key, value]) => setupPropertyKeys.has(key) && ["string", "number", "boolean"].includes(typeof value),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { getTrackedFeatureProperties } from "./analytics-properties";
|
||||
|
||||
const getInstanceId = async (): Promise<string | null> => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return null;
|
||||
@@ -34,10 +35,11 @@ export const analyticsRouter = createTRPCRouter({
|
||||
const instanceId = await getInstanceId();
|
||||
if (!instanceId) return;
|
||||
|
||||
trackEvent(instanceId, `feature:${input.feature}`, {
|
||||
...input.properties,
|
||||
userId: ctx.session.user.id,
|
||||
});
|
||||
trackEvent(
|
||||
instanceId,
|
||||
`feature:${input.feature}`,
|
||||
getTrackedFeatureProperties(input.feature, input.properties, ctx.session.user.id),
|
||||
);
|
||||
}),
|
||||
|
||||
heartbeat: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
|
||||
@@ -566,9 +566,11 @@ export const boardRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(boardCreateSchema)
|
||||
.output(z.object({ boardId: z.string() }))
|
||||
.output(z.object({ boardId: z.string(), name: z.string(), layoutId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const boardId = createId();
|
||||
const mobileLayoutId = createId();
|
||||
const baseLayoutId = createId();
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, ctx.session.user.id),
|
||||
@@ -594,7 +596,7 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
createBoardCollection.layouts.push(
|
||||
{
|
||||
id: createId(),
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
@@ -604,7 +606,7 @@ export const boardRouter = createTRPCRouter({
|
||||
boardId,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
id: baseLayoutId,
|
||||
name: "Base",
|
||||
columnCount: input.columnCount,
|
||||
leftGutterColumnCount: 0,
|
||||
@@ -621,7 +623,7 @@ export const boardRouter = createTRPCRouter({
|
||||
await ctx.db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, ctx.session.user.id));
|
||||
}
|
||||
|
||||
return { boardId };
|
||||
return { boardId, name: input.name, layoutId: baseLayoutId };
|
||||
}),
|
||||
duplicateBoard: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import {
|
||||
buildDockerServiceUrlCandidates,
|
||||
getIntegrationDefaultPort,
|
||||
matchDockerService,
|
||||
normalizeDockerServiceUrl,
|
||||
} from "@homarr/definitions";
|
||||
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
|
||||
|
||||
export type DockerReconciliationState = "newRecognized" | "newApp" | "represented" | "linked" | "moved";
|
||||
export type DockerReconciliationNextAction =
|
||||
| "setupIntegration"
|
||||
| "createApp"
|
||||
| "viewRepresentation"
|
||||
| "reviewIntegration";
|
||||
|
||||
export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
const [{ data, timestamp }, integrations, apps] = await Promise.all([
|
||||
dockerContainersRequestHandler.handler({}).getDataAsync(),
|
||||
db.query.integrations.findMany({
|
||||
with: {
|
||||
items: { columns: { itemId: true } },
|
||||
secrets: { columns: { kind: true } },
|
||||
},
|
||||
}),
|
||||
db.query.apps.findMany(),
|
||||
]);
|
||||
const containerNameCounts = new Map<string, number>();
|
||||
for (const container of data.containers) {
|
||||
const name = normalizeName(container.name);
|
||||
containerNameCounts.set(name, (containerNameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const candidates = data.containers.map((container) => {
|
||||
const match = matchDockerService(container);
|
||||
const urlCandidates = buildDockerServiceUrlCandidates({
|
||||
containerName: container.name,
|
||||
endpointHost: container.host,
|
||||
ports: container.ports,
|
||||
preferredPort: match ? getIntegrationDefaultPort(match.kind) : undefined,
|
||||
});
|
||||
const candidateUrls = new Set(
|
||||
urlCandidates.map(({ url }) => normalizeDockerServiceUrl(url)).filter((url) => url !== null),
|
||||
);
|
||||
const uniqueName = containerNameCounts.get(normalizeName(container.name)) === 1;
|
||||
const sameKindIntegrations = match ? integrations.filter(({ kind }) => kind === match.kind) : [];
|
||||
const exactIntegrationMatches = sameKindIntegrations.filter(({ url }) => {
|
||||
const normalizedUrl = normalizeDockerServiceUrl(url);
|
||||
return normalizedUrl ? candidateUrls.has(normalizedUrl) : false;
|
||||
});
|
||||
const nameIntegrationMatches = uniqueName
|
||||
? sameKindIntegrations.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
|
||||
: [];
|
||||
const exactIntegration = exactIntegrationMatches.length === 1 ? exactIntegrationMatches[0] : undefined;
|
||||
const nameIntegration = nameIntegrationMatches.length === 1 ? nameIntegrationMatches[0] : undefined;
|
||||
const integration = exactIntegration ?? nameIntegration;
|
||||
const exactAppMatches = apps.filter(({ href }) => {
|
||||
const normalizedUrl = normalizeDockerServiceUrl(href);
|
||||
return normalizedUrl ? candidateUrls.has(normalizedUrl) : false;
|
||||
});
|
||||
const nameAppMatches = uniqueName
|
||||
? apps.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
|
||||
: [];
|
||||
const exactApp = exactAppMatches.length === 1 ? exactAppMatches[0] : undefined;
|
||||
const nameApp = nameAppMatches.length === 1 ? nameAppMatches[0] : undefined;
|
||||
const linkedApp = integration?.appId ? apps.find(({ id }) => id === integration.appId) : undefined;
|
||||
const app = linkedApp ?? exactApp ?? nameApp;
|
||||
const exactUrl = Boolean(exactIntegration || exactApp);
|
||||
const linked = Boolean(integration?.appId && linkedApp);
|
||||
const state: DockerReconciliationState = linked
|
||||
? "linked"
|
||||
: integration && !exactUrl
|
||||
? "moved"
|
||||
: integration || app
|
||||
? "represented"
|
||||
: match
|
||||
? "newRecognized"
|
||||
: "newApp";
|
||||
|
||||
return {
|
||||
candidateKey: container.resourceId,
|
||||
endpointId: container.endpointId,
|
||||
endpointName: container.endpointName,
|
||||
nativeId: container.id,
|
||||
container,
|
||||
match,
|
||||
urlCandidates,
|
||||
state,
|
||||
nextAction: nextActionForState(state),
|
||||
representation: {
|
||||
integration: integration
|
||||
? {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
appId: integration.appId,
|
||||
itemCount: integration.items.length,
|
||||
configuredSecretKinds: integration.secrets.map(({ kind }) => kind),
|
||||
}
|
||||
: null,
|
||||
app: app ? { id: app.id, name: app.name, href: app.href } : null,
|
||||
signals: {
|
||||
exactUrl,
|
||||
nameMatch: Boolean(nameIntegration || nameApp),
|
||||
linked,
|
||||
ambiguous:
|
||||
exactIntegrationMatches.length > 1 ||
|
||||
nameIntegrationMatches.length > 1 ||
|
||||
exactAppMatches.length > 1 ||
|
||||
nameAppMatches.length > 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { candidates, endpoints: data.endpoints, timestamp };
|
||||
};
|
||||
|
||||
export const getDockerServiceHealthAsync = async (db: Database) => {
|
||||
const reconciliation = await getDockerReconciliationAsync(db);
|
||||
return {
|
||||
timestamp: reconciliation.timestamp,
|
||||
endpoints: reconciliation.endpoints.map((endpoint) => ({
|
||||
key: `endpoint:${endpoint.id}`,
|
||||
identity: { endpointId: endpoint.id, name: endpoint.name },
|
||||
layer: "docker" as const,
|
||||
status: endpoint.status,
|
||||
nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const),
|
||||
})),
|
||||
services: reconciliation.candidates.map((candidate) => ({
|
||||
key: candidate.candidateKey,
|
||||
identity: {
|
||||
endpointId: candidate.endpointId,
|
||||
nativeId: candidate.nativeId,
|
||||
name: candidate.container.name,
|
||||
integration: candidate.representation.integration,
|
||||
app: candidate.representation.app,
|
||||
},
|
||||
layers: [
|
||||
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
|
||||
{
|
||||
layer: "integrationConfiguration" as const,
|
||||
status: integrationHealthStatus(
|
||||
candidate.state,
|
||||
candidate.match?.kind,
|
||||
Boolean(candidate.representation.integration),
|
||||
),
|
||||
nextAction:
|
||||
candidate.state === "moved"
|
||||
? ("reviewIntegration" as const)
|
||||
: candidate.state === "newRecognized"
|
||||
? ("setupIntegration" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "authentication" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: candidate.representation.integration
|
||||
? ("testConnection" as const)
|
||||
: candidate.match
|
||||
? ("setupIntegration" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "apiRequest" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction: candidate.representation.integration
|
||||
? ("openIntegrationDiagnostics" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "appRepresentation" as const,
|
||||
status: candidate.representation.app ? ("linked" as const) : ("missing" as const),
|
||||
nextAction: candidate.representation.app ? ("none" as const) : ("createApp" as const),
|
||||
},
|
||||
{
|
||||
layer: "widgetConfiguration" as const,
|
||||
status: candidate.representation.integration
|
||||
? candidate.representation.integration.itemCount > 0
|
||||
? ("linked" as const)
|
||||
: ("unused" as const)
|
||||
: ("notApplicable" as const),
|
||||
nextAction:
|
||||
candidate.representation.integration && candidate.representation.integration.itemCount === 0
|
||||
? ("addWidget" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
{
|
||||
layer: "widgetQuery" as const,
|
||||
status: "notObserved" as const,
|
||||
nextAction:
|
||||
candidate.representation.integration && candidate.representation.integration.itemCount > 0
|
||||
? ("openBoard" as const)
|
||||
: ("none" as const),
|
||||
},
|
||||
],
|
||||
status: candidate.state,
|
||||
nextAction: candidate.nextAction,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeName = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => {
|
||||
if (state === "newRecognized") return "setupIntegration";
|
||||
if (state === "newApp") return "createApp";
|
||||
if (state === "moved") return "reviewIntegration";
|
||||
return "viewRepresentation";
|
||||
};
|
||||
|
||||
const integrationHealthStatus = (
|
||||
state: DockerReconciliationState,
|
||||
kind: IntegrationKind | undefined,
|
||||
hasIntegration: boolean,
|
||||
) => {
|
||||
if (!kind) return "notApplicable" as const;
|
||||
if (state === "moved") return "changed" as const;
|
||||
return hasIntegration ? ("configured" as const) : ("missing" as const);
|
||||
};
|
||||
@@ -2,19 +2,33 @@ import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
|
||||
import { DockerSingleton } from "@homarr/docker";
|
||||
import type { ContainerState, DockerContainerTarget, Port } from "@homarr/docker";
|
||||
import {
|
||||
dockerContainersRequestHandler,
|
||||
findDockerContainerAsync,
|
||||
getContainerLogsAsync,
|
||||
hasDockerEndpointCapability,
|
||||
streamContainerLogsAsync,
|
||||
} from "@homarr/request-handler/docker";
|
||||
|
||||
import { dockerMiddleware } from "../../middlewares/docker";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
import { throwIfDockerOperationsFailed } from "./docker-operation-results";
|
||||
import { getDockerReconciliationAsync, getDockerServiceHealthAsync } from "./docker-reconciliation";
|
||||
|
||||
const dockerContainerTargetSchema = z.object({
|
||||
endpointId: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export const dockerRouter = createTRPCRouter({
|
||||
reconcileServices: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => await getDockerReconciliationAsync(ctx.db)),
|
||||
getServiceHealth: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => await getDockerServiceHealthAsync(ctx.db)),
|
||||
getContainers: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
@@ -31,7 +45,8 @@ export const dockerRouter = createTRPCRouter({
|
||||
const { data, timestamp } = result;
|
||||
|
||||
return {
|
||||
containers: data satisfies DockerContainer[],
|
||||
containers: data.containers satisfies DockerContainer[],
|
||||
endpoints: data.endpoints,
|
||||
timestamp,
|
||||
};
|
||||
}),
|
||||
@@ -40,58 +55,39 @@ export const dockerRouter = createTRPCRouter({
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Start Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
description: "Start Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.start();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "start");
|
||||
}),
|
||||
stopAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Stop Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
description: "Stop Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.stop();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "stop");
|
||||
}),
|
||||
restartAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Restart Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
description: "Restart Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.restart();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "restart");
|
||||
}),
|
||||
removeAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -99,19 +95,13 @@ export const dockerRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Remove/delete Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
|
||||
"Remove/delete Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.input(z.object({ targets: z.array(dockerContainerTargetSchema) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const results = await Promise.allSettled(
|
||||
input.ids.map(async (id) => {
|
||||
const container = await getContainerOrThrowAsync(id);
|
||||
await container.remove();
|
||||
}),
|
||||
);
|
||||
throwIfDockerOperationsFailed(results);
|
||||
return await performDockerContainerActionsAsync(input.targets, "remove");
|
||||
}),
|
||||
logs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -119,18 +109,19 @@ export const dockerRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Fetch logs from a Docker container. REQUIRED: id (container ID from docker_getContainers). OPTIONAL: tail (number 1-1000, default 200)",
|
||||
"Fetch logs from a Docker container. REQUIRED: endpointId and id from docker_getContainers. OPTIONAL: tail (number 1-1000, default 200)",
|
||||
},
|
||||
})
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
...dockerContainerTargetSchema.shape,
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const logs = await getContainerLogsAsync(input.id, input.tail ?? 200);
|
||||
assertDockerEndpointCapability(input.endpointId, "logs");
|
||||
const logs = await getContainerLogsAsync(input, input.tail ?? 200);
|
||||
|
||||
if (!logs) {
|
||||
throw new TRPCError({
|
||||
@@ -148,11 +139,12 @@ export const dockerRouter = createTRPCRouter({
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
...dockerContainerTargetSchema.shape,
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.subscription(({ input }) => {
|
||||
assertDockerEndpointCapability(input.endpointId, "logs");
|
||||
return observable<string>((emit) => {
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
let isSubscribed = true;
|
||||
@@ -160,7 +152,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
const initializeStreamAsync = async () => {
|
||||
try {
|
||||
cleanupFn = await streamContainerLogsAsync(
|
||||
input.id,
|
||||
input,
|
||||
input.tail ?? 200,
|
||||
(data) => {
|
||||
if (isSubscribed) {
|
||||
@@ -200,36 +192,49 @@ export const dockerRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
|
||||
const container = instance.getContainer(id);
|
||||
type DockerContainerAction = "start" | "stop" | "restart" | "remove";
|
||||
|
||||
return await new Promise<Container | null>((resolve) => {
|
||||
container.inspect((err, data) => {
|
||||
if (err || !data) {
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(container);
|
||||
export const performDockerContainerActionsAsync = async (
|
||||
targets: DockerContainerTarget[],
|
||||
action: DockerContainerAction,
|
||||
) =>
|
||||
await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const capability = action === "remove" ? "remove" : "lifecycle";
|
||||
if (!hasDockerEndpointCapability(target.endpointId, capability)) {
|
||||
return { target, success: false as const, error: `Endpoint does not permit ${capability} actions` };
|
||||
}
|
||||
const container = await findDockerContainerAsync(target, capability);
|
||||
if (!container) return { target, success: false as const, error: "Container not found" };
|
||||
|
||||
if (action === "start") await container.start();
|
||||
else if (action === "stop") await container.stop();
|
||||
else if (action === "restart") await container.restart();
|
||||
else await container.remove();
|
||||
return { target, success: true as const };
|
||||
} catch (error) {
|
||||
return {
|
||||
target,
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Docker action failed",
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const getContainerOrThrowAsync = async (id: string) => {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const containers = await Promise.all(dockerInstances.map(({ instance }) => getContainerOrDefaultAsync(instance, id)));
|
||||
const foundContainer = containers.find((container) => container) ?? null;
|
||||
|
||||
if (!foundContainer) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Container not found",
|
||||
});
|
||||
}
|
||||
|
||||
return foundContainer;
|
||||
const assertDockerEndpointCapability = (
|
||||
endpointId: string,
|
||||
capability: "inventory" | "logs" | "lifecycle" | "remove",
|
||||
) => {
|
||||
if (hasDockerEndpointCapability(endpointId, capability)) return;
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: `Docker endpoint does not permit ${capability} access` });
|
||||
};
|
||||
|
||||
interface DockerContainer {
|
||||
endpointId: string;
|
||||
endpointName: string;
|
||||
resourceId: string;
|
||||
name: string;
|
||||
id: string;
|
||||
host: string;
|
||||
|
||||
@@ -368,6 +368,16 @@ export const integrationRouter = createTRPCRouter({
|
||||
kind: input.kind,
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
url: input.url,
|
||||
},
|
||||
appId,
|
||||
};
|
||||
}),
|
||||
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { KubeConfig } from "@kubernetes/client-node";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { KubernetesClientRegistry, KubernetesContextNotFoundError } from "./kubernetes-client";
|
||||
|
||||
const createRegistry = () => {
|
||||
const kubeConfig = new KubeConfig();
|
||||
const contextNames = ["home", "remote", "metrics-degraded"];
|
||||
kubeConfig.loadFromOptions({
|
||||
clusters: contextNames.map((name) => ({ name, server: `https://${name}.example` })),
|
||||
users: contextNames.map((name) => ({ name, token: `${name}-token` })),
|
||||
contexts: contextNames.map((name) => ({ name, cluster: name, user: name })),
|
||||
currentContext: "home",
|
||||
});
|
||||
return new KubernetesClientRegistry(kubeConfig);
|
||||
};
|
||||
|
||||
describe("KubernetesClientRegistry", () => {
|
||||
test("isolates duplicate resource names by context", async () => {
|
||||
const registry = createRegistry();
|
||||
const home = registry.getClient("home");
|
||||
const remote = registry.getClient("remote");
|
||||
vi.spyOn(home.coreApi, "listNamespace").mockResolvedValue({ items: [{ metadata: { name: "default" } }] } as never);
|
||||
vi.spyOn(remote.coreApi, "listNamespace").mockResolvedValue({
|
||||
items: [{ metadata: { name: "default" } }],
|
||||
} as never);
|
||||
|
||||
const [homeNamespaces, remoteNamespaces] = await Promise.all([
|
||||
home.coreApi.listNamespace(),
|
||||
remote.coreApi.listNamespace(),
|
||||
]);
|
||||
|
||||
expect(homeNamespaces.items[0]?.metadata?.name).toBe("default");
|
||||
expect(remoteNamespaces.items[0]?.metadata?.name).toBe("default");
|
||||
expect(home).not.toBe(remote);
|
||||
expect(home.kubeConfig.getCurrentContext()).toBe("home");
|
||||
expect(remote.kubeConfig.getCurrentContext()).toBe("remote");
|
||||
});
|
||||
|
||||
test("lists unavailable and metrics-degraded contexts without failing healthy contexts", async () => {
|
||||
const registry = createRegistry();
|
||||
for (const contextId of ["home", "remote", "metrics-degraded"]) {
|
||||
const client = registry.getClient(contextId);
|
||||
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
|
||||
vi.spyOn(client.metricsApi, "getNodeMetrics").mockResolvedValue({ items: [] } as never);
|
||||
}
|
||||
registry.getClient("remote").versionApi.getCode = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
registry.getClient("metrics-degraded").metricsApi.getNodeMetrics = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("no metrics"));
|
||||
|
||||
const result = await registry.getContextsAsync();
|
||||
|
||||
expect(result.defaultContextId).toBe("home");
|
||||
expect(result.contexts).toEqual([
|
||||
{ contextId: "home", name: "home", status: "available", metricsStatus: "available", isDefault: true },
|
||||
{ contextId: "remote", name: "remote", status: "unavailable", metricsStatus: "unknown", isDefault: false },
|
||||
{
|
||||
contextId: "metrics-degraded",
|
||||
name: "metrics-degraded",
|
||||
status: "degraded",
|
||||
metricsStatus: "unavailable",
|
||||
isDefault: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects unknown context IDs", () => {
|
||||
expect(() => createRegistry().getClient("missing")).toThrow(KubernetesContextNotFoundError);
|
||||
});
|
||||
|
||||
test("uses the first configured context when current-context is empty", async () => {
|
||||
const kubeConfig = new KubeConfig();
|
||||
kubeConfig.loadFromOptions({
|
||||
clusters: [{ name: "first", server: "https://first.example" }],
|
||||
users: [{ name: "first", token: "token" }],
|
||||
contexts: [{ name: "first", cluster: "first", user: "first" }],
|
||||
currentContext: "",
|
||||
});
|
||||
const registry = new KubernetesClientRegistry(kubeConfig);
|
||||
const client = registry.getClient("first");
|
||||
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
|
||||
vi.spyOn(client.metricsApi, "getNodeMetrics").mockResolvedValue({ items: [] } as never);
|
||||
|
||||
const result = await registry.getContextsAsync();
|
||||
|
||||
expect(result.defaultContextId).toBe("first");
|
||||
expect(result.contexts[0]).toEqual(expect.objectContaining({ contextId: "first", isDefault: true }));
|
||||
});
|
||||
});
|
||||
@@ -3,69 +3,132 @@ import { CoreV1Api, KubeConfig, Metrics, NetworkingV1Api, VersionApi } from "@ku
|
||||
|
||||
import { env } from "../../env";
|
||||
|
||||
export interface KubernetesContextStatus {
|
||||
contextId: string;
|
||||
name: string;
|
||||
status: "available" | "degraded" | "unavailable";
|
||||
metricsStatus: "available" | "unavailable" | "unknown";
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export class KubernetesClient {
|
||||
private static instance: KubernetesClient | null = null;
|
||||
private static registry: KubernetesClientRegistry | null = null;
|
||||
public kubeConfig: KubeConfig;
|
||||
public coreApi: CoreV1Api;
|
||||
public networkingApi: NetworkingV1Api;
|
||||
public metricsApi: Metrics;
|
||||
public versionApi: VersionApi;
|
||||
|
||||
private constructor() {
|
||||
this.kubeConfig = new KubeConfig();
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
this.kubeConfig.loadFromDefault();
|
||||
} else {
|
||||
this.kubeConfig.loadFromCluster();
|
||||
|
||||
const currentCluster = this.kubeConfig.getCurrentCluster();
|
||||
if (!currentCluster) throw new Error("No cluster configuration found");
|
||||
|
||||
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
|
||||
const caData = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "utf8");
|
||||
|
||||
const clusterWithCA = {
|
||||
...currentCluster,
|
||||
name: `${currentCluster.name}-service-account`,
|
||||
caData,
|
||||
};
|
||||
|
||||
const serviceAccountUser = {
|
||||
name: env.KUBERNETES_SERVICE_ACCOUNT_NAME ?? "default-sa",
|
||||
token,
|
||||
};
|
||||
|
||||
this.kubeConfig.clusters = [];
|
||||
this.kubeConfig.users = [];
|
||||
|
||||
this.kubeConfig.addCluster(clusterWithCA);
|
||||
this.kubeConfig.addUser(serviceAccountUser);
|
||||
|
||||
const currentContext = this.kubeConfig.getCurrentContext();
|
||||
const originalContext = this.kubeConfig.getContextObject(currentContext);
|
||||
if (!originalContext) throw new Error("No context found");
|
||||
|
||||
const updatedContext = {
|
||||
...originalContext,
|
||||
name: `${originalContext.name}-service-account`,
|
||||
cluster: clusterWithCA.name,
|
||||
user: serviceAccountUser.name,
|
||||
};
|
||||
|
||||
this.kubeConfig.contexts = [];
|
||||
this.kubeConfig.addContext(updatedContext);
|
||||
this.kubeConfig.setCurrentContext(updatedContext.name);
|
||||
}
|
||||
|
||||
this.coreApi = this.kubeConfig.makeApiClient(CoreV1Api);
|
||||
this.networkingApi = this.kubeConfig.makeApiClient(NetworkingV1Api);
|
||||
this.metricsApi = new Metrics(this.kubeConfig);
|
||||
this.versionApi = this.kubeConfig.makeApiClient(VersionApi);
|
||||
public constructor(kubeConfig: KubeConfig) {
|
||||
this.kubeConfig = kubeConfig;
|
||||
this.coreApi = kubeConfig.makeApiClient(CoreV1Api);
|
||||
this.networkingApi = kubeConfig.makeApiClient(NetworkingV1Api);
|
||||
this.metricsApi = new Metrics(kubeConfig);
|
||||
this.versionApi = kubeConfig.makeApiClient(VersionApi);
|
||||
}
|
||||
|
||||
public static getInstance(): KubernetesClient {
|
||||
KubernetesClient.instance ??= new KubernetesClient();
|
||||
return KubernetesClient.instance;
|
||||
public static getInstance(contextId: string): KubernetesClient {
|
||||
return KubernetesClient.getRegistry().getClient(contextId);
|
||||
}
|
||||
|
||||
public static async getContextsAsync() {
|
||||
return await KubernetesClient.getRegistry().getContextsAsync();
|
||||
}
|
||||
|
||||
private static getRegistry() {
|
||||
KubernetesClient.registry ??= new KubernetesClientRegistry(loadKubeConfig());
|
||||
return KubernetesClient.registry;
|
||||
}
|
||||
}
|
||||
|
||||
export class KubernetesClientRegistry {
|
||||
private readonly clients = new Map<string, KubernetesClient>();
|
||||
private readonly defaultContextId: string;
|
||||
|
||||
public constructor(kubeConfig: KubeConfig) {
|
||||
const defaultContextId = kubeConfig.getCurrentContext() || kubeConfig.contexts[0]?.name;
|
||||
if (!defaultContextId) throw new Error("No Kubernetes context is configured");
|
||||
this.defaultContextId = defaultContextId;
|
||||
|
||||
for (const context of kubeConfig.contexts) {
|
||||
const contextConfig = new KubeConfig();
|
||||
contextConfig.loadFromOptions({
|
||||
clusters: kubeConfig.clusters,
|
||||
users: kubeConfig.users,
|
||||
contexts: kubeConfig.contexts,
|
||||
currentContext: context.name,
|
||||
});
|
||||
this.clients.set(context.name, new KubernetesClient(contextConfig));
|
||||
}
|
||||
}
|
||||
|
||||
public getClient(contextId: string) {
|
||||
const client = this.clients.get(contextId);
|
||||
if (!client) throw new KubernetesContextNotFoundError(contextId);
|
||||
return client;
|
||||
}
|
||||
|
||||
public async getContextsAsync() {
|
||||
const contexts = await Promise.all(
|
||||
[...this.clients.entries()].map(async ([contextId, client]): Promise<KubernetesContextStatus> => {
|
||||
const [api, metrics] = await Promise.allSettled([
|
||||
client.versionApi.getCode(),
|
||||
client.metricsApi.getNodeMetrics(),
|
||||
]);
|
||||
const status =
|
||||
api.status === "rejected" ? "unavailable" : metrics.status === "rejected" ? "degraded" : "available";
|
||||
|
||||
return {
|
||||
contextId,
|
||||
name: contextId,
|
||||
status,
|
||||
metricsStatus:
|
||||
api.status === "rejected" ? "unknown" : metrics.status === "rejected" ? "unavailable" : "available",
|
||||
isDefault: contextId === this.defaultContextId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { contexts, defaultContextId: this.defaultContextId };
|
||||
}
|
||||
}
|
||||
|
||||
export class KubernetesContextNotFoundError extends Error {
|
||||
public constructor(contextId: string) {
|
||||
super(`Kubernetes context '${contextId}' was not found`);
|
||||
}
|
||||
}
|
||||
|
||||
const loadKubeConfig = () => {
|
||||
const kubeConfig = new KubeConfig();
|
||||
|
||||
if (process.env.NODE_ENV === "development" || process.env.KUBECONFIG) {
|
||||
kubeConfig.loadFromDefault();
|
||||
return kubeConfig;
|
||||
}
|
||||
|
||||
kubeConfig.loadFromCluster();
|
||||
const currentCluster = kubeConfig.getCurrentCluster();
|
||||
if (!currentCluster) throw new Error("No cluster configuration found");
|
||||
|
||||
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
|
||||
const caData = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "utf8");
|
||||
const clusterWithCA = { ...currentCluster, name: `${currentCluster.name}-service-account`, caData };
|
||||
const serviceAccountUser = { name: env.KUBERNETES_SERVICE_ACCOUNT_NAME ?? "default-sa", token };
|
||||
const currentContext = kubeConfig.getContextObject(kubeConfig.getCurrentContext());
|
||||
if (!currentContext) throw new Error("No context found");
|
||||
const updatedContext = {
|
||||
...currentContext,
|
||||
name: `${currentContext.name}-service-account`,
|
||||
cluster: clusterWithCA.name,
|
||||
user: serviceAccountUser.name,
|
||||
};
|
||||
|
||||
kubeConfig.loadFromOptions({
|
||||
clusters: [clusterWithCA],
|
||||
users: [serviceAccountUser],
|
||||
contexts: [updatedContext],
|
||||
currentContext: updatedContext.name,
|
||||
});
|
||||
return kubeConfig;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { KubernetesClient, KubernetesContextNotFoundError } from "./kubernetes-client";
|
||||
|
||||
export const kubernetesContextInput = z.object({ contextId: z.string().min(1) });
|
||||
|
||||
export const getKubernetesClient = (contextId: string) => {
|
||||
try {
|
||||
return KubernetesClient.getInstance(contextId);
|
||||
} catch (error) {
|
||||
if (error instanceof KubernetesContextNotFoundError) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { calculateResourcePercentage } from "./resource-percentage";
|
||||
|
||||
describe("calculateResourcePercentage", () => {
|
||||
test("calculates a rounded percentage", () => {
|
||||
expect(calculateResourcePercentage(1, 3)).toBe(33.33);
|
||||
});
|
||||
|
||||
test("returns zero when no capacity is available", () => {
|
||||
expect(calculateResourcePercentage(1, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const calculateResourcePercentage = (used: number, available: number) =>
|
||||
available > 0 ? Number(((used / available) * 100).toFixed(2)) : 0;
|
||||
@@ -5,7 +5,8 @@ import type { ClusterResourceCount, KubernetesCluster } from "@homarr/definition
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
import { calculateResourcePercentage } from "../resource-percentage";
|
||||
import { CpuResourceParser } from "../resource-parser/cpu-resource-parser";
|
||||
import { MemoryResourceParser } from "../resource-parser/memory-resource-parser";
|
||||
|
||||
@@ -13,14 +14,17 @@ export const clusterRouter = createTRPCRouter({
|
||||
getCluster: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesCluster> => {
|
||||
const { coreApi, metricsApi, versionApi, kubeConfig } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesCluster> => {
|
||||
const { coreApi, metricsApi, versionApi, kubeConfig } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const versionInfo = await versionApi.getCode();
|
||||
const nodes = await coreApi.listNode();
|
||||
const nodeMetricsClient = await metricsApi.getNodeMetrics();
|
||||
const listPodForAllNamespaces = await coreApi.listPodForAllNamespaces();
|
||||
const [versionInfo, nodes, listPodForAllNamespaces, nodeMetricsClient] = await Promise.all([
|
||||
versionApi.getCode(),
|
||||
coreApi.listNode(),
|
||||
coreApi.listPodForAllNamespaces(),
|
||||
metricsApi.getNodeMetrics().catch(() => null),
|
||||
]);
|
||||
|
||||
let totalCPUCapacity = 0;
|
||||
let totalCPUAllocatable = 0;
|
||||
@@ -48,7 +52,7 @@ export const clusterRouter = createTRPCRouter({
|
||||
totalMemoryAllocatable += memoryAllocatable;
|
||||
|
||||
const nodeName = node.metadata?.name;
|
||||
const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === nodeName);
|
||||
const nodeMetric = nodeMetricsClient?.items.find((metric) => metric.metadata.name === nodeName);
|
||||
if (nodeMetric) {
|
||||
const cpuUsage = cpuResourceParser.parse(nodeMetric.usage.cpu);
|
||||
totalCPUUsage += cpuUsage;
|
||||
@@ -61,13 +65,11 @@ export const clusterRouter = createTRPCRouter({
|
||||
const reservedCPU = totalCPUCapacity - totalCPUAllocatable;
|
||||
const reservedMemory = totalMemoryCapacity - totalMemoryAllocatable;
|
||||
|
||||
const reservedCPUPercentage = (reservedCPU / totalCPUCapacity) * 100;
|
||||
const reservedMemoryPercentage = (reservedMemory / totalMemoryCapacity) * 100;
|
||||
|
||||
const usagePercentageAllocatable = (totalCPUUsage / totalCPUAllocatable) * 100;
|
||||
const usagePercentageMemoryAllocatable = (totalMemoryUsage / totalMemoryAllocatable) * 100;
|
||||
|
||||
const usedPodsPercentage = (listPodForAllNamespaces.items.length / totalCapacityPods) * 100;
|
||||
const reservedCPUPercentage = calculateResourcePercentage(reservedCPU, totalCPUCapacity);
|
||||
const reservedMemoryPercentage = calculateResourcePercentage(reservedMemory, totalMemoryCapacity);
|
||||
const usagePercentageAllocatable = calculateResourcePercentage(totalCPUUsage, totalCPUAllocatable);
|
||||
const usagePercentageMemoryAllocatable = calculateResourcePercentage(totalMemoryUsage, totalMemoryAllocatable);
|
||||
const usedPodsPercentage = calculateResourcePercentage(listPodForAllNamespaces.items.length, totalCapacityPods);
|
||||
|
||||
return {
|
||||
name: kubeConfig.getCurrentContext(),
|
||||
@@ -75,50 +77,59 @@ export const clusterRouter = createTRPCRouter({
|
||||
kubernetesVersion: versionInfo.gitVersion,
|
||||
architecture: versionInfo.platform,
|
||||
nodeCount: nodes.items.length,
|
||||
metricsAvailable: nodeMetricsClient !== null,
|
||||
capacity: [
|
||||
{
|
||||
type: "CPU",
|
||||
resourcesStats: [
|
||||
{
|
||||
percentageValue: Number(reservedCPUPercentage.toFixed(2)),
|
||||
percentageValue: reservedCPUPercentage,
|
||||
type: "Reserved",
|
||||
capacityUnit: "Cores",
|
||||
usedValue: Number(reservedCPU.toFixed(2)),
|
||||
maxUsedValue: Number(totalCPUCapacity.toFixed(2)),
|
||||
},
|
||||
{
|
||||
percentageValue: Number(usagePercentageAllocatable.toFixed(2)),
|
||||
type: "Used",
|
||||
capacityUnit: "Cores",
|
||||
usedValue: Number(totalCPUUsage.toFixed(2)),
|
||||
maxUsedValue: Number(totalCPUAllocatable.toFixed(2)),
|
||||
},
|
||||
...(nodeMetricsClient
|
||||
? [
|
||||
{
|
||||
percentageValue: usagePercentageAllocatable,
|
||||
type: "Used" as const,
|
||||
capacityUnit: "Cores",
|
||||
usedValue: Number(totalCPUUsage.toFixed(2)),
|
||||
maxUsedValue: Number(totalCPUAllocatable.toFixed(2)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Memory",
|
||||
resourcesStats: [
|
||||
{
|
||||
percentageValue: Number(reservedMemoryPercentage.toFixed(2)),
|
||||
percentageValue: reservedMemoryPercentage,
|
||||
type: "Reserved",
|
||||
capacityUnit: "GiB",
|
||||
usedValue: Number(reservedMemory.toFixed(2)),
|
||||
maxUsedValue: Number(totalMemoryCapacity.toFixed(2)),
|
||||
},
|
||||
{
|
||||
percentageValue: Number(usagePercentageMemoryAllocatable.toFixed(2)),
|
||||
type: "Used",
|
||||
capacityUnit: "GiB",
|
||||
usedValue: Number(totalMemoryUsage.toFixed(2)),
|
||||
maxUsedValue: Number(totalMemoryAllocatable.toFixed(2)),
|
||||
},
|
||||
...(nodeMetricsClient
|
||||
? [
|
||||
{
|
||||
percentageValue: usagePercentageMemoryAllocatable,
|
||||
type: "Used" as const,
|
||||
capacityUnit: "GiB",
|
||||
usedValue: Number(totalMemoryUsage.toFixed(2)),
|
||||
maxUsedValue: Number(totalMemoryAllocatable.toFixed(2)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Pods",
|
||||
resourcesStats: [
|
||||
{
|
||||
percentageValue: Number(usedPodsPercentage.toFixed(2)),
|
||||
percentageValue: usedPodsPercentage,
|
||||
type: "Used",
|
||||
usedValue: listPodForAllNamespaces.items.length,
|
||||
maxUsedValue: totalCapacityPods,
|
||||
@@ -137,8 +148,10 @@ export const clusterRouter = createTRPCRouter({
|
||||
}),
|
||||
getClusterResourceCounts: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.query(async (): Promise<ClusterResourceCount[]> => {
|
||||
const { coreApi, networkingApi } = KubernetesClient.getInstance();
|
||||
.concat(kubernetesMiddleware())
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<ClusterResourceCount[]> => {
|
||||
const { coreApi, networkingApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const [pods, ingresses, services, configMaps, namespaces, nodes, secrets, volumes] = await Promise.all([
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { KubernetesBaseResource } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const configMapsRouter = createTRPCRouter({
|
||||
getConfigMaps: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesBaseResource[]> => {
|
||||
const { coreApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesBaseResource[]> => {
|
||||
const { coreApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const configMaps = await coreApi.listConfigMapForAllNamespaces();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
|
||||
export const contextsRouter = createTRPCRouter({
|
||||
getContexts: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async () => await KubernetesClient.getContextsAsync()),
|
||||
});
|
||||
@@ -5,14 +5,15 @@ import type { KubernetesIngress, KubernetesIngressPath, KubernetesIngressRuleAnd
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const ingressesRouter = createTRPCRouter({
|
||||
getIngresses: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesIngress[]> => {
|
||||
const { networkingApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesIngress[]> => {
|
||||
const { networkingApi } = getKubernetesClient(input.contextId);
|
||||
try {
|
||||
const ingresses = await networkingApi.listIngressForAllNamespaces();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
|
||||
import { createTRPCRouter } from "../../../trpc";
|
||||
|
||||
export const kubernetesRouter = createTRPCRouter({
|
||||
contexts: lazy(() => import("./contexts").then((mod) => mod.contextsRouter)),
|
||||
nodes: lazy(() => import("./nodes").then((mod) => mod.nodesRouter)),
|
||||
cluster: lazy(() => import("./cluster").then((mod) => mod.clusterRouter)),
|
||||
namespaces: lazy(() => import("./namespaces").then((mod) => mod.namespacesRouter)),
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { KubernetesNamespace, KubernetesNamespaceState } from "@homarr/defi
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const namespacesRouter = createTRPCRouter({
|
||||
getNamespaces: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesNamespace[]> => {
|
||||
const { coreApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesNamespace[]> => {
|
||||
const { coreApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const namespaces = await coreApi.listNamespace();
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { KubernetesNode, KubernetesNodeState } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
import { calculateResourcePercentage } from "../resource-percentage";
|
||||
import { CpuResourceParser } from "../resource-parser/cpu-resource-parser";
|
||||
import { MemoryResourceParser } from "../resource-parser/memory-resource-parser";
|
||||
|
||||
@@ -12,12 +13,15 @@ export const nodesRouter = createTRPCRouter({
|
||||
getNodes: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesNode[]> => {
|
||||
const { coreApi, metricsApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesNode[]> => {
|
||||
const { coreApi, metricsApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const nodes = await coreApi.listNode();
|
||||
const nodeMetricsClient = await metricsApi.getNodeMetrics();
|
||||
const [nodes, nodeMetricsClient] = await Promise.all([
|
||||
coreApi.listNode(),
|
||||
metricsApi.getNodeMetrics().catch(() => null),
|
||||
]);
|
||||
const cpuResourceParser = new CpuResourceParser();
|
||||
const memoryResourceParser = new MemoryResourceParser();
|
||||
|
||||
@@ -34,20 +38,26 @@ export const nodesRouter = createTRPCRouter({
|
||||
let cpuUsage = 0;
|
||||
let memoryUsage = 0;
|
||||
|
||||
const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === name);
|
||||
const nodeMetric = nodeMetricsClient?.items.find((metric) => metric.metadata.name === name);
|
||||
if (nodeMetric) {
|
||||
cpuUsage += cpuResourceParser.parse(nodeMetric.usage.cpu);
|
||||
memoryUsage += memoryResourceParser.parse(nodeMetric.usage.memory);
|
||||
}
|
||||
|
||||
const usagePercentageCPUAllocatable = (cpuUsage / cpuAllocatable) * 100;
|
||||
const usagePercentageMemoryAllocatable = (memoryUsage / memoryAllocatable) * 100;
|
||||
const usagePercentageCPUAllocatable = nodeMetric
|
||||
? calculateResourcePercentage(cpuUsage, cpuAllocatable)
|
||||
: null;
|
||||
const usagePercentageMemoryAllocatable = nodeMetric
|
||||
? calculateResourcePercentage(memoryUsage, memoryAllocatable)
|
||||
: null;
|
||||
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
allocatableCpuPercentage: Number(usagePercentageCPUAllocatable.toFixed(0)),
|
||||
allocatableRamPercentage: Number(usagePercentageMemoryAllocatable.toFixed(0)),
|
||||
allocatableCpuPercentage:
|
||||
usagePercentageCPUAllocatable === null ? null : Math.round(usagePercentageCPUAllocatable),
|
||||
allocatableRamPercentage:
|
||||
usagePercentageMemoryAllocatable === null ? null : Math.round(usagePercentageMemoryAllocatable),
|
||||
podsCount: Number(node.status?.capacity?.pods),
|
||||
operatingSystem: node.status?.nodeInfo?.operatingSystem,
|
||||
architecture: node.status?.nodeInfo?.architecture,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { KubernetesPod } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
const logger = createLogger({ module: "podsRouter" });
|
||||
|
||||
@@ -15,8 +15,9 @@ export const podsRouter = createTRPCRouter({
|
||||
getPods: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesPod[]> => {
|
||||
const { coreApi, kubeConfig } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesPod[]> => {
|
||||
const { coreApi, kubeConfig } = getKubernetesClient(input.contextId);
|
||||
try {
|
||||
const podsResp = await coreApi.listPodForAllNamespaces();
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { KubernetesSecret } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const secretsRouter = createTRPCRouter({
|
||||
getSecrets: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesSecret[]> => {
|
||||
const { coreApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesSecret[]> => {
|
||||
const { coreApi } = getKubernetesClient(input.contextId);
|
||||
try {
|
||||
const secrets = await coreApi.listSecretForAllNamespaces();
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { KubernetesService } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const servicesRouter = createTRPCRouter({
|
||||
getServices: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesService[]> => {
|
||||
const { coreApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesService[]> => {
|
||||
const { coreApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const services = await coreApi.listServiceForAllNamespaces();
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { KubernetesVolume } from "@homarr/definitions";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
|
||||
|
||||
export const volumesRouter = createTRPCRouter({
|
||||
getVolumes: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(kubernetesMiddleware())
|
||||
.query(async (): Promise<KubernetesVolume[]> => {
|
||||
const { coreApi } = KubernetesClient.getInstance();
|
||||
.input(kubernetesContextInput)
|
||||
.query(async ({ input }): Promise<KubernetesVolume[]> => {
|
||||
const { coreApi } = getKubernetesClient(input.contextId);
|
||||
|
||||
try {
|
||||
const volumes = await coreApi.listPersistentVolumeClaimForAllNamespaces();
|
||||
|
||||
@@ -636,7 +636,7 @@ describe("createBoard should create a new board", () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
|
||||
const result = await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
|
||||
|
||||
// Assert
|
||||
const dbBoard = await db.query.boards.findFirst({
|
||||
@@ -663,6 +663,11 @@ describe("createBoard should create a new board", () => {
|
||||
expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
boardId: dbBoard?.id,
|
||||
name: "newBoard",
|
||||
layoutId: dbBoard?.layouts.find((layout) => layout.role === "base")?.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error when user has no board-create permission", async () => {
|
||||
|
||||
@@ -6,10 +6,44 @@ import { objectKeys } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { getPermissionsWithChildren } from "@homarr/definitions";
|
||||
import type { DockerEndpointStatus } from "@homarr/docker";
|
||||
|
||||
import type { RouterInputs } from "../../..";
|
||||
import type { RouterInputs, RouterOutputs } from "../../..";
|
||||
import { dockerRouter } from "../../docker/docker-router";
|
||||
|
||||
const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn());
|
||||
const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true));
|
||||
const getDockerDataAsyncMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
() => Promise<{
|
||||
data: {
|
||||
containers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||
endpoints: DockerEndpointStatus[];
|
||||
};
|
||||
timestamp: Date;
|
||||
}>
|
||||
>(async () => ({
|
||||
data: { containers: [], endpoints: [] },
|
||||
timestamp: new Date(),
|
||||
})),
|
||||
);
|
||||
const findIntegrationsAsyncMock = vi.fn(
|
||||
async (): Promise<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "sonarr";
|
||||
url: string;
|
||||
appId: string | null;
|
||||
items?: { itemId: string }[];
|
||||
secrets?: { kind: "apiKey" }[];
|
||||
}[]
|
||||
> => [],
|
||||
);
|
||||
const findAppsAsyncMock = vi.fn(
|
||||
async (): Promise<{ id: string; name: string; href: string | null; iconUrl: string }[]> => [],
|
||||
);
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
vi.mock("@homarr/docker", () => ({
|
||||
@@ -30,6 +64,8 @@ vi.mock("@homarr/docker", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/request-handler/docker", () => ({
|
||||
findDockerContainerAsync: findDockerContainerAsyncMock,
|
||||
hasDockerEndpointCapability: hasDockerEndpointCapabilityMock,
|
||||
getContainerLogsAsync: async () => {
|
||||
await Promise.resolve();
|
||||
return "logs";
|
||||
@@ -40,9 +76,7 @@ vi.mock("@homarr/request-handler/docker", () => ({
|
||||
},
|
||||
dockerContainersRequestHandler: {
|
||||
handler: () => ({
|
||||
getDataAsync: async () => {
|
||||
return await Promise.resolve({ data: [], timestamp: new Date() });
|
||||
},
|
||||
getDataAsync: getDockerDataAsyncMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -67,20 +101,43 @@ const procedureKeys = objectKeys(dockerRouter._def.procedures);
|
||||
const validInputs: {
|
||||
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
|
||||
} = {
|
||||
reconcileServices: undefined,
|
||||
getServiceHealth: undefined,
|
||||
getContainers: undefined,
|
||||
startAll: { ids: ["1"] },
|
||||
stopAll: { ids: ["1"] },
|
||||
restartAll: { ids: ["1"] },
|
||||
removeAll: { ids: ["1"] },
|
||||
logs: { id: "1", tail: 200 },
|
||||
subscribeLogs: { id: "1", tail: 200 },
|
||||
startAll: { targets: [{ endpointId: "one", id: "1" }] },
|
||||
stopAll: { targets: [{ endpointId: "one", id: "1" }] },
|
||||
restartAll: { targets: [{ endpointId: "one", id: "1" }] },
|
||||
removeAll: { targets: [{ endpointId: "one", id: "1" }] },
|
||||
logs: { endpointId: "one", id: "1", tail: 200 },
|
||||
subscribeLogs: { endpointId: "one", id: "1", tail: 200 },
|
||||
};
|
||||
|
||||
const database = {
|
||||
query: {
|
||||
integrations: { findMany: findIntegrationsAsyncMock },
|
||||
apps: { findMany: findAppsAsyncMock },
|
||||
},
|
||||
} as unknown as Database;
|
||||
|
||||
const createDockerContainer = () => ({
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
restart: vi.fn().mockResolvedValue(undefined),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const createAdminCaller = () =>
|
||||
dockerRouter.createCaller({
|
||||
db: database,
|
||||
deviceType: undefined,
|
||||
session: createSessionWithPermissions("admin"),
|
||||
});
|
||||
|
||||
describe("All procedures should only be accessible for users with admin permission", () => {
|
||||
test.each(procedureKeys)("Procedure %s should be accessible for users with admin permission", async (procedure) => {
|
||||
// Arrange
|
||||
const caller = dockerRouter.createCaller({
|
||||
db: null as unknown as Database,
|
||||
db: database,
|
||||
deviceType: undefined,
|
||||
session: createSessionWithPermissions("admin"),
|
||||
});
|
||||
@@ -122,3 +179,205 @@ describe("All procedures should only be accessible for users with admin permissi
|
||||
await expect(act()).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Docker action targeting", () => {
|
||||
test("enforces endpoint capabilities before running a lifecycle action", async () => {
|
||||
hasDockerEndpointCapabilityMock.mockReturnValueOnce(false);
|
||||
|
||||
const result = await createAdminCaller().stopAll({ targets: [{ endpointId: "readonly", id: "one" }] });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
target: { endpointId: "readonly", id: "one" },
|
||||
success: false,
|
||||
error: "Endpoint does not permit lifecycle actions",
|
||||
},
|
||||
]);
|
||||
expect(findDockerContainerAsyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("targets duplicate container IDs on their explicit endpoints", async () => {
|
||||
const first = createDockerContainer();
|
||||
const second = createDockerContainer();
|
||||
findDockerContainerAsyncMock.mockImplementation(async ({ endpointId }) =>
|
||||
endpointId === "first" ? first : second,
|
||||
);
|
||||
|
||||
const result = await createAdminCaller().startAll({
|
||||
targets: [
|
||||
{ endpointId: "first", id: "duplicate" },
|
||||
{ endpointId: "second", id: "duplicate" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(first.start).toHaveBeenCalledOnce();
|
||||
expect(second.start).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual([
|
||||
{ target: { endpointId: "first", id: "duplicate" }, success: true },
|
||||
{ target: { endpointId: "second", id: "duplicate" }, success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports partial failures per target", async () => {
|
||||
const healthy = createDockerContainer();
|
||||
const failing = createDockerContainer();
|
||||
failing.restart.mockRejectedValue(new Error("proxy denied restart"));
|
||||
findDockerContainerAsyncMock.mockImplementation(async ({ endpointId }) =>
|
||||
endpointId === "healthy" ? healthy : failing,
|
||||
);
|
||||
|
||||
const result = await createAdminCaller().restartAll({
|
||||
targets: [
|
||||
{ endpointId: "healthy", id: "one" },
|
||||
{ endpointId: "failing", id: "two" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ target: { endpointId: "healthy", id: "one" }, success: true },
|
||||
{
|
||||
target: { endpointId: "failing", id: "two" },
|
||||
success: false,
|
||||
error: "proxy denied restart",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("distinguishes unavailable endpoints from an empty inventory", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [],
|
||||
endpoints: [{ id: "remote", name: "Remote Docker", status: "unavailable" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
const result = await createAdminCaller().getContainers();
|
||||
|
||||
expect(result.containers).toEqual([]);
|
||||
expect(result.endpoints).toEqual([{ id: "remote", name: "Remote Docker", status: "unavailable" }]);
|
||||
});
|
||||
|
||||
test("keeps multiple recognized services of the same integration kind", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [
|
||||
createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr-home", publicPort: 8989 }),
|
||||
createInventoryContainer({ endpointId: "remote", id: "two", name: "sonarr-remote", publicPort: 18989 }),
|
||||
],
|
||||
endpoints: [
|
||||
{ id: "home", name: "Home", status: "available" },
|
||||
{ id: "remote", name: "Remote", status: "available" },
|
||||
] satisfies DockerEndpointStatus[],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates).toHaveLength(2);
|
||||
expect(result.candidates.map(({ match }) => match?.kind)).toEqual(["sonarr", "sonarr"]);
|
||||
expect(result.candidates.map(({ candidateKey }) => candidateKey)).toEqual(["home:one", "remote:two"]);
|
||||
});
|
||||
|
||||
test("preserves unavailable endpoints beside available service candidates", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [
|
||||
{ id: "home", name: "Home", status: "available" },
|
||||
{ id: "remote", name: "Remote", status: "unavailable" },
|
||||
] satisfies DockerEndpointStatus[],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates).toHaveLength(1);
|
||||
expect(result.endpoints).toContainEqual({ id: "remote", name: "Remote", status: "unavailable" });
|
||||
});
|
||||
|
||||
test("does not adopt an ambiguous existing integration", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
findIntegrationsAsyncMock.mockResolvedValueOnce([
|
||||
{ id: "first", name: "Sonarr one", kind: "sonarr", url: "http://home.example:8989", appId: null },
|
||||
{ id: "second", name: "Sonarr two", kind: "sonarr", url: "http://home.example:8989", appId: null },
|
||||
]);
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates[0]?.representation.integration).toBeNull();
|
||||
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(true);
|
||||
expect(result.candidates[0]?.state).toBe("newRecognized");
|
||||
});
|
||||
|
||||
test("projects persisted service layers without inventing runtime health", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
findIntegrationsAsyncMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: "integration",
|
||||
name: "Sonarr",
|
||||
kind: "sonarr",
|
||||
url: "http://home.example:8989",
|
||||
appId: "app",
|
||||
items: [{ itemId: "widget" }],
|
||||
secrets: [{ kind: "apiKey" }],
|
||||
},
|
||||
]);
|
||||
findAppsAsyncMock.mockResolvedValueOnce([
|
||||
{ id: "app", name: "Sonarr", href: "http://home.example:8989", iconUrl: "" },
|
||||
]);
|
||||
|
||||
const result = await createAdminCaller().getServiceHealth();
|
||||
|
||||
expect(result.services[0]?.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ layer: "docker", status: "available", nextAction: "none" },
|
||||
{ layer: "integrationConfiguration", status: "configured", nextAction: "none" },
|
||||
{ layer: "authentication", status: "notObserved", nextAction: "testConnection" },
|
||||
{ layer: "apiRequest", status: "notObserved", nextAction: "openIntegrationDiagnostics" },
|
||||
{ layer: "appRepresentation", status: "linked", nextAction: "none" },
|
||||
{ layer: "widgetConfiguration", status: "linked", nextAction: "none" },
|
||||
{ layer: "widgetQuery", status: "notObserved", nextAction: "openBoard" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
const createInventoryContainer = ({
|
||||
endpointId,
|
||||
id,
|
||||
name,
|
||||
publicPort,
|
||||
}: {
|
||||
endpointId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
publicPort: number;
|
||||
}) => ({
|
||||
id,
|
||||
endpointId,
|
||||
endpointName: endpointId,
|
||||
resourceId: `${endpointId}:${id}`,
|
||||
name,
|
||||
host: `${endpointId}.example:2375`,
|
||||
state: "running" as const,
|
||||
image: "lscr.io/linuxserver/sonarr:latest",
|
||||
iconUrl: null,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
ports: [{ IP: "0.0.0.0", PrivatePort: 8989, PublicPort: publicPort, Type: "tcp" }],
|
||||
});
|
||||
@@ -312,7 +312,7 @@ describe("create should create a new integration", () => {
|
||||
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fakeNow);
|
||||
await caller.create(input);
|
||||
const result = await caller.create(input);
|
||||
vi.useRealTimers();
|
||||
|
||||
const dbIntegration = await db.query.integrations.findFirst();
|
||||
@@ -327,6 +327,15 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
|
||||
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
expect(result).toEqual({
|
||||
integration: {
|
||||
id: dbIntegration!.id,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
url: input.url,
|
||||
},
|
||||
appId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("with create integration access should not create a search engine for media request search integrations", async () => {
|
||||
@@ -347,7 +356,7 @@ describe("create should create a new integration", () => {
|
||||
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fakeNow);
|
||||
await caller.create(input);
|
||||
const result = await caller.create(input);
|
||||
vi.useRealTimers();
|
||||
|
||||
const dbIntegration = await db.query.integrations.findFirst();
|
||||
@@ -363,6 +372,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
|
||||
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
expect(result).toMatchObject({ appId: dbIntegration!.appId, integration: { id: dbIntegration!.id } });
|
||||
|
||||
expect(dbSearchEngine).toBeUndefined();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user