feat: add containers tab to compose services

Add a Containers tab to the compose service page that lists all
containers with their state, status, and container ID. Each container
has a dropdown menu with lifecycle actions: View Logs, Restart, Start,
Stop, and Kill.

- Add containerStart, containerStop, containerKill functions to docker service
- Add corresponding tRPC procedures with server ownership checks and audit logging
- Update containerRestart to support remote servers via serverId
- Create ShowComposeContainers component with table view and action menu
- Add Containers tab between Deployments and Backups, gated by docker.read permission
This commit is contained in:
Mauricio Siu
2026-04-13 20:11:21 -06:00
parent 3cefa43a21
commit f8eb2ba4ba
4 changed files with 435 additions and 14 deletions

View File

@@ -1,6 +1,9 @@
import {
containerKill,
containerRemove,
containerRestart,
containerStart,
containerStop,
findServerById,
getConfig,
getContainers,
@@ -42,17 +45,101 @@ export const dockerRouter = createTRPCRouter({
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const result = await containerRestart(input.containerId);
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await containerRestart(input.containerId, input.serverId);
await audit(ctx, {
action: "start",
resourceType: "docker",
resourceId: input.containerId,
resourceName: input.containerId,
});
return result;
}),
startContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await containerStart(input.containerId, input.serverId);
await audit(ctx, {
action: "start",
resourceType: "docker",
resourceId: input.containerId,
resourceName: input.containerId,
});
}),
stopContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await containerStop(input.containerId, input.serverId);
await audit(ctx, {
action: "stop",
resourceType: "docker",
resourceId: input.containerId,
resourceName: input.containerId,
});
}),
killContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await containerKill(input.containerId, input.serverId);
await audit(ctx, {
action: "stop",
resourceType: "docker",
resourceId: input.containerId,
resourceName: input.containerId,
});
}),
removeContainer: withPermission("docker", "read")