Skip to content

Commit

Permalink
feat: add docker container table (#520)
Browse files Browse the repository at this point in the history
* WIP On docker integration

* WIP on adding docker support

* WIP on adding docker support

* chore: Add cacheTime parameter to createCacheChannel function

* bugfix: Add node-loader npm dependency for webpack configuration

* revert changes

* chore: Add node-loader npm dependency for webpack configuration

* feat: Add Docker container list to DockerPage

* chore: apply pr suggestions

* fix: fix printing issue using a Date objext

* chore: Update npm dependencies

* feat: Create DockerTable component for displaying Docker container list

* feat: Refactor DockerPage to use DockerTable component

* feat: Refactor DockerPage to use DockerTable component

* feat: Add useTimeAgo hook for displaying relative timestamps

* feat: Add hooks module to common package

* refactor: Update DockerTable component

Include container actions and state badges

* feat: add information about instance for docker containers

* feat: Add OverflowBadge component for displaying overflowed data

* feat: Refactor DockerSingleton to use host and instance properties

This commit refactors the DockerSingleton class in the `docker.ts` file to use the `host` and `instance` properties instead of the previous `key` and `remoteApi` properties. This change improves clarity and consistency in the codebase.

* feat: Add OverflowBadge component for displaying overflowed data

* feat: Improve DockerTable component with Avatar and Name column

This commit enhances the DockerTable component in the `DockerTable.tsx` file by adding an Avatar and Name column. The Avatar column displays an icon based on the Docker container's image, while the Name column shows the container's name. This improvement provides better visual representation and identification of the containers in the table.

* feat: Enhance DockerTable component with Avatar and Name columns

* refactor: improve docker table and icon resolution

* chore: address pull request feedback

* fix: format issues

* chore: add missing translations

* refactor: remove black background

---------

Co-authored-by: Meier Lukas <[email protected]>
  • Loading branch information
ajnart and Meierschlumpf committed May 29, 2024
1 parent e030e06 commit dd2937a
Show file tree
Hide file tree
Showing 19 changed files with 1,057 additions and 77 deletions.
11 changes: 10 additions & 1 deletion apps/nextjs/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Importing env files here to validate on build
import "./src/env.mjs";
import "@homarr/auth/env.mjs";
import "./src/env.mjs";

/** @type {import("next").NextConfig} */
const config = {
Expand All @@ -9,6 +9,15 @@ const config = {
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
webpack: (config) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
config.module.rules.push({
test: /\.node$/,
loader: "node-loader",
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return config;
},
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
},
Expand Down
3 changes: 3 additions & 0 deletions apps/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0",
"chroma-js": "^2.4.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"dotenv": "^16.4.5",
"flag-icons": "^7.2.3",
"glob": "^10.4.1",
"jotai": "^2.8.2",
"mantine-react-table": "2.0.0-beta.3",
"next": "^14.2.3",
"postcss-preset-mantine": "^1.15.0",
"react": "18.3.1",
Expand All @@ -72,6 +74,7 @@
"@types/react-dom": "^18.3.0",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
"node-loader": "^2.0.0",
"prettier": "^3.2.5",
"tsx": "4.11.0",
"typescript": "^5.4.5"
Expand Down
154 changes: 154 additions & 0 deletions apps/nextjs/src/app/[locale]/manage/tools/docker/DockerTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import type { ButtonProps, MantineColor } from "@mantine/core";
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
import { IconPlayerPlay, IconPlayerStop, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";

import type { RouterOutputs } from "@homarr/api";
import { useTimeAgo } from "@homarr/common";
import type { DockerContainerState } from "@homarr/definitions";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { OverflowBadge } from "@homarr/ui";

const createColumns = (
t: TranslationFunction,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
{
accessorKey: "name",
header: t("docker.field.name.label"),
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs">
<Avatar variant="outline" radius="lg" size="md" src={row.original.iconUrl}>
{row.original.name.at(0)?.toUpperCase()}
</Avatar>
<Text>{renderedCellValue}</Text>
</Group>
);
},
},
{
accessorKey: "state",
header: t("docker.field.state.label"),
size: 120,
Cell({ cell }) {
return <ContainerStateBadge state={cell.row.original.state} />;
},
},
{
accessorKey: "image",
header: t("docker.field.containerImage.label"),
maxSize: 200,
Cell({ renderedCellValue }) {
return (
<Box maw={200}>
<Text truncate="end">{renderedCellValue}</Text>
</Box>
);
},
},
{
accessorKey: "ports",
header: t("docker.field.ports.label"),
Cell({ cell }) {
return (
<OverflowBadge overflowCount={1} data={cell.row.original.ports.map((port) => port.PrivatePort.toString())} />
);
},
},
];

export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const relativeTime = useTimeAgo(timestamp);
const table = useMantineReactTable({
data: containers,
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
enablePagination: false,
enableRowSelection: true,
positionToolbarAlertBanner: "top",
enableTableFooter: false,
enableBottomToolbar: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: containers.length }),
style: { minWidth: 300 },
autoFocus: true,
},

initialState: { density: "xs", showGlobalFilter: true },
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
return (
<Group gap={"sm"}>
{groupedAlert}
<Text fw={500}>
{tDocker("table.selected", {
selectCount: table.getSelectedRowModel().rows.length,
totalCount: table.getRowCount(),
})}
</Text>
<ContainerActionBar />
</Group>
);
},

