From 681e64528359526d99da963bb408dc9e17737a9f Mon Sep 17 00:00:00 2001 From: Joe Monk Date: Tue, 29 Apr 2025 17:30:03 +0100 Subject: [PATCH] Clean up a little, quick things are quick, slow things show a loader --- src/app/_components/docker-table.tsx | 89 +++++++++ src/app/layout.tsx | 4 +- src/app/page.tsx | 61 +----- src/server/api/routers/docker.ts | 272 +++++++++++++++++---------- 4 files changed, 274 insertions(+), 152 deletions(-) create mode 100644 src/app/_components/docker-table.tsx diff --git a/src/app/_components/docker-table.tsx b/src/app/_components/docker-table.tsx new file mode 100644 index 0000000..acdf9c7 --- /dev/null +++ b/src/app/_components/docker-table.tsx @@ -0,0 +1,89 @@ +"use client"; + +import type { dockerRouterType } from "@/server/api/routers/docker"; +import { api } from "@/trpc/react"; +import type { JSX } from "react"; + +function DockerRow({ + containerInfo, +}: { + containerInfo: dockerRouterType['list'][number] +}) { + const { data: latest, isError, isLoading } = api.docker.latest.useQuery({ id: containerInfo.container.id }); + const outdated = containerInfo.image.current.hash !== latest?.latest.hash; + + let latestFragment: JSX.Element | null = null; + if (isError) { + latestFragment = ( + <> + {"Error"} + {"Error"} + + ) + } else if (isLoading) { + latestFragment = ( + <> + + + + ) + } else if (latest) { + latestFragment = ( + <> + {latest?.latest.tag} + {latest?.latest.hash} + + ) + } + + return ( + + {containerInfo.container.name} + {containerInfo.image.name} + {containerInfo.image.current.tag} + {containerInfo.image.current.hash} + {latestFragment} + + ); +} + +export function DockerTable() { + const { data: list, isLoading: listLoading } = api.docker.list.useQuery(); + return ( +
+ {!listLoading ? ( + + + + + + + + + + + + + {list + ? list + .sort((ca, cb) => { + if (ca.container.name && cb.container.name) { + if (ca.container.name < cb.container.name) { + return -1; + } + if (ca.container.name > cb.container.name) { + return 1; + } + } + return 0; + }) + .map((containerInfo) => ) + : null} + +
NameImageTagShort HashTagShort Hash
+ ) : ( + <>Loading data... + )} +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 18d5759..716f6ec 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,8 +6,8 @@ import { Geist } from "next/font/google"; import { TRPCReactProvider } from "@/trpc/react"; export const metadata: Metadata = { - title: "Create T3 App", - description: "Generated by create-t3-app", + title: "DVDash", + description: "Docker version dashboard", icons: [{ rel: "icon", url: "/favicon.ico" }], }; diff --git a/src/app/page.tsx b/src/app/page.tsx index a5f91eb..d5a5c9e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,61 +1,14 @@ -import { HydrateClient, api } from "@/trpc/server"; +import { DockerTable } from "@/app/_components/docker-table"; +import { api } from "@/trpc/server"; export default async function Home() { - const list = await api.docker.list(); - void api.docker.list.prefetch(); return ( - -
-
- {list ? ( -
- - - - - - - - - - - - - {list - .sort((ca, cb) => { - if (ca.containerName && cb.containerName) { - if (ca.containerName < cb.containerName) { - return -1; - } - if (ca.containerName > cb.containerName) { - return 1; - } - } - return 0; - }) - .map((containerInfo) => { - const outdated = containerInfo.current.hash !== containerInfo.latest.hash; - return ( - - - - - - - - - ); - })} - -
NameImageTagShort HashTagShort Hash
{containerInfo.containerName}{containerInfo.imageName}{containerInfo.current.tag}{containerInfo.current.hash}{containerInfo.latest.tag}{containerInfo.latest.hash}
-
- ) : ( - "Loading tRPC query..." - )} -
-
-
+
+
+ +
+
); } diff --git a/src/server/api/routers/docker.ts b/src/server/api/routers/docker.ts index b4c0c3b..703f7b3 100644 --- a/src/server/api/routers/docker.ts +++ b/src/server/api/routers/docker.ts @@ -1,123 +1,203 @@ import Docker from "dockerode"; import semver from "semver"; +import { z } from "zod"; import { $ } from "zx"; import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; +import { TRPCError, type inferRouterOutputs } from "@trpc/server"; export const dockerRouter = createTRPCRouter({ - list: publicProcedure.query(async () => { - const docker = new Docker({ socketPath: "/var/run/docker.sock" }); - const containers = await docker.listContainers(); + latest: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => { + const docker = getDocker(); + const containers = await getContainers(docker); - // curl -fsSL -v "https://quay.io/token?service=quay.io&scope=repository:linuxserver/code-server:pull" - // curl -fsSL -v -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" -H "Authorization: Bearer djE6bGludXhzZXJ2ZXIvY29kZS1zZXJ2ZXI6MTc0NTQ1NTk1MTY3NjM5ODA4Mg==" quay.io/v2/linuxserver.io/code-server/tags/list + const container = containers.find((container) => container.Id === input.id); + if (!container) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Container with id ${input.id} not found`, + }); + } - // curl -fsSL -v -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" quay.io/v2/linuxserver.io/code-server/tags/list - // link: ; rel="next" - // link: ; rel="next" + try { + const imageData = await getImageData(docker, container); - // curl -fsSL -v -H "Accept: application/json" "hub.docker.com/v2/repositories/linuxserver/code-server/tags?n=25&ordering=last_updated" + return { + latest: await getLatest(imageData.name, imageData.tag), + }; + } catch (ex) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: (ex as Error).message, + cause: (ex as Error).cause, + }); + } + }), + list: publicProcedure.query(async () => { + const docker = getDocker(); + const containers = await getContainers(docker); - const dockerInfo = await Promise.all( - containers.map(async (container) => { - const imageInspect = await docker.getImage(container.Image).inspect(); - const imageDigest = imageInspect.RepoDigests[0]; - const containerInspect = docker.getContainer(container.Id); + // All this data should be local/from the local docker socket/api + let dockerInfo = await Promise.all( + containers.map(async (container) => { + try { + if (!container.Id) { + throw new Error("No container id could be found"); + } - let imageTag = imageInspect.RepoTags[0]?.split(":")[1]; + const imageData = await getImageData(docker, container); - let imageName = imageDigest?.split("@")?.[0]; - imageName ??= container.Image.split(":")[0] ?? undefined; + const info = { + container: { + name: container.Names[0]?.replace("/", "") ?? "", + id: container.Id + }, + image: { + name: imageData.name, + current: { + hash: imageData.hash.substring(0, 12), + tag: imageData.tag, + } + } + }; + return info; + } catch (ex) { + console.error("Error getting container data:", { + ex: { + message: (ex as Error).message, + cause: (ex as Error).cause, + }, + container: { + id: container.Id, + }, + }); + return null; + } + }), + ); - const imageHash = imageDigest?.split("@")?.[1]?.split(":")?.[1]; - imageTag ??= (await containerInspect.inspect()).Config.Image.split(":")[1] ?? undefined; + dockerInfo = dockerInfo.filter((info) => !!info); - const current = { - hash: imageHash?.substring(0, 12), - tag: imageTag, - }; + if (dockerInfo.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "No docker containers could be found, check logs for more information", + }); + } - let latest: { - hash?: string; - tag?: string; - } = { - hash: "", - tag: "", - }; - if (imageName) { - latest = await getLatest(imageName, current.tag); - } - - return { - containerName: container.Names[0]?.replace("/", ""), - imageName, - current, - latest, - }; - }), - ); - - return dockerInfo; - }), + return dockerInfo as unknown as NonNullable<(typeof dockerInfo)[number]>[]; + }), }); -function isSemver(tag: string): boolean { - const removeV = tag.replace(/^v/i, ""); - return !!semver.valid(removeV); +async function getImageData(docker: Docker, container: Docker.ContainerInfo) { + const inspect = await docker.getImage(container.Image).inspect(); + const digest = inspect.RepoDigests[0]; + const containerInspect = await docker.getContainer(container.Id).inspect(); + + let tag = inspect.RepoTags[0]?.split(":")[1]; + tag ??= containerInspect.Config.Image.split(":")[1]; + + let name = digest?.split("@")?.[0]; + name ??= container.Image.split(":")[0]; + + const hash = digest?.split("@")?.[1]?.split(":")?.[1]; + + if (!name) { + throw new Error("Container image name could not be found"); + } + if (!hash) { + throw new Error("No image hash could be found"); + } + + return { + name, + tag, + hash + } } -async function getLatestSemverTag(image: string, tag?: string): Promise<{ hash: string; tag?: string }> { - try { - const latest = { - hash: "", - tag: "", - }; +function getDocker() { + try { + const docker = new Docker({ socketPath: "/var/run/docker.sock" }); + if (docker) { + return docker; + } + throw new Error("Could not connect to the docker socket"); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not connect to docker socket", + }); + } +} - const allTags = await $`bin/regctl tag ls --exclude 'version.*' --exclude 'unstable.*' --exclude '.*rc.*' --exclude 'lib.*' --exclude 'release.*' --exclude 'arm.* -' --exclude 'amd.*' ${image}`.text(); - const semverTags = allTags.split("\n").filter((tag) => isSemver(tag)); - const newestTag = semver.rsort(semverTags)[0]; +async function getContainers(docker: Docker) { + try { + const containers = await docker.listContainers(); + if (containers.length) { + return containers; + } + throw Error("No containers found"); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not get containers from docker socket", + }); + } +} - let imageSh = image; - if (newestTag) { - imageSh += `:${newestTag}`; - latest.tag = newestTag; - } - const latestImage = await $`bin/regctl image digest ${imageSh}`.text(); - latest.hash = latestImage.split(":")?.[1]?.substring(0, 12) ?? "Error"; +function isSemver(tag: string): boolean { + const removeV = tag.replace(/^v/i, ""); + return !!semver.valid(removeV); +} - return latest; - } catch (ex) { - console.error((ex as unknown as Error).message); - return { - hash: "Error", - tag: "Error", - }; - } +async function getHash(image: string) { + const latestImage = await $`bin/regctl image digest ${image}`.text(); + const hash = latestImage.split(":")?.[1]?.substring(0, 12); + if (!hash) { + throw new Error("Hash not found", { + cause: { + imageDigest: latestImage, + }, + }); + } + return hash; } async function getLatest(image: string, tag?: string): Promise<{ hash: string; tag?: string }> { - try { - let latest: { hash: string; tag?: string } = { - hash: "", - tag: tag, - }; - if (tag && isSemver(tag)) { - latest = await getLatestSemverTag(image, tag); - } else { - let imageSh = image; - if (tag) { - imageSh += `:${tag}`; - } - const latestImage = await $`bin/regctl image digest ${imageSh}`.text(); - latest.hash = latestImage.split(":")?.[1]?.substring(0, 12) ?? "Error"; - } - return latest; - } catch (ex) { - console.error((ex as unknown as Error).message); - return { - hash: "Error", - tag: "Error", - }; - } + if (tag && isSemver(tag)) { + return await getSemverTag(image); + } + + let imageSh = image; + if (tag) { + imageSh += `:${tag}`; + } + + const hash = await getHash(imageSh); + + return { + hash, + }; } + +async function getSemverTag(image: string): Promise<{ hash: string; tag?: string }> { + const allTags = await $`bin/regctl tag ls --exclude 'version.*' --exclude 'unstable.*' --exclude '.*rc.*' --exclude 'lib.*' --exclude 'release.*' --exclude 'arm.* +' --exclude 'amd.*' ${image}`.text(); + const semverTags = allTags.split("\n").filter((tag) => isSemver(tag)); + const newestTag = semver.rsort(semverTags)[0]; + + let imageSh = image; + if (newestTag) { + imageSh += `:${newestTag}`; + return { + hash: await getHash(imageSh), + tag: newestTag, + }; + } + return { + hash: await getHash(imageSh), + }; +} + +export type dockerRouterType = inferRouterOutputs; \ No newline at end of file