feat: docker widget (#2288)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com> Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com> Co-authored-by: homarr-crowdin[bot] <190541745+homarr-crowdin[bot]@users.noreply.github.com> Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import type { RouterOutputs } from "@homarr/api";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useTimeAgo } from "@homarr/common";
|
import { useTimeAgo } from "@homarr/common";
|
||||||
import type { ContainerState } from "@homarr/docker";
|
import type { ContainerState } from "@homarr/docker";
|
||||||
|
import { containerStateColorMap } from "@homarr/docker/shared";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
@@ -244,21 +245,11 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerStates = {
|
|
||||||
created: "cyan",
|
|
||||||
running: "green",
|
|
||||||
paused: "yellow",
|
|
||||||
restarting: "orange",
|
|
||||||
exited: "red",
|
|
||||||
removing: "pink",
|
|
||||||
dead: "dark",
|
|
||||||
} satisfies Record<ContainerState, MantineColor>;
|
|
||||||
|
|
||||||
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||||
const t = useScopedI18n("docker.field.state.option");
|
const t = useScopedI18n("docker.field.state.option");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge size="lg" radius="sm" variant="light" w={120} color={containerStates[state]}>
|
<Badge size="lg" radius="sm" variant="light" w={120} color={containerStateColorMap[state]}>
|
||||||
{t(state)}
|
{t(state)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"main": "./src/main.ts",
|
"main": "./src/main.ts",
|
||||||
"types": "./src/main.ts",
|
"types": "./src/main.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:@opentelemetry/api --external:deasync --outfile=tasks.cjs",
|
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:*.node --external:@opentelemetry/api --external:deasync --outfile=tasks.cjs",
|
||||||
"clean": "rm -rf .turbo node_modules",
|
"clean": "rm -rf .turbo node_modules",
|
||||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
|||||||
@@ -1,90 +1,48 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { observable } from "@trpc/server/observable";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { db, like, or } from "@homarr/db";
|
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
|
||||||
import { icons } from "@homarr/db/schema";
|
|
||||||
import { DockerSingleton } from "@homarr/docker";
|
import { DockerSingleton } from "@homarr/docker";
|
||||||
import type { Container, ContainerInfo, ContainerState, Docker, Port } from "@homarr/docker";
|
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
|
||||||
import { logger } from "@homarr/log";
|
|
||||||
import { createCacheChannel } from "@homarr/redis";
|
|
||||||
|
|
||||||
import { dockerMiddleware } from "../../middlewares/docker";
|
import { dockerMiddleware } from "../../middlewares/docker";
|
||||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||||
|
|
||||||
const dockerCache = createCacheChannel<{
|
|
||||||
containers: (ContainerInfo & { instance: string; iconUrl: string | null })[];
|
|
||||||
}>("docker-containers", 5 * 60 * 1000);
|
|
||||||
|
|
||||||
export const dockerRouter = createTRPCRouter({
|
export const dockerRouter = createTRPCRouter({
|
||||||
getContainers: permissionRequiredProcedure
|
getContainers: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.concat(dockerMiddleware())
|
.concat(dockerMiddleware())
|
||||||
.query(async () => {
|
.query(async () => {
|
||||||
const result = await dockerCache
|
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||||
.consumeAsync(async () => {
|
const result = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
const dockerInstances = DockerSingleton.getInstances();
|
|
||||||
const containers = await Promise.all(
|
|
||||||
// Return all the containers of all the instances into only one item
|
|
||||||
dockerInstances.map(({ instance, host: key }) =>
|
|
||||||
instance.listContainers({ all: true }).then((containers) =>
|
|
||||||
containers.map((container) => ({
|
|
||||||
...container,
|
|
||||||
instance: key,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).then((containers) => containers.flat());
|
|
||||||
|
|
||||||
const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
|
|
||||||
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
|
|
||||||
const dbIcons =
|
|
||||||
likeQueries.length >= 1
|
|
||||||
? await db.query.icons.findMany({
|
|
||||||
where: or(...likeQueries),
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
containers: containers.map((container) => ({
|
|
||||||
...container,
|
|
||||||
iconUrl:
|
|
||||||
dbIcons.find((icon) => {
|
|
||||||
const extractedImage = extractImage(container);
|
|
||||||
if (!extractedImage) return false;
|
|
||||||
return icon.name.toLowerCase().includes(extractedImage.toLowerCase());
|
|
||||||
})?.url ?? null,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
logger.error(error);
|
|
||||||
return {
|
|
||||||
isError: true,
|
|
||||||
error: error as unknown,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if ("isError" in result) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "An error occurred while fetching the containers",
|
|
||||||
cause: result.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, timestamp } = result;
|
const { data, timestamp } = result;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
containers: sanitizeContainers(data.containers),
|
containers: data satisfies DockerContainer[],
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
subscribeContainers: permissionRequiredProcedure
|
||||||
|
.requiresPermission("admin")
|
||||||
|
.concat(dockerMiddleware())
|
||||||
|
.subscription(() => {
|
||||||
|
return observable<DockerContainer[]>((emit) => {
|
||||||
|
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||||
|
const unsubscribe = innerHandler.subscribe((data) => {
|
||||||
|
emit.next(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
}),
|
||||||
invalidate: permissionRequiredProcedure
|
invalidate: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.concat(dockerMiddleware())
|
.concat(dockerMiddleware())
|
||||||
.mutation(async () => {
|
.mutation(async () => {
|
||||||
await dockerCache.invalidateAsync();
|
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||||
return;
|
await innerHandler.invalidateAsync();
|
||||||
}),
|
}),
|
||||||
startAll: permissionRequiredProcedure
|
startAll: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
@@ -98,7 +56,8 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await dockerCache.invalidateAsync();
|
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||||
|
await innerHandler.invalidateAsync();
|
||||||
}),
|
}),
|
||||||
stopAll: permissionRequiredProcedure
|
stopAll: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
@@ -112,7 +71,8 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await dockerCache.invalidateAsync();
|
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||||
|
await innerHandler.invalidateAsync();
|
||||||
}),
|
}),
|
||||||
restartAll: permissionRequiredProcedure
|
restartAll: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
@@ -126,7 +86,8 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await dockerCache.invalidateAsync();
|
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||||
|
await innerHandler.invalidateAsync();
|
||||||
}),
|
}),
|
||||||
removeAll: permissionRequiredProcedure
|
removeAll: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
@@ -140,7 +101,8 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await dockerCache.invalidateAsync();
|
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||||
|
await innerHandler.invalidateAsync();
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,20 +142,6 @@ interface DockerContainer {
|
|||||||
image: string;
|
image: string;
|
||||||
ports: Port[];
|
ports: Port[];
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
}
|
cpuUsage: number;
|
||||||
|
memoryUsage: number;
|
||||||
function sanitizeContainers(
|
|
||||||
containers: (ContainerInfo & { instance: string; iconUrl: string | null })[],
|
|
||||||
): DockerContainer[] {
|
|
||||||
return containers.map((container) => {
|
|
||||||
return {
|
|
||||||
name: container.Names[0]?.split("/")[1] ?? "Unknown",
|
|
||||||
id: container.Id,
|
|
||||||
instance: container.instance,
|
|
||||||
state: container.State as ContainerState,
|
|
||||||
image: container.Image,
|
|
||||||
ports: container.Ports,
|
|
||||||
iconUrl: container.iconUrl,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,26 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { objectKeys } from "@homarr/common";
|
import { objectKeys } from "@homarr/common";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { getPermissionsWithChildren } from "@homarr/definitions";
|
|
||||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||||
|
import { getPermissionsWithChildren } from "@homarr/definitions";
|
||||||
|
|
||||||
import type { RouterInputs } from "../../..";
|
import type { RouterInputs } from "../../..";
|
||||||
import { dockerRouter } from "../../docker/docker-router";
|
import { dockerRouter } from "../../docker/docker-router";
|
||||||
|
|
||||||
// Mock the auth module to return an empty session
|
// Mock the auth module to return an empty session
|
||||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||||
|
vi.mock("@homarr/request-handler/docker", () => ({
|
||||||
|
dockerContainersRequestHandler: {
|
||||||
|
handler: () => ({
|
||||||
|
getCachedOrUpdatedDataAsync: async () => {
|
||||||
|
return await Promise.resolve({ containers: [] });
|
||||||
|
},
|
||||||
|
invalidateAsync: async () => {
|
||||||
|
return await Promise.resolve();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
vi.mock("@homarr/redis", () => ({
|
vi.mock("@homarr/redis", () => ({
|
||||||
createCacheChannel: () => ({
|
createCacheChannel: () => ({
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
@@ -22,6 +34,7 @@ vi.mock("@homarr/redis", () => ({
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
invalidateAsync: async () => {},
|
invalidateAsync: async () => {},
|
||||||
}),
|
}),
|
||||||
|
createWidgetOptionsChannel: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@homarr/docker/env", () => ({
|
vi.mock("@homarr/docker/env", () => ({
|
||||||
@@ -46,6 +59,7 @@ const validInputs: {
|
|||||||
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
|
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
|
||||||
} = {
|
} = {
|
||||||
getContainers: undefined,
|
getContainers: undefined,
|
||||||
|
subscribeContainers: undefined,
|
||||||
startAll: { ids: ["1"] },
|
startAll: { ids: ["1"] },
|
||||||
stopAll: { ids: ["1"] },
|
stopAll: { ids: ["1"] },
|
||||||
restartAll: { ids: ["1"] },
|
restartAll: { ids: ["1"] },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const cronJobs = {
|
|||||||
mediaTranscoding: { preventManualExecution: false },
|
mediaTranscoding: { preventManualExecution: false },
|
||||||
minecraftServerStatus: { preventManualExecution: false },
|
minecraftServerStatus: { preventManualExecution: false },
|
||||||
networkController: { preventManualExecution: false },
|
networkController: { preventManualExecution: false },
|
||||||
|
dockerContainers: { preventManualExecution: false },
|
||||||
} satisfies Record<JobGroupKeys, { preventManualExecution?: boolean }>;
|
} satisfies Record<JobGroupKeys, { preventManualExecution?: boolean }>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { analyticsJob } from "./jobs/analytics";
|
import { analyticsJob } from "./jobs/analytics";
|
||||||
|
import { dockerContainersJob } from "./jobs/docker";
|
||||||
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
||||||
import { dnsHoleJob } from "./jobs/integrations/dns-hole";
|
import { dnsHoleJob } from "./jobs/integrations/dns-hole";
|
||||||
import { downloadsJob } from "./jobs/integrations/downloads";
|
import { downloadsJob } from "./jobs/integrations/downloads";
|
||||||
@@ -35,6 +36,7 @@ export const jobGroup = createCronJobGroup({
|
|||||||
updateChecker: updateCheckerJob,
|
updateChecker: updateCheckerJob,
|
||||||
mediaTranscoding: mediaTranscodingJob,
|
mediaTranscoding: mediaTranscodingJob,
|
||||||
minecraftServerStatus: minecraftServerStatusJob,
|
minecraftServerStatus: minecraftServerStatusJob,
|
||||||
|
dockerContainers: dockerContainersJob,
|
||||||
networkController: networkControllerJob,
|
networkController: networkControllerJob,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
28
packages/cron-jobs/src/jobs/docker.ts
Normal file
28
packages/cron-jobs/src/jobs/docker.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
|
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||||
|
import { db, eq } from "@homarr/db";
|
||||||
|
import { items } from "@homarr/db/schema";
|
||||||
|
import { logger } from "@homarr/log";
|
||||||
|
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../../../widgets";
|
||||||
|
import { createCronJob } from "../lib";
|
||||||
|
|
||||||
|
export const dockerContainersJob = createCronJob("dockerContainers", EVERY_MINUTE).withCallback(async () => {
|
||||||
|
const dockerItems = await db.query.items.findMany({
|
||||||
|
where: eq(items.kind, "dockerContainers"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
dockerItems.map(async (item) => {
|
||||||
|
try {
|
||||||
|
const options = SuperJSON.parse<WidgetComponentProps<"dockerContainers">["options"]>(item.options);
|
||||||
|
const innerHandler = dockerContainersRequestHandler.handler(options);
|
||||||
|
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to update Docker container status", { item, error });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -24,5 +24,6 @@ export const widgetKinds = [
|
|||||||
"indexerManager",
|
"indexerManager",
|
||||||
"healthMonitoring",
|
"healthMonitoring",
|
||||||
"releases",
|
"releases",
|
||||||
|
"dockerContainers",
|
||||||
] as const;
|
] as const;
|
||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
|
"./shared": "./src/shared.ts",
|
||||||
"./env": "./src/env.ts"
|
"./env": "./src/env.ts"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
|
|||||||
13
packages/docker/src/shared.ts
Normal file
13
packages/docker/src/shared.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { MantineColor } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { ContainerState } from ".";
|
||||||
|
|
||||||
|
export const containerStateColorMap = {
|
||||||
|
created: "cyan",
|
||||||
|
running: "green",
|
||||||
|
paused: "yellow",
|
||||||
|
restarting: "orange",
|
||||||
|
exited: "red",
|
||||||
|
removing: "pink",
|
||||||
|
dead: "dark",
|
||||||
|
} satisfies Record<ContainerState, MantineColor>;
|
||||||
80
packages/request-handler/src/docker.ts
Normal file
80
packages/request-handler/src/docker.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import type { ContainerInfo, ContainerStats } from "dockerode";
|
||||||
|
|
||||||
|
import { db, like, or } from "@homarr/db";
|
||||||
|
import { icons } from "@homarr/db/schema";
|
||||||
|
|
||||||
|
import type { ContainerState } from "../../docker/src";
|
||||||
|
import { DockerSingleton } from "../../docker/src";
|
||||||
|
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||||
|
|
||||||
|
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
|
||||||
|
queryKey: "dockerContainersResult",
|
||||||
|
widgetKind: "dockerContainers",
|
||||||
|
async requestAsync() {
|
||||||
|
const containers = await getContainersWithStatsAsync();
|
||||||
|
|
||||||
|
return containers;
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(20, "seconds"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dockerInstances = DockerSingleton.getInstances();
|
||||||
|
|
||||||
|
const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
|
||||||
|
|
||||||
|
async function getContainersWithStatsAsync() {
|
||||||
|
const containers = await Promise.all(
|
||||||
|
dockerInstances.map(async ({ instance, host }) => {
|
||||||
|
const instanceContainers = await instance.listContainers({ all: true });
|
||||||
|
return instanceContainers.map((container) => ({
|
||||||
|
...container,
|
||||||
|
instance: host,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
).then((res) => res.flat());
|
||||||
|
|
||||||
|
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
|
||||||
|
|
||||||
|
const dbIcons =
|
||||||
|
likeQueries.length > 0
|
||||||
|
? await db.query.icons.findMany({
|
||||||
|
where: or(...likeQueries),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const containerStatsPromises = containers.map(async (container) => {
|
||||||
|
const instance = dockerInstances.find(({ host }) => host === container.instance)?.instance;
|
||||||
|
if (!instance) return null;
|
||||||
|
|
||||||
|
const stats = await instance.getContainer(container.Id).stats({ stream: false });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: container.Id,
|
||||||
|
name: container.Names[0]?.split("/")[1] ?? "Unknown",
|
||||||
|
state: container.State as ContainerState,
|
||||||
|
iconUrl:
|
||||||
|
dbIcons.find((icon) => {
|
||||||
|
const extractedImage = extractImage(container);
|
||||||
|
if (!extractedImage) return false;
|
||||||
|
return icon.name.toLowerCase().includes(extractedImage.toLowerCase());
|
||||||
|
})?.url ?? null,
|
||||||
|
cpuUsage: calculateCpuUsage(stats),
|
||||||
|
memoryUsage: stats.memory_stats.usage,
|
||||||
|
image: container.Image,
|
||||||
|
ports: container.Ports,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (await Promise.all(containerStatsPromises)).filter((container) => container !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateCpuUsage(stats: ContainerStats): number {
|
||||||
|
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||||
|
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||||
|
const numberOfCpus = stats.cpu_stats.online_cpus || 1;
|
||||||
|
|
||||||
|
if (systemDelta === 0) return 0;
|
||||||
|
|
||||||
|
return (cpuDelta / systemDelta) * numberOfCpus * 100;
|
||||||
|
}
|
||||||
@@ -1832,6 +1832,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"name": "Docker stats",
|
||||||
|
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
|
||||||
|
"option": {},
|
||||||
|
"error": {
|
||||||
|
"internalServerError": "Failed to fetch containers stats"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"location": {
|
"location": {
|
||||||
"query": "City / Postal code",
|
"query": "City / Postal code",
|
||||||
@@ -3091,6 +3099,9 @@
|
|||||||
},
|
},
|
||||||
"networkController": {
|
"networkController": {
|
||||||
"label": "Network Controller"
|
"label": "Network Controller"
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": "Docker containers"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3154,8 +3165,9 @@
|
|||||||
"title": "Containers",
|
"title": "Containers",
|
||||||
"table": {
|
"table": {
|
||||||
"updated": "Updated {when}",
|
"updated": "Updated {when}",
|
||||||
"search": "Search {count} containers",
|
"search": "Seach {count} containers",
|
||||||
"selected": "{selectCount} of {totalCount} containers selected"
|
"selected": "{selectCount} of {totalCount} containers selected",
|
||||||
|
"footer": "Total {count} containers"
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -3173,6 +3185,14 @@
|
|||||||
"dead": "Dead"
|
"dead": "Dead"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stats": {
|
||||||
|
"cpu": {
|
||||||
|
"label": "CPU"
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"label": "Memory"
|
||||||
|
}
|
||||||
|
},
|
||||||
"containerImage": {
|
"containerImage": {
|
||||||
"label": "Image"
|
"label": "Image"
|
||||||
},
|
},
|
||||||
@@ -3181,6 +3201,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
|
"title": "Actions",
|
||||||
"start": {
|
"start": {
|
||||||
"label": "Start",
|
"label": "Start",
|
||||||
"notification": {
|
"notification": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
|
"@homarr/docker": "workspace:^0.1.0",
|
||||||
"@homarr/form": "workspace:^0.1.0",
|
"@homarr/form": "workspace:^0.1.0",
|
||||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||||
"@homarr/integrations": "workspace:^0.1.0",
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
|
|||||||
256
packages/widgets/src/docker/component.tsx
Normal file
256
packages/widgets/src/docker/component.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ActionIcon, Avatar, Badge, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
|
import type { IconProps } from "@tabler/icons-react";
|
||||||
|
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
|
||||||
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||||
|
import type { ContainerState } from "@homarr/docker";
|
||||||
|
import { containerStateColorMap } from "@homarr/docker/shared";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
|
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||||
|
const t = useScopedI18n("docker.field.state.option");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge size="xs" radius="sm" variant="light" color={containerStateColorMap[state]}>
|
||||||
|
{t(state)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const memoryUsageColor = (number: number, state: string) => {
|
||||||
|
const mbUsage = number / 1024 / 1024;
|
||||||
|
if (mbUsage === 0 && state !== "running") return "red";
|
||||||
|
if (mbUsage < 128) return "green";
|
||||||
|
if (mbUsage < 256) return "yellow";
|
||||||
|
if (mbUsage < 512) return "orange";
|
||||||
|
return "red";
|
||||||
|
};
|
||||||
|
|
||||||
|
const cpuUsageColor = (number: number, state: string) => {
|
||||||
|
if (number === 0 && state !== "running") return "red";
|
||||||
|
if (number < 40) return "green";
|
||||||
|
if (number < 60) return "yellow";
|
||||||
|
if (number < 90) return "orange";
|
||||||
|
return "red";
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback);
|
||||||
|
|
||||||
|
const actionIconIconStyle: IconProps["style"] = {
|
||||||
|
height: "var(--ai-icon-size)",
|
||||||
|
width: "var(--ai-icon-size)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createColumns = (
|
||||||
|
t: ReturnType<typeof useScopedI18n<"docker">>,
|
||||||
|
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("field.name.label"),
|
||||||
|
Cell({ renderedCellValue, row }) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Avatar variant="outline" radius="md" size={20} src={row.original.iconUrl} />
|
||||||
|
<Text p="0.5" size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{renderedCellValue}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "state",
|
||||||
|
size: 100,
|
||||||
|
header: t("field.state.label"),
|
||||||
|
Cell({ row }) {
|
||||||
|
return <ContainerStateBadge state={row.original.state} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "cpuUsage",
|
||||||
|
size: 80,
|
||||||
|
header: t("field.stats.cpu.label"),
|
||||||
|
Cell({ row }) {
|
||||||
|
const cpuUsage = safeValue(row.original.cpuUsage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text size="xs" c={cpuUsageColor(cpuUsage, row.original.state)}>
|
||||||
|
{cpuUsage.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "memoryUsage",
|
||||||
|
size: 80,
|
||||||
|
header: t("field.stats.memory.label"),
|
||||||
|
Cell({ row }) {
|
||||||
|
const bytesUsage = safeValue(row.original.memoryUsage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
||||||
|
{humanFileSize(bytesUsage)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "actions",
|
||||||
|
size: 80,
|
||||||
|
header: t("action.title"),
|
||||||
|
Cell({ row }) {
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const { mutateAsync: startContainer } = clientApi.docker.startAll.useMutation();
|
||||||
|
const { mutateAsync: stopContainer } = clientApi.docker.stopAll.useMutation();
|
||||||
|
const { mutateAsync: restartContainer } = clientApi.docker.restartAll.useMutation();
|
||||||
|
|
||||||
|
const handleActionAsync = async (action: "start" | "stop" | "restart") => {
|
||||||
|
const mutation = action === "start" ? startContainer : action === "stop" ? stopContainer : restartContainer;
|
||||||
|
|
||||||
|
await mutation(
|
||||||
|
{ ids: [row.original.id] },
|
||||||
|
{
|
||||||
|
async onSettled() {
|
||||||
|
await utils.docker.getContainers.invalidate();
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t(`action.${action}.notification.success.title`),
|
||||||
|
message: t(`action.${action}.notification.success.message`),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t(`action.${action}.notification.error.title`),
|
||||||
|
message: t(`action.${action}.notification.error.message`),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group wrap="nowrap" gap="xs">
|
||||||
|
<Tooltip label={row.original.state === "running" ? t("action.stop.label") : t("action.start.label")}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
radius="100%"
|
||||||
|
onClick={() => handleActionAsync(row.original.state === "running" ? "stop" : "start")}
|
||||||
|
>
|
||||||
|
{row.original.state === "running" ? (
|
||||||
|
<IconPlayerStop style={actionIconIconStyle} />
|
||||||
|
) : (
|
||||||
|
<IconPlayerPlay style={actionIconIconStyle} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t("action.restart.label")}>
|
||||||
|
<ActionIcon variant="subtle" size="xs" radius="100%" onClick={() => handleActionAsync("restart")}>
|
||||||
|
<IconRotateClockwise style={actionIconIconStyle} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DockerWidget({ width }: WidgetComponentProps<"dockerContainers">) {
|
||||||
|
const t = useScopedI18n("docker");
|
||||||
|
const isTiny = width <= 256;
|
||||||
|
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const [{ containers, timestamp }] = clientApi.docker.getContainers.useSuspenseQuery();
|
||||||
|
const relativeTime = useTimeAgo(timestamp);
|
||||||
|
|
||||||
|
clientApi.docker.subscribeContainers.useSubscription(undefined, {
|
||||||
|
onData(data) {
|
||||||
|
utils.docker.getContainers.setData(undefined, { containers: data, timestamp: new Date() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalContainers = containers.length;
|
||||||
|
|
||||||
|
const columns = useMemo(() => createColumns(t), [t]);
|
||||||
|
|
||||||
|
const table = useTranslatedMantineReactTable({
|
||||||
|
columns,
|
||||||
|
data: containers,
|
||||||
|
enablePagination: false,
|
||||||
|
enableTopToolbar: false,
|
||||||
|
enableBottomToolbar: false,
|
||||||
|
enableSorting: false,
|
||||||
|
enableColumnActions: false,
|
||||||
|
enableStickyHeader: false,
|
||||||
|
enableColumnOrdering: false,
|
||||||
|
enableRowSelection: false,
|
||||||
|
enableFullScreenToggle: false,
|
||||||
|
enableGlobalFilter: false,
|
||||||
|
enableDensityToggle: false,
|
||||||
|
enableFilters: false,
|
||||||
|
enableHiding: false,
|
||||||
|
initialState: {
|
||||||
|
density: "xs",
|
||||||
|
},
|
||||||
|
mantinePaperProps: {
|
||||||
|
flex: 1,
|
||||||
|
withBorder: false,
|
||||||
|
shadow: undefined,
|
||||||
|
},
|
||||||
|
mantineTableProps: {
|
||||||
|
className: "docker-widget-table",
|
||||||
|
style: {
|
||||||
|
tableLayout: "fixed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mantineTableHeadProps: {
|
||||||
|
fz: "xs",
|
||||||
|
},
|
||||||
|
mantineTableHeadCellProps: {
|
||||||
|
p: 4,
|
||||||
|
},
|
||||||
|
mantineTableBodyCellProps: {
|
||||||
|
p: 4,
|
||||||
|
},
|
||||||
|
mantineTableContainerProps: {
|
||||||
|
style: {
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={0} h="100%" display="flex">
|
||||||
|
<MantineReactTable table={table} />
|
||||||
|
|
||||||
|
{!isTiny && (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
style={{
|
||||||
|
borderTop: "0.0625rem solid var(--border-color)",
|
||||||
|
}}
|
||||||
|
p={4}
|
||||||
|
>
|
||||||
|
<Group gap={4}>
|
||||||
|
<IconBrandDocker size={20} />
|
||||||
|
<Text size="sm">{t("table.footer", { count: totalContainers.toString() })}</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text size="sm">{t("table.updated", { when: relativeTime })}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
packages/widgets/src/docker/index.ts
Normal file
17
packages/widgets/src/docker/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IconBrandDocker, IconServerOff } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { createWidgetDefinition } from "../definition";
|
||||||
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
|
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
|
||||||
|
icon: IconBrandDocker,
|
||||||
|
createOptions() {
|
||||||
|
return optionsBuilder.from(() => ({}));
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
INTERNAL_SERVER_ERROR: {
|
||||||
|
icon: IconServerOff,
|
||||||
|
message: (t) => t("widget.dockerContainers.error.internalServerError"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).withDynamicImport(() => import("./component"));
|
||||||
@@ -6,7 +6,7 @@ import { translateIfNecessary } from "@homarr/translation";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
interface BaseWidgetErrorProps {
|
export interface BaseWidgetErrorProps {
|
||||||
icon: TablerIcon;
|
icon: TablerIcon;
|
||||||
message: stringOrTranslation;
|
message: stringOrTranslation;
|
||||||
showLogsLink?: boolean;
|
showLogsLink?: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
import { IconExclamationCircle, IconShield } from "@tabler/icons-react";
|
||||||
import { TRPCClientError } from "@trpc/client";
|
import { TRPCClientError } from "@trpc/client";
|
||||||
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
|
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import type { WidgetKind } from "@homarr/definitions";
|
|||||||
import type { WidgetDefinition } from "..";
|
import type { WidgetDefinition } from "..";
|
||||||
import { widgetImports } from "..";
|
import { widgetImports } from "..";
|
||||||
import { ErrorBoundaryError } from "./base";
|
import { ErrorBoundaryError } from "./base";
|
||||||
|
import type { BaseWidgetErrorProps } from "./base-component";
|
||||||
import { BaseWidgetError } from "./base-component";
|
import { BaseWidgetError } from "./base-component";
|
||||||
|
|
||||||
interface WidgetErrorProps {
|
interface WidgetErrorProps {
|
||||||
@@ -23,28 +24,58 @@ export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProp
|
|||||||
return <BaseWidgetError {...error.getErrorBoundaryData()} onRetry={resetErrorBoundary} />;
|
return <BaseWidgetError {...error.getErrorBoundaryData()} onRetry={resetErrorBoundary} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const commonFallbackError = (
|
const widgetTrpcErrorData = handleWidgetTrpcError(error, currentDefinition);
|
||||||
|
if (widgetTrpcErrorData) {
|
||||||
|
return <BaseWidgetError {...widgetTrpcErrorData} onRetry={resetErrorBoundary} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trpcErrorData = handleCommonTrpcError(error);
|
||||||
|
if (trpcErrorData) {
|
||||||
|
return <BaseWidgetError {...trpcErrorData} onRetry={resetErrorBoundary} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<BaseWidgetError
|
<BaseWidgetError
|
||||||
icon={IconExclamationCircle}
|
icon={IconExclamationCircle}
|
||||||
message={(error as { toString: () => string }).toString()}
|
message={(error as { toString: () => string }).toString()}
|
||||||
onRetry={resetErrorBoundary}
|
onRetry={resetErrorBoundary}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (error instanceof TRPCClientError && "code" in error.data) {
|
const handleWidgetTrpcError = (
|
||||||
const errorData = error.data as DefaultErrorData;
|
error: unknown,
|
||||||
|
currentDefinition: WidgetDefinition,
|
||||||
|
): Omit<BaseWidgetErrorProps, "onRetry"> | null => {
|
||||||
|
if (!(error instanceof TRPCClientError && "code" in error.data)) return null;
|
||||||
|
|
||||||
if (!("errors" in currentDefinition)) return commonFallbackError;
|
const errorData = error.data as DefaultErrorData;
|
||||||
|
|
||||||
const errors: Exclude<WidgetDefinition["errors"], undefined> = currentDefinition.errors;
|
if (!("errors" in currentDefinition) || currentDefinition.errors === undefined) return null;
|
||||||
const errorDefinition = errors[errorData.code];
|
|
||||||
|
|
||||||
if (!errorDefinition) return commonFallbackError;
|
const errors: Exclude<WidgetDefinition["errors"], undefined> = currentDefinition.errors;
|
||||||
|
const errorDefinition = errors[errorData.code];
|
||||||
|
|
||||||
return (
|
if (!errorDefinition) return null;
|
||||||
<BaseWidgetError {...errorDefinition} onRetry={resetErrorBoundary} showLogsLink={!errorDefinition.hideLogsLink} />
|
|
||||||
);
|
return {
|
||||||
|
...errorDefinition,
|
||||||
|
showLogsLink: !errorDefinition.hideLogsLink,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommonTrpcError = (error: unknown): Omit<BaseWidgetErrorProps, "onRetry"> | null => {
|
||||||
|
if (!(error instanceof TRPCClientError && "code" in error.data)) return null;
|
||||||
|
|
||||||
|
const errorData = error.data as DefaultErrorData;
|
||||||
|
|
||||||
|
if (errorData.code === "UNAUTHORIZED" || errorData.code === "FORBIDDEN") {
|
||||||
|
return {
|
||||||
|
icon: IconShield,
|
||||||
|
message: "You don't have permission to access this widget",
|
||||||
|
showLogsLink: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return commonFallbackError;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import * as clock from "./clock";
|
|||||||
import type { WidgetComponentProps } from "./definition";
|
import type { WidgetComponentProps } from "./definition";
|
||||||
import * as dnsHoleControls from "./dns-hole/controls";
|
import * as dnsHoleControls from "./dns-hole/controls";
|
||||||
import * as dnsHoleSummary from "./dns-hole/summary";
|
import * as dnsHoleSummary from "./dns-hole/summary";
|
||||||
|
import * as dockerContainers from "./docker";
|
||||||
import * as downloads from "./downloads";
|
import * as downloads from "./downloads";
|
||||||
import * as healthMonitoring from "./health-monitoring";
|
import * as healthMonitoring from "./health-monitoring";
|
||||||
import * as iframe from "./iframe";
|
import * as iframe from "./iframe";
|
||||||
@@ -64,6 +65,7 @@ export const widgetImports = {
|
|||||||
healthMonitoring,
|
healthMonitoring,
|
||||||
mediaTranscoding,
|
mediaTranscoding,
|
||||||
minecraftServerStatus,
|
minecraftServerStatus,
|
||||||
|
dockerContainers,
|
||||||
releases,
|
releases,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -2075,6 +2075,9 @@ importers:
|
|||||||
'@homarr/definitions':
|
'@homarr/definitions':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../definitions
|
version: link:../definitions
|
||||||
|
'@homarr/docker':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../docker
|
||||||
'@homarr/form':
|
'@homarr/form':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../form
|
version: link:../form
|
||||||
|
|||||||
Reference in New Issue
Block a user