containers page progress

This commit is contained in:
Derock 2024-01-15 19:24:44 -05:00
parent d943a9419b
commit 712888d9e8
No known key found for this signature in database
19 changed files with 10375 additions and 19 deletions

View file

@ -13,13 +13,15 @@
"dev:run": "node --enable-source-maps dist/server.js",
"lint": "next lint",
"start": "node -r tsconfig",
"fetch-compose-types": "curl -s https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json | json2ts > src/server/docker/compose.d.ts"
"fetch-compose-types": "curl -s https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json | json2ts > src/server/docker/compose.d.ts",
"fetch-docker-types": "openapi-typescript https://docs.docker.com/reference/engine/v1.43.yaml -o ./src/server/docker/types.d.ts"
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@mantine/form": "^7.4.0",
"@nicktomlin/codemirror-lang-yaml-lite": "^0.0.3",
"@prisma/migrate": "^5.7.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
@ -91,6 +93,7 @@
"eslint-config-next": "^14.0.4",
"json-schema-to-typescript": "^13.1.1",
"npm-run-all": "^4.1.5",
"openapi-typescript": "^5.4.1",
"postcss": "^8.4.32",
"prettier": "^3.1.1",
"prettier-plugin-tailwindcss": "^0.5.10",

View file

@ -17,6 +17,9 @@ dependencies:
'@prisma/migrate':
specifier: ^5.7.1
version: 5.7.1(@prisma/generator-helper@5.7.1)(@prisma/internals@5.7.1)
'@radix-ui/react-checkbox':
specifier: ^1.0.4
version: 1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-dialog':
specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
@ -226,6 +229,9 @@ devDependencies:
npm-run-all:
specifier: ^4.1.5
version: 4.1.5
openapi-typescript:
specifier: ^5.4.1
version: 5.4.1
postcss:
specifier: ^8.4.32
version: 8.4.32
@ -1075,6 +1081,11 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
/@fastify/busboy@2.1.0:
resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==}
engines: {node: '>=14'}
dev: true
/@floating-ui/core@1.5.2:
resolution: {integrity: sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==}
dependencies:
@ -1579,6 +1590,34 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-checkbox@1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.7
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@radix-ui/react-use-size': 1.0.1(@types/react@18.2.46)(react@18.2.0)
'@types/react': 18.2.46
'@types/react-dom': 18.2.18
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==}
peerDependencies:
@ -2095,6 +2134,20 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-use-previous@1.0.1(@types/react@18.2.46)(react@18.2.0):
resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.23.7
'@types/react': 18.2.46
react: 18.2.0
dev: false
/@radix-ui/react-use-rect@1.0.1(@types/react@18.2.46)(react@18.2.0):
resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==}
peerDependencies:
@ -5019,6 +5072,10 @@ packages:
define-properties: 1.2.1
dev: true
/globalyzer@0.1.0:
resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==}
dev: true
/globby@11.1.0:
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
engines: {node: '>=10'}
@ -5047,6 +5104,10 @@ packages:
- supports-color
dev: true
/globrex@0.1.2:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
dev: true
/gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
dependencies:
@ -5909,7 +5970,6 @@ packages:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'}
hasBin: true
dev: false
/mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
@ -6354,6 +6414,19 @@ packages:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
dev: false
/openapi-typescript@5.4.1:
resolution: {integrity: sha512-AGB2QiZPz4rE7zIwV3dRHtoUC/CWHhUjuzGXvtmMQN2AFV8xCTLKcZUHLcdPQmt/83i22nRE7+TxXOXkK+gf4Q==}
engines: {node: '>= 14.0.0'}
hasBin: true
dependencies:
js-yaml: 4.1.0
mime: 3.0.0
prettier: 2.8.8
tiny-glob: 0.2.9
undici: 5.28.2
yargs-parser: 21.1.1
dev: true
/optionator@0.9.3:
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
engines: {node: '>= 0.8.0'}
@ -7624,6 +7697,13 @@ packages:
next-tick: 1.1.0
dev: true
/tiny-glob@0.2.9:
resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==}
dependencies:
globalyzer: 0.1.0
globrex: 0.1.2
dev: true
/tiny-invariant@1.3.1:
resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==}
dev: false
@ -7889,6 +7969,13 @@ packages:
/undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
/undici@5.28.2:
resolution: {integrity: sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==}
engines: {node: '>=14.0'}
dependencies:
'@fastify/busboy': 2.1.0
dev: true
/unenv@1.8.0:
resolution: {integrity: sha512-uIGbdCWZfhRRmyKj1UioCepQ0jpq638j/Cf0xFTn4zD1nGJ2lSdzYHLzfdXN791oo/0juUiSWW1fBklXMTsuqg==}
dependencies:
@ -8183,6 +8270,11 @@ packages:
resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==}
engines: {node: '>= 14'}
/yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
dev: true
/yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}

