Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add docker container table #520

Merged
merged 33 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
60c1d77
WIP On docker integration
ajnart May 18, 2024
cefcb18
Merge branch 'dev' into ajnart/docker-integration
ajnart May 18, 2024
8ebf756
WIP on adding docker support
ajnart May 18, 2024
1511404
Merge branch 'dev' into ajnart/docker-integration
ajnart May 18, 2024
8d4eece
WIP on adding docker support
ajnart May 18, 2024
0585509
chore: Add cacheTime parameter to createCacheChannel function
ajnart May 18, 2024
6a5837f
bugfix: Add node-loader npm dependency for webpack configuration
ajnart May 18, 2024
cd476c0
revert changes
ajnart May 18, 2024
dde88a6
chore: Add node-loader npm dependency for webpack configuration
ajnart May 18, 2024
8b8f255
feat: Add Docker container list to DockerPage
ajnart May 18, 2024
d86ff41
chore: apply pr suggestions
ajnart May 19, 2024
163abbf
fix: fix printing issue using a Date objext
ajnart May 19, 2024
09d0dcd
chore: Update npm dependencies
ajnart May 19, 2024
ee1f396
feat: Create DockerTable component for displaying Docker container list
ajnart May 19, 2024
e50dbec
feat: Refactor DockerPage to use DockerTable component
ajnart May 19, 2024
3975b4d
feat: Refactor DockerPage to use DockerTable component
ajnart May 19, 2024
8f40056
feat: Add useTimeAgo hook for displaying relative timestamps
ajnart May 19, 2024
54f426d
feat: Add hooks module to common package
ajnart May 19, 2024
2ddbf77
refactor: Update DockerTable component
ajnart May 20, 2024
866ea22
feat: add information about instance for docker containers
Meierschlumpf May 20, 2024
3067ede
feat: Add OverflowBadge component for displaying overflowed data
ajnart May 20, 2024
f939f87
feat: Refactor DockerSingleton to use host and instance properties
ajnart May 20, 2024
d18dfc6
feat: Add OverflowBadge component for displaying overflowed data
ajnart May 20, 2024
f4617c5
feat: Improve DockerTable component with Avatar and Name column
ajnart May 20, 2024
d4ca1be
feat: Enhance DockerTable component with Avatar and Name columns
ajnart May 20, 2024
5e7e1d9
Merge branch 'dev' into ajnart/docker-integration
ajnart May 21, 2024
7b9e944
refactor: improve docker table and icon resolution
Meierschlumpf May 28, 2024
0ff54e9
Merge branch 'dev' into ajnart/docker-integration
Meierschlumpf May 28, 2024
97856f4
chore: address pull request feedback
Meierschlumpf May 29, 2024
f1a33d7
Merge branch 'dev' into ajnart/docker-integration
Meierschlumpf May 29, 2024
2b06d44
fix: format issues
Meierschlumpf May 29, 2024
a533c20
chore: add missing translations
Meierschlumpf May 29, 2024
94524df
refactor: remove black background
Meierschlumpf May 29, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, ...)
Meierschlumpf marked this conversation as resolved.
Show resolved Hide resolved
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
Loading