Compare commits
7 Commits
7b7c32d9ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a463e61fb4 | |||
| 7b0ff36052 | |||
| 00c32e7cdf | |||
| 21caaf3901 | |||
| 590fe84b40 | |||
| 32f53eb27e | |||
| eb23299181 |
@@ -1,5 +1,5 @@
|
|||||||
# Use the official Node.js image as the base image
|
# Use the official Node.js image as the base image
|
||||||
FROM node:24-alpine
|
FROM node:24-trixie-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
@@ -17,6 +17,8 @@ RUN npm ci
|
|||||||
# Copy the rest of the application files
|
# Copy the rest of the application files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN chmod +x ./bin/regctl
|
||||||
|
|
||||||
# Build the NestJS application
|
# Build the NestJS application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
51
README.md
51
README.md
@@ -1,29 +1,30 @@
|
|||||||
# Create T3 App
|
# DVDash
|
||||||
|
|
||||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
A little dashboard for checking the latest versions of running docker containers.
|
||||||
|
Uses [dockerode](https://www.npmjs.com/package/dockerode) to check running containers, grab their images and then uses [regctl](https://github.com/regclient/regclient) to handle the connection to any registry to get the latest semver version for the image if found.
|
||||||
## What's next? How do I make an app with this?
|
I used regctl because it turns out loads of docker registries do not follow the [OCI spec](https://github.com/opencontainers/distribution-spec/blob/main/spec.md) and that tool just deals with it for me.
|
||||||
|
|
||||||
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
|
|
||||||
|
|
||||||
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
|
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org)
|
|
||||||
- [NextAuth.js](https://next-auth.js.org)
|
|
||||||
- [Prisma](https://prisma.io)
|
|
||||||
- [Drizzle](https://orm.drizzle.team)
|
|
||||||
- [Tailwind CSS](https://tailwindcss.com)
|
|
||||||
- [tRPC](https://trpc.io)
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
|
|
||||||
|
|
||||||
- [Documentation](https://create.t3.gg/)
|
|
||||||
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
|
|
||||||
|
|
||||||
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## How do I deploy this?
|
## How do I deploy this?
|
||||||
|
|
||||||
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
|
This just runs on port 3000 and needs the docker socket as read only.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dvdash:
|
||||||
|
container_name: dvdash
|
||||||
|
image: gitea.home.joemonk.co.uk/joe/dvdash:1
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
ports:
|
||||||
|
3000:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create T3 App
|
||||||
|
|
||||||
|
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app` (because I'm comfortable with it and wanted to build a very small application very quickly).
|
||||||
|
|
||||||
|
### Learn More
|
||||||
|
|
||||||
|
- [Next.js](https://nextjs.org)
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com)
|
||||||
|
- [tRPC](https://trpc.io)
|
||||||
|
|||||||
BIN
bin/regctl
BIN
bin/regctl
Binary file not shown.
@@ -4,90 +4,103 @@ import type { dockerRouterType } from "@/server/api/routers/docker";
|
|||||||
import { api } from "@/trpc/react";
|
import { api } from "@/trpc/react";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
function DockerRow({
|
function DockerCard({
|
||||||
containerInfo,
|
containerInfo,
|
||||||
}: {
|
}: {
|
||||||
containerInfo: dockerRouterType["list"][number];
|
containerInfo: dockerRouterType["list"][number];
|
||||||
}) {
|
}) {
|
||||||
const { data: latest, isError, isLoading } = api.docker.latest.useQuery({ id: containerInfo.container.id });
|
const { data: latest, isError, isLoading, error } = api.docker.latest.useQuery({ id: containerInfo.container.id }, { refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false });
|
||||||
const outdated = containerInfo.image.current.hash !== latest?.latest.hash;
|
|
||||||
|
|
||||||
let latestFragment: JSX.Element | null = null;
|
let latestFragment: JSX.Element | null = null;
|
||||||
if (isError) {
|
if (isError) {
|
||||||
latestFragment = (
|
latestFragment = (
|
||||||
<>
|
<div className="flex flex-row flex-wrap *:basis-1/2">
|
||||||
<td>{"Error"}</td>
|
<dt>Tag</dt>
|
||||||
<td>{"Error"}</td>
|
<dd>Error: {error.message}</dd>
|
||||||
</>
|
|
||||||
|
<dt>Short Hash</dt>
|
||||||
|
<dd>Error: {error.message}</dd>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
} else if (isLoading) {
|
} else if (isLoading) {
|
||||||
latestFragment = (
|
latestFragment = (
|
||||||
<>
|
<div className="flex flex-row flex-wrap *:basis-1/2">
|
||||||
<td>
|
<dt>Tag</dt>
|
||||||
<span className="loading loading-dots loading-lg" />
|
<dd className="loading loading-dots loading-sm" />
|
||||||
</td>
|
|
||||||
<td>
|
<dt>Short Hash</dt>
|
||||||
<span className="loading loading-dots loading-lg" />
|
<dd className="loading loading-dots loading-sm" />
|
||||||
</td>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
} else if (latest) {
|
} else if (latest) {
|
||||||
latestFragment = (
|
latestFragment = (
|
||||||
<>
|
<div className="flex flex-row flex-wrap *:basis-1/2">
|
||||||
<td>{latest?.latest.tag}</td>
|
<dt>Tag</dt>
|
||||||
<td>{latest?.latest.hash}</td>
|
<dd>{latest?.latest.tag ?? "Unknown"}</dd>
|
||||||
</>
|
|
||||||
|
<dt>Short Hash</dt>
|
||||||
|
<dd>{latest?.latest.hash ?? "Unknown"}</dd>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const outdated = containerInfo.image.current.hash !== latest?.latest.hash;
|
||||||
|
const showWarning = isLoading || (!isError && !isLoading && (!latest?.latest.tag || !latest?.latest.hash));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={containerInfo.container.name} className={`${outdated ? "bg-base-200" : null}`}>
|
<div
|
||||||
<td className={`border-l-8 ${isLoading ? "border-l-warning/80" : outdated ? "border-l-error/80" : "border-l-info/80"}`}>{containerInfo.container.name}</td>
|
key={containerInfo.container.name}
|
||||||
<td>{containerInfo.image.name}</td>
|
className={`card card-outline card-compact w-80 border-t-8 bg-base-300 shadow-md ${showWarning ? "border-t-warning/80" : outdated ? "border-t-error/80" : "border-t-info/80"}`}
|
||||||
<td>{containerInfo.image.current.tag}</td>
|
>
|
||||||
<td>{containerInfo.image.current.hash}</td>
|
<dl className="card-body flex flex-col flex-wrap items-center justify-around text-base">
|
||||||
{latestFragment}
|
<h2 className="text-center text-3xl">{containerInfo.container.name}</h2>
|
||||||
</tr>
|
<p>{containerInfo.image.name}</p>
|
||||||
|
<div className="divider divider-primary m-1" />
|
||||||
|
<h3 className="text-xl underline decoration-1 underline-offset-4">Current</h3>
|
||||||
|
<div className="flex flex-row flex-wrap *:basis-1/2">
|
||||||
|
<dt>Tag</dt>
|
||||||
|
<dd>{containerInfo.image.current.tag}</dd>
|
||||||
|
<dt>Short Hash</dt>
|
||||||
|
<dd>{containerInfo.image.current.hash}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="divider divider-primary m-0" />
|
||||||
|
<h3 className="text-xl underline decoration-1 underline-offset-4">Latest</h3>
|
||||||
|
{latestFragment}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DockerTable() {
|
export function DockerTable() {
|
||||||
const { data: list, isLoading: listLoading } = api.docker.list.useQuery();
|
const { data: list, isLoading: listLoading, error } = api.docker.list.useQuery(undefined, { refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-md border border-base-content/15 bg-base-100">
|
<>
|
||||||
{!listLoading ? (
|
{listLoading ? (
|
||||||
<table className="table-s table">
|
<>Loading docker container list...</>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Image</th>
|
|
||||||
<th>Tag</th>
|
|
||||||
<th>Short Hash</th>
|
|
||||||
<th>Tag</th>
|
|
||||||
<th>Short Hash</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{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) => <DockerRow key={containerInfo.container.name} containerInfo={containerInfo} />)
|
|
||||||
: null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : (
|
) : (
|
||||||
<>Loading data...</>
|
<>
|
||||||
|
<div className={"flex flex-row flex-wrap justify-around gap-8"}>
|
||||||
|
{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) => <DockerCard key={containerInfo.container.name} containerInfo={containerInfo} />)
|
||||||
|
) : (
|
||||||
|
<>Error loading docker container list: {error?.message}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ export default async function Home() {
|
|||||||
void api.docker.list.prefetch();
|
void api.docker.list.prefetch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center">
|
<main className="container flex min-h-screen flex-col items-center justify-center px-4 py-8">
|
||||||
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16">
|
<DockerTable />
|
||||||
<DockerTable />
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const container = containers.find((container) => container.Id === input.id);
|
const container = containers.find((container) => container.Id === input.id);
|
||||||
if (!container) {
|
if (!container) {
|
||||||
|
console.error(`Container with id ${input.id} not found`);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: `Container with id ${input.id} not found`,
|
message: `Container with id ${input.id} not found`,
|
||||||
@@ -27,6 +28,7 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
latest: await getLatest(imageData.name, imageData.tag),
|
latest: await getLatest(imageData.name, imageData.tag),
|
||||||
};
|
};
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: (ex as Error).message,
|
message: (ex as Error).message,
|
||||||
@@ -159,9 +161,10 @@ function isSemver(tag: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getHash(image: string) {
|
async function getHash(image: string) {
|
||||||
const latestImage = await $`bin/regctl image digest ${image}`.text();
|
const latestImage = await $`./bin/regctl image digest ${image}`.text();
|
||||||
const hash = latestImage.split(":")?.[1]?.substring(0, 12);
|
const hash = latestImage.split(":")?.[1]?.substring(0, 12);
|
||||||
if (!hash) {
|
if (!hash) {
|
||||||
|
console.error(`Hash not found: ${latestImage}`);
|
||||||
throw new Error("Hash not found", {
|
throw new Error("Hash not found", {
|
||||||
cause: {
|
cause: {
|
||||||
imageDigest: latestImage,
|
imageDigest: latestImage,
|
||||||
@@ -185,12 +188,13 @@ async function getLatest(image: string, tag?: string): Promise<{ hash: string; t
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hash,
|
hash,
|
||||||
|
tag,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSemverTag(image: string): Promise<{ hash: string; tag?: string }> {
|
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.*
|
const allTags =
|
||||||
' --exclude 'amd.*' ${image}`.text();
|
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 semverTags = allTags.split("\n").filter((tag) => isSemver(tag));
|
||||||
const newestTag = semver.rsort(semverTags)[0];
|
const newestTag = semver.rsort(semverTags)[0];
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,4 @@
|
|||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user