Compare commits

..

11 Commits

Author SHA1 Message Date
Mauricio Siu
385850f354 fix: update audit action for container termination
Change the audit action from "kill" to "stop" for the containerKill function to better reflect the operation being performed. This aligns the logging with the intended action and improves clarity in audit records.
2026-04-13 20:36:04 -06:00
Mauricio Siu
a48306a2c6 fix: address PR review feedback
- Use "kill" audit action for killContainer instead of "stop"
- Pass undefined instead of empty string for optional serverId
2026-04-13 20:34:06 -06:00
Mauricio Siu
89737e7b65 refactor: remove duplicate import of ShowComposeContainers component
Eliminate redundant import statement for ShowComposeContainers in the compose service page, streamlining the code and improving readability.
2026-04-13 20:32:11 -06:00
Mauricio Siu
00c708483e fix: use service.read permission for compose container actions
Change restartContainer, startContainer, stopContainer, and killContainer
endpoints to use service.read instead of docker.read so members with
access to the compose can use container lifecycle actions.
2026-04-13 20:31:58 -06:00
autofix-ci[bot]
ddf570a807 [autofix.ci] apply automated fixes 2026-04-14 02:15:37 +00:00
Mauricio Siu
f8eb2ba4ba 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
2026-04-13 20:11:21 -06:00
Mauricio Siu
3cefa43a21 Merge pull request #4031 from difagume/style/deployments-remove-max-w-8xl
style(dashboard): remove max-width constraint from deployments card
2026-04-12 14:00:46 -06:00
autofix-ci[bot]
0941ec9f3e [autofix.ci] apply automated fixes 2026-04-12 20:00:08 +00:00
Mauricio Siu
879218a8b1 Merge branch 'canary' into style/deployments-remove-max-w-8xl 2026-04-12 13:59:24 -06:00
Diego Fabricio
4ef8c94340 Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-18 21:21:47 -05:00
Diego Fabricio
ff369c9d3a style(dashboard): remove max-width constraint from deployments card
- Deleted max-w-8xl class to allow card width to adapt freely
2026-03-18 21:09:08 -05:00
7 changed files with 437 additions and 19 deletions

View File

@@ -0,0 +1,268 @@
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const DockerLogsId = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
serverId,
},
{
enabled: !!appName,
},
);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-xl">Containers</CardTitle>
<CardDescription>
Inspect each container in this compose and run basic lifecycle
actions.
</CardDescription>
</div>
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isPending}
>
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
</Button>
</CardHeader>
<CardContent>
{isPending ? (
<div className="flex items-center justify-center h-[20vh]">
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
</div>
) : !data || data.length === 0 ? (
<div className="flex items-center justify-center h-[20vh]">
<span className="text-muted-foreground">
No containers found. Deploy the compose to see containers here.
</span>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
container={container}
serverId={serverId}
onActionComplete={() => refetch()}
/>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
};
interface ContainerRowProps {
container: {
containerId: string;
name: string;
state: string;
status: string;
};
serverId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
serverId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const restartMutation = api.docker.restartContainer.useMutation();
const startMutation = api.docker.startContainer.useMutation();
const stopMutation = api.docker.stopContainer.useMutation();
const killMutation = api.docker.killContainer.useMutation();
const handleAction = async (
action: string,
mutationFn: typeof restartMutation,
) => {
setActionLoading(action);
try {
await mutationFn.mutateAsync({
containerId: container.containerId,
serverId,
});
toast.success(`Container ${action} successfully`);
onActionComplete();
} catch (error) {
toast.error(
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setActionLoading(null);
}
};
return (
<TableRow>
<TableCell className="font-medium">{container.name}</TableCell>
<TableCell>
<Badge
variant={
container.state === "running"
? "default"
: container.state === "exited"
? "secondary"
: "destructive"
}
>
{container.state}
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
</TableCell>
<TableCell className="text-right">
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Logs
</DropdownMenuItem>
</DialogTrigger>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
Restart
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("start", startMutation)}
>
Start
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("stop", stopMutation)}
>
Stop
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-600"
disabled={actionLoading !== null}
onClick={() => handleAction("kill", killMutation)}
>
Kill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>Logs for {container.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={container.containerId}
serverId={serverId}
runType="native"
/>
</div>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
);
};

View File

@@ -40,7 +40,7 @@ function DeploymentsPage() {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">

View File

@@ -22,6 +22,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
@@ -60,6 +61,7 @@ type TabState =
| "advanced"
| "deployments"
| "domains"
| "containers"
| "monitoring"
| "volumeBackups";
@@ -231,6 +233,9 @@ const Service = (
Deployments
</TabsTrigger>
)}
{permissions?.service.read && (
<TabsTrigger value="containers">Containers</TabsTrigger>
)}
{permissions?.service.create && (
<TabsTrigger value="backups">Backups</TabsTrigger>
)}
@@ -298,6 +303,18 @@ const Service = (
</div>
</TabsContent>
)}
{permissions?.service.read && (
<TabsContent value="containers">
<div className="flex flex-col gap-4 pt-2.5">
<ShowComposeContainers
serverId={data?.serverId || undefined}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
/>
</div>
</TabsContent>
)}
{permissions?.monitoring.read && (
<TabsContent value="monitoring">
<div className="pt-2.5">

View File

@@ -1,6 +1,9 @@
import {
containerKill,
containerRemove,
containerRestart,
containerStart,
containerStop,
findServerById,
getConfig,
getContainers,
@@ -35,24 +38,108 @@ export const dockerRouter = createTRPCRouter({
return await getContainers(input.serverId);
}),
restartContainer: withPermission("docker", "read")
restartContainer: withPermission("service", "read")
.input(
z.object({
containerId: z
.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("service", "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("service", "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("service", "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")

View File

@@ -104,7 +104,10 @@ process.on("uncaughtException", (err) => {
});
process.on("unhandledRejection", (reason, _promise) => {
logger.error(reason instanceof Error ? reason : { reason: String(reason) }, "Unhandled Rejection at: Promise");
logger.error(
reason instanceof Error ? reason : { reason: String(reason) },
"Unhandled Rejection at: Promise",
);
});
const port = Number.parseInt(process.env.PORT || "3000");

View File

@@ -142,7 +142,10 @@ export const initializeJobs = async () => {
cronSchedule: CLEANUP_CRON_JOB,
});
} catch (error) {
logger.error(error, `Failed to schedule cleanup job for server ${serverId}`);
logger.error(
error,
`Failed to schedule cleanup job for server ${serverId}`,
);
}
}
@@ -255,7 +258,10 @@ export const initializeJobs = async () => {
cronSchedule: volumeBackup.cronExpression,
});
} catch (error) {
logger.error(error, `Failed to schedule volume backup ${volumeBackup.volumeBackupId}`);
logger.error(
error,
`Failed to schedule volume backup ${volumeBackup.volumeBackupId}`,
);
}
}

View File

@@ -417,21 +417,58 @@ export const getContainerLogs = async (
}
};
export const containerRestart = async (containerId: string) => {
try {
const { stdout, stderr } = await execAsync(
`docker container restart ${containerId}`,
);
export const containerRestart = async (
containerId: string,
serverId?: string,
) => {
const command = `docker container restart ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
const config = JSON.parse(stdout);
export const containerStart = async (
containerId: string,
serverId?: string,
) => {
const command = `docker container start ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
return config;
} catch {}
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
export const containerStop = async (containerId: string, serverId?: string) => {
const command = `docker container stop ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
export const containerKill = async (containerId: string, serverId?: string) => {
const command = `docker container kill ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
export const containerRemove = async (