mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat: add docker container table (#520)
* 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 <meierschlumpf@gmail.com>
This commit is contained in:
@@ -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 = {
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -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"
|
||||
|
||||
154
apps/nextjs/src/app/[locale]/manage/tools/docker/DockerTable.tsx
Normal file
154
apps/nextjs/src/app/[locale]/manage/tools/docker/DockerTable.tsx
Normal file
@@ -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
apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx
Normal file
18
apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
@@ -24,6 +25,7 @@ export const appRouter = createTRPCRouter({
|
||||
log: logRouter,
|
||||
icon: iconsRouter,
|
||||
home: homeRouter,
|
||||
docker: dockerRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
});
|
||||
|
||||
|
||||
84
packages/api/src/router/docker/docker-router.ts
Normal file
84
packages/api/src/router/docker/docker-router.ts
Normal file
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
50
packages/api/src/router/docker/docker-singleton.ts
Normal file
50
packages/api/src/router/docker/docker-singleton.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import Docker from "dockerode";
|
||||
|
||||
interface DockerInstance {
|
||||
host: string;
|
||||
instance: Docker;
|
||||
}
|
||||
|
||||
export class DockerSingleton {
|
||||
private static instances: DockerInstance[];
|
||||
|
||||
private createInstances() {
|
||||
const instances: DockerInstance[] = [];
|
||||
const hostVariable = process.env.DOCKER_HOST;
|
||||
const portVariable = process.env.DOCKER_PORT;
|
||||
if (hostVariable === undefined || portVariable === undefined) {
|
||||
instances.push({ host: "socket", instance: new Docker() });
|
||||
return instances;
|
||||
}
|
||||
const hosts = hostVariable.split(",");
|
||||
const ports = portVariable.split(",");
|
||||
|
||||
if (hosts.length !== ports.length) {
|
||||
throw new Error("The number of hosts and ports must match");
|
||||
}
|
||||
|
||||
hosts.forEach((host, i) => {
|
||||
instances.push({
|
||||
host: `${host}:${ports[i]}`,
|
||||
instance: new Docker({
|
||||
host,
|
||||
port: parseInt(ports[i] || "", 10),
|
||||
}),
|
||||
});
|
||||
return instances;
|
||||
});
|
||||
return instances;
|
||||
}
|
||||
|
||||
public static findInstance(key: string): DockerInstance | undefined {
|
||||
return this.instances.find((instance) => instance.host === key);
|
||||
}
|
||||
|
||||
public static getInstance(): DockerInstance[] {
|
||||
if (!DockerSingleton.instances) {
|
||||
DockerSingleton.instances = new DockerSingleton().createInstances();
|
||||
}
|
||||
|
||||
return this.instances;
|
||||
}
|
||||
}
|
||||
25
packages/common/src/hooks.ts
Normal file
25
packages/common/src/hooks.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const calculateTimeAgo = (timestamp: Date, locale: string) => {
|
||||
return dayjs().locale(locale).to(timestamp);
|
||||
};
|
||||
|
||||
export const useTimeAgo = (timestamp: Date) => {
|
||||
const { locale } = useParams<{ locale: string }>();
|
||||
const [timeAgo, setTimeAgo] = useState(calculateTimeAgo(timestamp, locale));
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => setTimeAgo(calculateTimeAgo(timestamp, locale)), 1000); // update every second
|
||||
|
||||
return () => clearInterval(intervalId); // clear interval on hook unmount
|
||||
}, [timestamp, locale]);
|
||||
|
||||
return timeAgo;
|
||||
};
|
||||
@@ -3,4 +3,5 @@ export * from "./string";
|
||||
export * from "./cookie";
|
||||
export * from "./array";
|
||||
export * from "./stopwatch";
|
||||
export * from "./hooks";
|
||||
export * from "./number";
|
||||
|
||||
11
packages/definitions/src/docker.ts
Normal file
11
packages/definitions/src/docker.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const dockerContainerStates = [
|
||||
"created",
|
||||
"running",
|
||||
"paused",
|
||||
"restarting",
|
||||
"exited",
|
||||
"removing",
|
||||
"dead",
|
||||
] as const;
|
||||
|
||||
export type DockerContainerState = (typeof dockerContainerStates)[number];
|
||||
@@ -3,3 +3,4 @@ export * from "./integration";
|
||||
export * from "./section";
|
||||
export * from "./widget";
|
||||
export * from "./permissions";
|
||||
export * from "./docker";
|
||||
|
||||
@@ -56,9 +56,10 @@ const cacheClient = createRedisConnection();
|
||||
/**
|
||||
* Creates a new cache channel.
|
||||
* @param name name of the channel
|
||||
* @param cacheDurationMs duration in milliseconds to cache
|
||||
* @returns cache channel object
|
||||
*/
|
||||
export const createCacheChannel = <TData>(name: string, cacheDurationSeconds: number = 5 * 60 * 1000) => {
|
||||
export const createCacheChannel = <TData>(name: string, cacheDurationMs: number = 5 * 60 * 1000) => {
|
||||
const cacheChannelName = `cache:${name}`;
|
||||
|
||||
return {
|
||||
@@ -73,7 +74,7 @@ export const createCacheChannel = <TData>(name: string, cacheDurationSeconds: nu
|
||||
const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - parsedData.timestamp.getTime();
|
||||
if (diff > cacheDurationSeconds) return null;
|
||||
if (diff > cacheDurationMs) return null;
|
||||
|
||||
return parsedData;
|
||||
},
|
||||
@@ -102,7 +103,7 @@ export const createCacheChannel = <TData>(name: string, cacheDurationSeconds: nu
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - parsedData.timestamp.getTime();
|
||||
|
||||
if (diff > cacheDurationSeconds) {
|
||||
if (diff > cacheDurationMs) {
|
||||
return await getNewDataAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -1369,4 +1369,41 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
docker: {
|
||||
title: "Containers",
|
||||
table: {
|
||||
updated: "Updated {when}",
|
||||
search: "Seach {count} containers",
|
||||
selected: "{selectCount} of {totalCount} containers selected",
|
||||
},
|
||||
field: {
|
||||
name: {
|
||||
label: "Name",
|
||||
},
|
||||
state: {
|
||||
label: "State",
|
||||
option: {
|
||||
created: "Created",
|
||||
running: "Running",
|
||||
paused: "Paused",
|
||||
restarting: "Restarting",
|
||||
exited: "Exited",
|
||||
removing: "Removing",
|
||||
dead: "Dead",
|
||||
},
|
||||
},
|
||||
containerImage: {
|
||||
label: "Image",
|
||||
},
|
||||
ports: {
|
||||
label: "Ports",
|
||||
},
|
||||
},
|
||||
action: {
|
||||
start: "Start",
|
||||
stop: "Stop",
|
||||
restart: "Restart",
|
||||
remove: "Remove",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export * from "./count-badge";
|
||||
export { OverflowBadge } from "./overflow-badge";
|
||||
export { SearchInput } from "./search-input";
|
||||
export * from "./select-with-description";
|
||||
export * from "./select-with-description-and-badge";
|
||||
export { TablePagination } from "./table-pagination";
|
||||
export { TextMultiSelect } from "./text-multi-select";
|
||||
export { UserAvatar } from "./user-avatar";
|
||||
export { UserAvatarGroup } from "./user-avatar-group";
|
||||
export { TablePagination } from "./table-pagination";
|
||||
export { SearchInput } from "./search-input";
|
||||
export { TextMultiSelect } from "./text-multi-select";
|
||||
|
||||
55
packages/ui/src/components/overflow-badge.tsx
Normal file
55
packages/ui/src/components/overflow-badge.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { BadgeProps } from "@mantine/core";
|
||||
import { ActionIcon, Badge, Group, Popover, Stack } from "@mantine/core";
|
||||
|
||||
export function OverflowBadge({
|
||||
data,
|
||||
overflowCount = 3,
|
||||
...props
|
||||
}: {
|
||||
data: string[];
|
||||
overflowCount?: number;
|
||||
} & BadgeProps) {
|
||||
const badgeProps = {
|
||||
variant: "default",
|
||||
size: "lg",
|
||||
radius: "sm",
|
||||
...props,
|
||||
};
|
||||
return (
|
||||
<Popover width="content" shadow="md">
|
||||
<Group gap="xs">
|
||||
{data.slice(0, overflowCount).map((item) => (
|
||||
<Badge key={item} px="xs" {...badgeProps}>
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
{data.length > overflowCount && (
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
{...{
|
||||
variant: badgeProps.variant,
|
||||
color: badgeProps.color,
|
||||
}}
|
||||
size="sm"
|
||||
fw="bold"
|
||||
fz="sm"
|
||||
p="sm"
|
||||
px="md"
|
||||
>
|
||||
+{data.length - overflowCount}
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
)}
|
||||
</Group>
|
||||
<Popover.Dropdown>
|
||||
<Stack>
|
||||
{data.slice(overflowCount).map((item) => (
|
||||
<Badge key={item} {...badgeProps}>
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
659
pnpm-lock.yaml
generated
659
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user