View file

@ -1,5 +1,6 @@
"use client";
import { HomeIcon } from "lucide-react";
import { SettingsHeader } from "~/app/(dashboard)/settings/SettingsHeader";
import { SidebarNav } from "~/components/SidebarNav";
import { Separator } from "~/components/ui/separator";
@ -10,6 +11,7 @@ const sidebarNavItems = [
title: "Home",
description: "Quick overview of all containers for this project.",
href: "/",
icon: HomeIcon,
},
{
title: "Sessions",

View file

@ -17,6 +17,7 @@ export function ProjectLayout(props: {
}) {
const params = useParams();
const projectPath = `/project/${params.id as string}`;
const servicePath = `${projectPath}/service/${params.serviceId as string}`;
const project = api.projects.get.useQuery(
{ projectId: props.project.id },
@ -33,9 +34,9 @@ export function ProjectLayout(props: {
).length ?? 0;
const selectedService =
typeof params.serviceid === "string"
typeof params.serviceId === "string"
? project.data.services.find((service) =>
[service.id, service.name].includes(params.serviceid as string),
[service.id, service.name].includes(params.serviceId as string),
)
: undefined;
@ -44,6 +45,7 @@ export function ProjectLayout(props: {
data={{
...project.data,
path: projectPath,
servicePath,
selectedService,
}}
>

View file

@ -0,0 +1,104 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@radix-ui/react-dropdown-menu";
import { ClipboardIcon } from "lucide-react";
import { FaGear } from "react-icons/fa6";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { api } from "~/trpc/react";
import { useProject } from "../../../_context/ProjectContext";
export default function Containers() {
const project = useProject();
const containers = api.projects.services.containers.useQuery({
serviceId: project.selectedService!.id,
projectId: project.id,
});
return (
<Table className="w-full">
<TableHeader>
<TableRow>
<TableHead>Container ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{containers.data?.containers.map((container) => (
<TableRow key={container.containerId}>
<TableCell
className="cursor-pointer font-mono text-sm text-muted-foreground"
onClick={() => {
if (!navigator.clipboard || !window.isSecureContext) {
return toast.error(
"Cannot copy to clipboard when not using HTTPS.",
);
}
navigator.clipboard
.writeText(container.containerId)
.then(() => {
toast.success("Copied to clipboard.");
})
.catch((err) => {
console.error(err);
toast.error("Failed to copy to clipboard");
});
}}
>
{container.containerId?.substring(0, 8) ?? "N/A (deploying)"}
{container.containerId && (
<ClipboardIcon
size={14}
strokeWidth={1.5}
className="ml-2 inline-block stroke-muted-foreground"
/>
)}
</TableCell>
<TableCell>Deployed (updated)</TableCell>
<TableCell>{container.error}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<span className="sr-only">Actions</span>
<FaGear />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem>test</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
{project.services.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center">
No services
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}

View file

@ -1,6 +1,15 @@
"use client";
import { BoxesIcon, CloudyIcon, CodeIcon, HomeIcon } from "lucide-react";
import {
BoxesIcon,
CloudyIcon,
CodeIcon,
ContainerIcon,
GlobeIcon,
HomeIcon,
SaveAllIcon,
ServerCogIcon,
} from "lucide-react";
import { SidebarNav, type SidebarNavProps } from "~/components/SidebarNav";
import { useProject } from "../../_context/ProjectContext";
@ -29,12 +38,42 @@ const sidebarNavItems = [
href: "/deployments",
icon: CloudyIcon,
},
{
type: "divider",
title: "Build Settings",
},
{
title: "Source",
description: "Source settings",
href: "/source",
icon: CodeIcon,
},
{
title: "Domains",
description: "Domain settings",
href: "/domains",
icon: GlobeIcon,
},
{
title: "Environment",
description: "Environment settings",
href: "/environment",
icon: ContainerIcon,
},
{
title: "Volumes",
description: "Volume settings",
href: "/volumes",
icon: SaveAllIcon,
},
{
title: "Advanced",
description: "Advanced settings",
href: "/replication",
icon: ServerCogIcon,
},
] as const;
export default function ProjectHomeLayout({
@ -45,7 +84,7 @@ export default function ProjectHomeLayout({
const project = useProject();
const items = sidebarNavItems.map((item) => ({
...item,
href: "href" in item ? `${project.path}${item.href}` : undefined,
href: "href" in item ? `${project.servicePath}${item.href}` : undefined,
})) as SidebarNavProps["items"];
return (
@ -54,7 +93,7 @@ export default function ProjectHomeLayout({
<aside className="lg:w-1/5">
<SidebarNav items={items} />
</aside>
<div className="flex-1 space-y-6 lg:max-w-2xl">
<div className="flex-1 flex-grow space-y-6">
{/* <SettingsHeader items={items} /> */}
{/* <Separator /> */}
{children}

View file

@ -35,10 +35,15 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
)}
{...props}
>
{items.map((item, i) =>
item.type === "divider" ? (
{items.map((item, i) => {
const isActive =
item.type !== "divider"
? pathname === item.href.replace(/\/$/, "")
: false;
return item.type === "divider" ? (
<p
className="pb-2 pt-4 text-xs tracking-wide text-muted-foreground"
className="pb-1.5 pl-2 pt-4 text-xs tracking-wide text-muted-foreground"
key={i}
>
{item.title}
@ -49,21 +54,21 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
href={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === item.href.replace(/\/$/, "")
isActive
? "bg-muted hover:bg-muted"
: "hover:bg-transparent hover:underline",
"justify-start",
)}
>
{item.icon && (
<div className="mr-2 rounded-md bg-card p-1.5">
<div className="mr-2 rounded-md bg-border p-1.5">
<item.icon size={16} strokeWidth={1.5} />
</div>
)}
{item.title}
</Link>
),
)}
);
})}
</nav>
);
}

View file

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "@radix-ui/react-icons"
import { cn } from "~/utils/utils.ts"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View file

@ -0,0 +1,28 @@
import { experimental_standaloneMiddleware } from "@trpc/server";
import chalk from "chalk";
import logger from "~/server/utils/logger";
const log = logger.child({ module: "trpc:server" });
export const loggerMiddleware = experimental_standaloneMiddleware().create(
async ({ type, path, next }) => {
const result = await next();
if (result.ok === false) {
if (result.error.code === "INTERNAL_SERVER_ERROR") {
log.error(
`Internal server error on ${chalk.red(type)}: ${chalk.red(path)}`,
result.error,
);
} else {
log.warn(
`${result.error.code} on ${chalk.yellow(type)}: ${chalk.yellow(
path,
)}`,
result.error,
);
}
}
return result;
},
);

View file

@ -3,6 +3,13 @@ import { eq, or } from "drizzle-orm";
import { type db } from "~/server/db";
import { projects } from "~/server/db/schema";
export type BasicProjectDetails = {
id: string;
friendlyName: string;
internalName: string;
createdAt: number;
};
export const projectMiddleware = experimental_standaloneMiddleware<{
ctx: { db: typeof db };
input: { projectId: string };
@ -38,7 +45,7 @@ export const projectMiddleware = experimental_standaloneMiddleware<{
return next({
ctx: {
project: project,
project: project as BasicProjectDetails,
},
});
});

View file

@ -0,0 +1,53 @@
import { TRPCError, experimental_standaloneMiddleware } from "@trpc/server";
import { and, eq, or } from "drizzle-orm";
import { type db } from "~/server/db";
import { service } from "~/server/db/schema";
import { type BasicProjectDetails } from "./project";
export const serviceMiddleware = experimental_standaloneMiddleware<{
ctx: { db: typeof db; project: BasicProjectDetails };
input: { serviceId: string };
}>().create(async ({ ctx, input, next }) => {
if (typeof input.serviceId != "string") {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Expected a service ID or internal name.",
});
}
if (typeof ctx.project?.id != "string") {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"Expected a project ID. (maybe projectMiddleware is not being used?)",
});
}
const [serviceDetails] = await ctx.db
.select({
id: service.id,
name: service.name,
createdAt: service.createdAt,
})
.from(service)
.where(
and(
eq(service.projectId, ctx.project.id),
or(eq(service.name, input.serviceId), eq(service.id, input.serviceId)),
),
)
.limit(1);
if (!serviceDetails)
throw new TRPCError({
code: "NOT_FOUND",
message:
"Service not found or insufficient permissions: " + input.serviceId,
});
return next({
ctx: {
service: serviceDetails,
},
});
});

View file

@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
import { z } from "zod";
import { service } from "~/server/db/schema";
import { buildDockerStackFile } from "~/server/docker/stack";
import logger from "~/server/utils/logger";
import { projectMiddleware } from "../../middleware/project";
import { authenticatedProcedure } from "../../trpc";
@ -33,6 +34,8 @@ export const deployProject = authenticatedProcedure
});
const dockerStackFile = await buildDockerStackFile(services);
logger.debug("deploying stack", { dockerStackFile });
const response = await ctx.docker.cli(
["stack", "deploy", "--compose-file", "-", ctx.project.internalName],
{

View file

@ -0,0 +1,162 @@
import assert from "assert";
import { z } from "zod";
import { projectMiddleware } from "~/server/api/middleware/project";
import { serviceMiddleware } from "~/server/api/middleware/service";
import { authenticatedProcedure } from "~/server/api/trpc";
import { type paths as DockerAPITypes } from "~/server/docker/types";
const getServiceContainersOutput = z.object({
replication: z.object({
running: z.number(),
desired: z.number(),
}),
containers: z.array(
z.object({
status: z
.string({
description:
"Same as [].Status https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList",
})
.optional(),
state: z
.string({
description:
"Same as [].State https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList",
})
.optional(),
taskState: z.enum([
"complete",
"new",
"allocated",
"pending",
"assigned",
"accepted",
"preparing",
"ready",
"starting",
"running",
"shutdown",
"failed",
"rejected",
"remove",
"orphaned",
]),
containerId: z.string(),
containerCreatedAt: z.number(),
taskUpdatedAt: z.number(),
error: z.string().optional(),
node: z.string().optional(),
}),
),
});
export const getServiceContainers = authenticatedProcedure
.meta({
openapi: {
method: "GET",
path: "/api/projects/:projectId/services/:serviceId/containers",
summary: "Get service containers",
},
})
.input(
z.object({
projectId: z.string(),
serviceId: z.string(),
}),
)
.output(getServiceContainersOutput)
// .output(z.unknown())
.use(projectMiddleware)
.use(serviceMiddleware)
.query(async ({ ctx, input }) => {
// get docker service stats
const service = (await ctx.docker
.getService(`${ctx.project.internalName}_${ctx.service.name}`)
.inspect()) as DockerAPITypes["/services/{id}"]["get"]["responses"]["200"]["schema"];
assert(service.ID, "Unable to retrieve service ID.");
// list all the containers related to this service
const containersPromise = ctx.docker.listContainers({
all: true,
filters: {
label: [`com.docker.swarm.service.id=${service.ID}`],
},
});
// and find the current task ID for this service
const tasksPromise = ctx.docker.listTasks({
filters: {
service: [service.ID],
},
}) as Promise<
DockerAPITypes["/tasks"]["get"]["responses"]["200"]["schema"]
>;
// and list all nodes
const nodesPromise = ctx.docker.listNodes() as Promise<
DockerAPITypes["/nodes"]["get"]["responses"]["200"]["schema"]
>;
const [containers, tasks, nodes] = await Promise.all([
containersPromise,
tasksPromise,
nodesPromise,
]);
// format stats
const formatted = {
serviceId: service.ID,
replication: {
running: service.Spec?.Mode?.Replicated?.Replicas ?? 0,
desired: service.Spec?.Mode?.Replicated?.Replicas ?? 0,
},
containers: tasks
.sort((a, b) => {
// order in descending order of creation
if (a.CreatedAt && b.CreatedAt) {
return (
new Date(b.CreatedAt).getTime() - new Date(a.CreatedAt).getTime()
);
} else {
return 0;
}
})
.map((task) => {
// find the associated container
const container = containers.find(
(container) =>
container.Id === task.Status?.ContainerStatus?.ContainerID,
);
const taskUpdatedAt = new Date(task.UpdatedAt ?? 0).getTime();
const containerCreatedAt = new Date(
container?.Created ?? 0,
).getTime();
return {
status: container?.Status,
state: container?.State,
taskState: task.Status?.State,
containerId: task.Status?.ContainerStatus?.ContainerID ?? "",
containerCreatedAt,
taskUpdatedAt,
error: task.Status?.Err,
node: nodes.find((node) => node.ID === task.NodeID)?.Description
?.Hostname,
};
}),
};
// return formatted;
// I don't feel like writing a lot of assert's because for some reason all the types are `| undefined` and I don't know why
return getServiceContainersOutput.parse(formatted);
});

View file

@ -8,8 +8,11 @@ import { authenticatedProcedure, createTRPCRouter } from "~/server/api/trpc";
import { service } from "~/server/db/schema";
import { ServiceSource } from "~/server/db/types";
import { zDockerName } from "~/server/utils/zod";
import { getServiceContainers } from "./containers";
export const serviceRouter = createTRPCRouter({
containers: getServiceContainers,
create: authenticatedProcedure
.meta({
openapi: {

View file

@ -17,6 +17,7 @@ import { Session } from "../auth/Session";
import { getDockerInstance } from "../docker";
import { type Docker } from "../docker/docker";
import logger from "../utils/logger";
import { loggerMiddleware } from "./middleware/logger";
// import { OpenApiMeta, generateOpenApiDocument } from "trpc-openapi";
export type ExtendedRequest = IncomingMessage & {
@ -163,7 +164,7 @@ export const createTRPCRouter = t.router;
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure;
export const publicProcedure = t.procedure.use(loggerMiddleware);
/**
* Authenticated procedure
@ -171,7 +172,7 @@ export const publicProcedure = t.procedure;
* This is the base piece you use to build new queries and mutations on your tRPC API. It guarantees
* that a user querying is authorized, and you can access user session data.
*/
export const authenticatedProcedure = t.procedure.use(
export const authenticatedProcedure = t.procedure.use(loggerMiddleware).use(
t.middleware(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({

View file

@ -70,4 +70,11 @@ export class Docker extends Dockerode {
});
});
}
// /**
// * Lists all containers on all nodes.
// */
// public async listContainersOnAllNodes() {
// this.listTasks
// }
}

View file

@ -155,6 +155,21 @@ export async function buildDockerStackFile(
return {
version: "3.8",
services: swarmServices,
services: cleanObject(swarmServices),
};
}
/**
* Small utility function to clean out keys with null value from an object.
* Useful because sometimes docker will treat `null` as '', causing issues.
*/
export function cleanObject<T extends Record<string, unknown>>(obj: T): T {
for (const key in obj) {
if (obj[key] === null) delete obj[key];
if (typeof obj[key] === "object")
// @ts-expect-error - idk how to type this any better
obj[key] = cleanObject(obj[key] as Record<string, unknown>) as unknown;
}
return obj;
}

9794
src/server/docker/types.d.ts vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -24,7 +24,13 @@ const logger = createLogger({
if (others[SPLAT]) {
const splat = others[SPLAT] as unknown[];
if (splat.length > 0) {
return base + " " + splat.map((s) => util.inspect(s)).join("\n");
const formattedSplat = splat
.map((s) => util.inspect(s, { colors: true, showHidden: true }))
.flatMap((s) => s.split("\n"))
.map((s) => ` ${s}`)
.join("\n");
return base + "\n" + formattedSplat;
}
}