columns: createColumns(t),
});
return (
<>
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
<MantineReactTable table={table} />
</>
);
}

const ContainerActionBar = () => {
const t = useScopedI18n("docker.action");
const sharedButtonProps = {
variant: "light",
radius: "md",
} satisfies Partial<ButtonProps>;

return (
<Group gap="xs">
<Button leftSection={<IconPlayerPlay />} color="green" {...sharedButtonProps}>
{t("start")}
</Button>
<Button leftSection={<IconPlayerStop />} color="red" {...sharedButtonProps}>
{t("stop")}
</Button>
<Button leftSection={<IconRotateClockwise />} color="orange" {...sharedButtonProps}>
{t("restart")}
</Button>
<Button leftSection={<IconTrash />} color="red" {...sharedButtonProps}>
{t("remove")}
</Button>
</Group>
);
};

const containerStates = {
created: "cyan",
running: "green",
paused: "yellow",
restarting: "orange",
exited: "red",
removing: "pink",
dead: "dark",
} satisfies Record<DockerContainerState, MantineColor>;

const ContainerStateBadge = ({ state }: { state: DockerContainerState }) => {
const t = useScopedI18n("docker.field.state.option");

return (
<Badge size="lg" radius="sm" variant="light" w={120} color={containerStates[state]}>
{t(state)}
</Badge>
);
};
18 changes: 18 additions & 0 deletions apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Stack, Title } from "@mantine/core";

import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";

import { DockerTable } from "./DockerTable";

export default async function DockerPage() {
const { containers, timestamp } = await api.docker.getContainers();
const tDocker = await getScopedI18n("docker");

return (
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerTable containers={containers} timestamp={timestamp} />
</Stack>
);
}
5 changes: 5 additions & 0 deletions apps/nextjs/src/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export const env = createEnv({
DB_USER: isUsingDbCredentials ? z.string() : z.string().optional(),
DB_PASSWORD: isUsingDbCredentials ? z.string() : z.string().optional(),
DB_NAME: isUsingDbUrl ? z.string().optional() : z.string(),
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
DOCKER_HOSTNAMES: z.string().optional(),
DOCKER_PORTS: z.number().optional(),
},
/**
* Specify your client-side environment variables schema here.
Expand All @@ -49,6 +52,8 @@ export const env = createEnv({
DB_PORT: process.env.DB_PORT,
DB_DRIVER: process.env.DB_DRIVER,
NODE_ENV: process.env.NODE_ENV,
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
DOCKER_PORTS: process.env.DOCKER_PORTS,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation:
Expand Down
2 changes: 1 addition & 1 deletion apps/websocket/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"type": "module",
"scripts": {
"dev": "pnpm with-env tsx ./src/main.ts",
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --loader:.html=text",
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --loader:.html=text --loader:.node=text",
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore",
Expand Down
2 changes: 2 additions & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@trpc/client": "next",
"@trpc/server": "next",
"dockerode": "^4.0.2",
"superjson": "2.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.29",
"eslint": "^8.57.0",
"prettier": "^3.2.5",
"typescript": "^5.4.5"
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/root.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { dockerRouter } from "./router/docker/docker-router";
import { groupRouter } from "./router/group";
import { homeRouter } from "./router/home";
import { iconsRouter } from "./router/icons";
Expand All @@ -24,6 +25,7 @@ export const appRouter = createTRPCRouter({
log: logRouter,
icon: iconsRouter,
home: homeRouter,
docker: dockerRouter,
serverSettings: serverSettingsRouter,
});

Expand Down
84 changes: 84 additions & 0 deletions packages/api/src/router/docker/docker-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type Docker from "dockerode";

import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema/sqlite";
import type { DockerContainerState } from "@homarr/definitions";
import { createCacheChannel } from "@homarr/redis";

import { createTRPCRouter, publicProcedure } from "../../trpc";
import { DockerSingleton } from "./docker-singleton";

const dockerCache = createCacheChannel<{
containers: (Docker.ContainerInfo & { instance: string; iconUrl: string | null })[];
}>("docker-containers", 5 * 60 * 1000);

export const dockerRouter = createTRPCRouter({
getContainers: publicProcedure.query(async () => {
const { timestamp, data } = await dockerCache.consumeAsync(async () => {
const dockerInstances = DockerSingleton.getInstance();
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: Docker.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,
})),
};
});

return {
containers: sanitizeContainers(data.containers),
timestamp,
};
}),
});

interface DockerContainer {
name: string;
id: string;
state: DockerContainerState;
image: string;
ports: Docker.Port[];
iconUrl: string | null;
}

function sanitizeContainers(
containers: (Docker.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 DockerContainerState,
image: container.Image,
ports: container.Ports,
iconUrl: container.iconUrl,
};
});
}
Loading

0 comments on commit dd2937a

Please sign in to comment.