feat(sandbox): sandboxes list and detail UI

This commit is contained in:
Mauricio Siu
2026-09-10 01:16:55 -06:00
parent ad00da50c1
commit e3954f7915
8 changed files with 1212 additions and 0 deletions
@@ -0,0 +1,383 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Box, PlusIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
const schema = z
.object({
name: z.string().max(64).optional(),
source: z.enum(["template", "image"]),
template: z.enum(["base", "python", "node"]),
image: z.string().max(255).optional(),
serverId: z.string().nullable(),
cpu: z.number().min(0.1).max(64),
memoryMb: z.number().int().min(64).max(262_144),
timeoutMinutes: z.number().min(1).max(1440),
networkMode: z.enum(["isolated", "internet"]),
envVars: z.string().optional(),
})
.refine((value) => value.source === "template" || !!value.image?.trim(), {
message: "Image is required",
path: ["image"],
});
type FormValues = z.infer<typeof schema>;
interface Props {
environmentId: string;
projectId: string;
}
export const AddSandbox = ({ environmentId, projectId }: Props) => {
const router = useRouter();
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: templates } = api.sandbox.templates.useQuery();
const { mutateAsync, isPending, error } = api.sandbox.create.useMutation();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const form = useForm<FormValues>({
defaultValues: {
name: "",
source: "template",
template: "python",
image: "",
serverId: null,
cpu: 1,
memoryMb: 512,
timeoutMinutes: 5,
networkMode: "isolated",
envVars: "",
},
resolver: zodResolver(schema),
});
const source = form.watch("source");
const onSubmit = async (values: FormValues) => {
try {
const sandbox = await mutateAsync({
environmentId,
name: values.name?.trim() || undefined,
template: values.source === "template" ? values.template : undefined,
image: values.source === "image" ? values.image?.trim() : undefined,
serverId: values.serverId,
cpu: values.cpu,
memoryMb: values.memoryMb,
timeoutMs: Math.round(values.timeoutMinutes * 60_000),
networkMode: values.networkMode,
envVars: values.envVars?.trim() || undefined,
});
toast.success("Sandbox created");
await utils.sandbox.list.invalidate({ environmentId });
setOpen(false);
form.reset();
router.push(
`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`,
);
} catch {
toast.error("Error creating the sandbox");
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<PlusIcon className="size-4" />
Create Sandbox
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl md:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Box className="size-5 text-muted-foreground" />
New Sandbox
</DialogTitle>
<DialogDescription>
An ephemeral, resource-limited container you can drive through the
API. It is removed automatically when its timeout expires.
</DialogDescription>
</DialogHeader>
{error && <AlertBlock type="error">{error.message}</AlertBlock>}
<Form {...form}>
<form
id="add-sandbox-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="my-sandbox" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="source"
render={({ field }) => (
<FormItem>
<FormLabel>Image</FormLabel>
<Tabs value={field.value} onValueChange={field.onChange}>
<TabsList>
<TabsTrigger value="template">Template</TabsTrigger>
<TabsTrigger value="image">Custom image</TabsTrigger>
</TabsList>
</Tabs>
</FormItem>
)}
/>
{source === "template" ? (
<FormField
control={form.control}
name="template"
render={({ field }) => (
<FormItem>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a template" />
</SelectTrigger>
<SelectContent>
{(templates ?? []).map((template) => (
<SelectItem key={template.name} value={template.name}>
<span className="capitalize">{template.name}</span>
<span className="text-muted-foreground">
{" "}
· {template.image}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="alpine:3.20" {...field} />
</FormControl>
<FormDescription>
The image must contain a POSIX shell (sh) and tail.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{(servers?.length ?? 0) > 0 && (
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<Select
value={field.value ?? "dokploy"}
onValueChange={(value) =>
field.onChange(value === "dokploy" ? null : value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
{showLocalOption && (
<SelectItem value="dokploy">
Dokploy Server
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<FormField
control={form.control}
name="cpu"
render={({ field }) => (
<FormItem>
<FormLabel>CPU cores</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
min={0.1}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memoryMb"
render={({ field }) => (
<FormItem>
<FormLabel>Memory (MB)</FormLabel>
<FormControl>
<Input
type="number"
step="64"
min={64}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeoutMinutes"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (minutes)</FormLabel>
<FormControl>
<Input
type="number"
step="1"
min={1}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="networkMode"
render={({ field }) => (
<FormItem>
<FormLabel>Network</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="isolated">
Isolated (no network access)
</SelectItem>
<SelectItem value="internet">Internet access</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Sandboxes are never attached to dokploy-network or any
application network.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="envVars"
render={({ field }) => (
<FormItem>
<FormLabel>Environment variables</FormLabel>
<FormControl>
<Textarea
placeholder={"KEY=value\nOTHER=value"}
className="font-mono min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
type="submit"
form="add-sandbox-form"
isLoading={isPending}
disabled={isPending}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,120 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Clock } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const schema = z.object({
minutes: z.number().min(1).max(1440),
});
interface Props {
sandboxId: string;
currentTimeoutMs: number;
}
export const ExtendSandboxTimeout = ({
sandboxId,
currentTimeoutMs,
}: Props) => {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isPending } = api.sandbox.setTimeout.useMutation();
const form = useForm<z.infer<typeof schema>>({
defaultValues: {
minutes: Math.max(1, Math.round(currentTimeoutMs / 60_000)),
},
resolver: zodResolver(schema),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Clock className="size-4" />
Extend timeout
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Extend timeout</DialogTitle>
<DialogDescription>
The sandbox will be killed this many minutes after its last
activity.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="extend-sandbox-form"
className="grid gap-4"
onSubmit={form.handleSubmit(async (values) => {
await mutateAsync({
sandboxId,
timeoutMs: Math.round(values.minutes * 60_000),
})
.then(async () => {
toast.success("Timeout updated");
await utils.sandbox.one.invalidate({ sandboxId });
setOpen(false);
})
.catch(() => toast.error("Error updating the timeout"));
})}
>
<FormField
control={form.control}
name="minutes"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (minutes)</FormLabel>
<FormControl>
<Input
type="number"
min={1}
step={1}
{...field}
onChange={(event) =>
field.onChange(event.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
type="submit"
form="extend-sandbox-form"
isLoading={isPending}
disabled={isPending}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,185 @@
import { Eraser, Loader2, Play, Terminal } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type Line = {
kind: "cmd" | "stdout" | "stderr" | "exit" | "error" | "info";
text: string;
};
type ServerMessage =
| { type: "ready"; sandboxId: string }
| { type: "stdout"; data: string }
| { type: "stderr"; data: string }
| { type: "exit"; exitCode: number; timedOut: boolean }
| { type: "error"; message: string };
interface Props {
sandboxId: string;
enabled: boolean;
workdir: string;
}
const lineClass: Record<Line["kind"], string> = {
cmd: "text-primary font-semibold",
stdout: "text-foreground",
stderr: "text-orange-500",
exit: "text-muted-foreground italic",
error: "text-destructive",
info: "text-muted-foreground",
};
export const SandboxConsole = ({ sandboxId, enabled, workdir }: Props) => {
const [lines, setLines] = useState<Line[]>([]);
const [command, setCommand] = useState("");
const [running, setRunning] = useState(false);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const outputRef = useRef<HTMLDivElement | null>(null);
const append = useCallback((line: Line) => {
setLines((prev) => {
const last = prev[prev.length - 1];
if (
last &&
(line.kind === "stdout" || line.kind === "stderr") &&
last.kind === line.kind &&
!last.text.endsWith("\n")
) {
return [...prev.slice(0, -1), { ...last, text: last.text + line.text }];
}
return [...prev, line];
});
}, []);
useEffect(() => {
if (!enabled) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${protocol}//${window.location.host}/sandbox-exec?sandboxId=${sandboxId}`,
);
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onclose = (event) => {
setConnected(false);
setRunning(false);
if (event.code >= 4000) {
append({ kind: "error", text: event.reason || "Connection closed" });
}
};
ws.onmessage = (event) => {
let message: ServerMessage;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "stdout")
append({ kind: "stdout", text: message.data });
else if (message.type === "stderr")
append({ kind: "stderr", text: message.data });
else if (message.type === "exit") {
append({
kind: "exit",
text: message.timedOut
? `[timed out, exit code ${message.exitCode}]`
: `[exit code ${message.exitCode}]`,
});
setRunning(false);
} else if (message.type === "error") {
append({ kind: "error", text: message.message });
setRunning(false);
}
};
return () => {
ws.close();
wsRef.current = null;
};
}, [sandboxId, enabled, append]);
useEffect(() => {
outputRef.current?.scrollTo({ top: outputRef.current.scrollHeight });
}, [lines]);
const run = () => {
const cmd = command.trim();
const ws = wsRef.current;
if (!cmd || !ws || ws.readyState !== WebSocket.OPEN || running) return;
append({ kind: "cmd", text: `$ ${cmd}` });
setRunning(true);
setCommand("");
ws.send(JSON.stringify({ type: "exec", cmd }));
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-xl flex items-center gap-2">
<Terminal className="size-5 text-muted-foreground" />
Console
<span className="text-xs font-normal text-muted-foreground">
{enabled
? connected
? "connected"
: "connecting..."
: "sandbox is not running"}
</span>
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setLines([])}
disabled={lines.length === 0}
>
<Eraser className="size-4" />
Clear
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div
ref={outputRef}
className="h-[360px] overflow-auto rounded-lg border bg-muted/30 p-3 font-mono text-xs whitespace-pre-wrap break-words"
>
{lines.length === 0 ? (
<span className="text-muted-foreground">
Commands run with `sh -c` inside {workdir}. Output streams here.
</span>
) : (
lines.map((line, index) => (
<div key={index} className={cn(lineClass[line.kind])}>
{line.text}
</div>
))
)}
</div>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
run();
}}
>
<Input
value={command}
onChange={(event) => setCommand(event.target.value)}
placeholder="echo hello"
className="font-mono"
disabled={!enabled || !connected || running}
autoComplete="off"
/>
<Button type="submit" disabled={!enabled || !connected || running}>
{running ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4" />
)}
Run
</Button>
</form>
</CardContent>
</Card>
);
};
@@ -0,0 +1,16 @@
import { Badge } from "@/components/ui/badge";
type SandboxStatus = "creating" | "running" | "killed" | "error";
const variants: Record<SandboxStatus, "green" | "yellow" | "blank" | "red"> = {
running: "green",
creating: "yellow",
killed: "blank",
error: "red",
};
export const SandboxStatusBadge = ({ status }: { status: SandboxStatus }) => (
<Badge variant={variants[status]} className="capitalize">
{status}
</Badge>
);
@@ -0,0 +1,134 @@
import { formatDistanceToNow } from "date-fns";
import { Box, ServerIcon, Trash2 } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { SandboxStatusBadge } from "./sandbox-status-badge";
interface Props {
projectId: string;
environmentId: string;
}
export const formatExpiresIn = (expiresAt: Date | null, status: string) => {
if (status !== "running" || !expiresAt) return "—";
if (expiresAt.getTime() <= Date.now()) return "expired";
return formatDistanceToNow(expiresAt, { addSuffix: true });
};
export const ShowSandboxes = ({ projectId, environmentId }: Props) => {
const utils = api.useUtils();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: sandboxes, isLoading } = api.sandbox.list.useQuery(
{ environmentId },
{ refetchInterval: 10_000 },
);
const { mutateAsync: kill } = api.sandbox.kill.useMutation();
if (isLoading) {
return (
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
if (!sandboxes || sandboxes.length === 0) {
return (
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-2">
<Box className="size-8 text-muted-foreground" />
<span className="font-medium text-muted-foreground">
No sandboxes yet. Create one from the UI or through the API.
</span>
</div>
);
}
return (
<div className="rounded-lg border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Image</TableHead>
<TableHead>Status</TableHead>
<TableHead>Server</TableHead>
<TableHead>CPU / RAM</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sandboxes.map((sandbox) => (
<TableRow key={sandbox.sandboxId}>
<TableCell className="font-medium">
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`}
className="hover:underline"
>
{sandbox.name}
</Link>
</TableCell>
<TableCell className="font-mono text-xs">
{sandbox.image}
</TableCell>
<TableCell>
<SandboxStatusBadge status={sandbox.status} />
</TableCell>
<TableCell>
<span className="flex items-center gap-1.5 text-sm">
<ServerIcon className="size-3.5 text-muted-foreground" />
{sandbox.server?.name ?? "Dokploy Server"}
</span>
</TableCell>
<TableCell className="text-sm">
{sandbox.cpu} vCPU / {sandbox.memoryMb} MB
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatExpiresIn(sandbox.expiresAt, sandbox.status)}
</TableCell>
<TableCell className="text-sm">
<DateTooltip date={sandbox.createdAt} />
</TableCell>
<TableCell className="text-right">
{sandbox.status === "running" &&
permissions?.deployment.create && (
<DialogAction
title="Kill sandbox"
description={`The container for "${sandbox.name}" will be stopped and removed.`}
onClick={async () => {
await kill({ sandboxId: sandbox.sandboxId })
.then(() => {
toast.success("Sandbox killed");
utils.sandbox.list.invalidate({ environmentId });
})
.catch(() =>
toast.error("Error killing the sandbox"),
);
}}
>
<Button variant="ghost" size="icon">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
@@ -3,6 +3,7 @@ import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import {
Ban,
Box,
Check,
CheckCircle2,
ChevronsUpDown,
@@ -1062,6 +1063,14 @@ const EnvironmentPage = (
<ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button>
</ProjectEnvironment>
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes`}
>
<Button variant="outline">
<Box className="size-4" />
Sandboxes
</Button>
</Link>
{permissions?.service.create && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -0,0 +1,230 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { ArrowLeft, Box, Skull, Trash2 } from "lucide-react";
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import type { ReactElement } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { ExtendSandboxTimeout } from "@/components/dashboard/sandbox/extend-sandbox-timeout";
import { SandboxConsole } from "@/components/dashboard/sandbox/sandbox-console";
import { SandboxStatusBadge } from "@/components/dashboard/sandbox/sandbox-status-badge";
import { formatExpiresIn } from "@/components/dashboard/sandbox/show-sandboxes";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
const Field = ({ label, value }: { label: string; value: React.ReactNode }) => (
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span className="text-sm font-medium break-all">{value}</span>
</div>
);
const SandboxPage = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
) => {
const { sandboxId } = props;
const router = useRouter();
const utils = api.useUtils();
const { projectId, environmentId } = router.query as {
projectId: string;
environmentId: string;
};
const { data, refetch } = api.sandbox.one.useQuery(
{ sandboxId },
{ refetchInterval: 10_000 },
);
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: kill, isPending: isKilling } =
api.sandbox.kill.useMutation();
const { mutateAsync: remove } = api.sandbox.remove.useMutation();
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const listHref = `/dashboard/project/${projectId}/environment/${environmentId}/sandboxes`;
const isRunning = data?.status === "running";
return (
<div className="pb-10">
<AdvanceBreadcrumb />
<Head>
<title>
Sandbox: {data?.name} - {data?.environment?.project?.name} | {appName}
</title>
</Head>
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row flex-wrap justify-between items-start gap-4">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl flex items-center gap-2">
<Box className="size-6 text-muted-foreground" />
{data?.name}
{data && <SandboxStatusBadge status={data.status} />}
</CardTitle>
<span className="text-sm text-muted-foreground font-mono">
{data?.sandboxId}
</span>
</div>
<div className="flex flex-col items-end gap-2">
<Badge>{data?.server?.name || "Dokploy Server"}</Badge>
<div className="flex flex-wrap gap-2 justify-end">
<Link href={listHref}>
<Button variant="outline">
<ArrowLeft className="size-4" />
Sandboxes
</Button>
</Link>
{isRunning && permissions?.deployment.create && (
<ExtendSandboxTimeout
sandboxId={sandboxId}
currentTimeoutMs={data?.timeoutMs ?? 300_000}
/>
)}
{isRunning && permissions?.deployment.create && (
<DialogAction
title="Kill sandbox"
description="The container will be stopped and removed. Files inside it are lost."
onClick={async () => {
await kill({ sandboxId })
.then(async () => {
toast.success("Sandbox killed");
await refetch();
})
.catch(() => toast.error("Error killing the sandbox"));
}}
>
<Button variant="destructive" isLoading={isKilling}>
<Skull className="size-4" />
Kill
</Button>
</DialogAction>
)}
{permissions?.service.delete && (
<DialogAction
title="Delete sandbox"
description="Removes the sandbox record (and its container if still running)."
onClick={async () => {
await remove({ sandboxId })
.then(async () => {
toast.success("Sandbox deleted");
await utils.sandbox.list.invalidate({
environmentId,
});
router.push(listHref);
})
.catch(() => toast.error("Error deleting the sandbox"));
}}
>
<Button variant="ghost" size="icon">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-6 py-8 border-t">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Details</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-6">
<Field label="Image" value={data?.image} />
<Field label="Template" value={data?.template ?? "custom"} />
<Field label="CPU" value={`${data?.cpu} vCPU`} />
<Field label="Memory" value={`${data?.memoryMb} MB`} />
<Field label="PIDs limit" value={data?.pidsLimit} />
<Field label="Network" value={data?.networkMode} />
<Field label="Workdir" value={data?.workdir} />
<Field label="User" value={data?.user ?? "image default"} />
<Field
label="Timeout"
value={`${Math.round((data?.timeoutMs ?? 0) / 60_000)} min`}
/>
<Field
label="Expires"
value={
data ? formatExpiresIn(data.expiresAt, data.status) : "—"
}
/>
<Field
label="Created"
value={data ? <DateTooltip date={data.createdAt} /> : "—"}
/>
<Field
label="Container"
value={
<span className="font-mono text-xs">
{data?.containerId?.slice(0, 12) ?? "—"}
</span>
}
/>
</CardContent>
</Card>
{permissions?.deployment.create && (
<SandboxConsole
sandboxId={sandboxId}
enabled={isRunning}
workdir={data?.workdir ?? "/"}
/>
)}
</CardContent>
</div>
</Card>
</div>
);
};
export default SandboxPage;
SandboxPage.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ sandboxId: string }>,
) {
const { params, req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user) {
return { redirect: { permanent: false, destination: "/" } };
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
if (typeof params?.sandboxId === "string") {
try {
await helpers.sandbox.one.fetch({ sandboxId: params.sandboxId });
return {
props: {
trpcState: helpers.dehydrate(),
sandboxId: params.sandboxId,
},
};
} catch {
return { redirect: { permanent: false, destination: "/" } };
}
}
return { redirect: { permanent: false, destination: "/" } };
}
@@ -0,0 +1,135 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { ArrowLeft, Box } from "lucide-react";
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import type { ReactElement } from "react";
import superjson from "superjson";
import { AddSandbox } from "@/components/dashboard/sandbox/add-sandbox";
import { ShowSandboxes } from "@/components/dashboard/sandbox/show-sandboxes";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
const SandboxesPage = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
) => {
const { projectId, environmentId } = props;
const { data: environment } = api.environment.one.useQuery({ environmentId });
const { data: permissions } = api.user.getPermissions.useQuery();
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
return (
<div className="pb-10">
<AdvanceBreadcrumb />
<Head>
<title>
Sandboxes: {environment?.name} | {environment?.project?.name} |{" "}
{appName}
</title>
</Head>
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row flex-wrap justify-between items-center gap-4">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl flex items-center gap-2">
<Box className="size-6 text-muted-foreground" />
Sandboxes
</CardTitle>
<CardDescription>
Ephemeral containers for agents and automations in{" "}
{environment?.project?.name} / {environment?.name}.
</CardDescription>
</div>
<div className="flex gap-2">
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}`}
>
<Button variant="outline">
<ArrowLeft className="size-4" />
Services
</Button>
</Link>
{permissions?.service.create && (
<AddSandbox
projectId={projectId}
environmentId={environmentId}
/>
)}
</div>
</CardHeader>
<CardContent className="py-8 border-t">
<ShowSandboxes
projectId={projectId}
environmentId={environmentId}
/>
</CardContent>
</div>
</Card>
</div>
);
};
export default SandboxesPage;
SandboxesPage.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ projectId: string; environmentId: string }>,
) {
const { params, req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user) {
return { redirect: { permanent: false, destination: "/" } };
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
if (
typeof params?.projectId === "string" &&
typeof params?.environmentId === "string"
) {
try {
await helpers.environment.one.fetch({
environmentId: params.environmentId,
});
await helpers.sandbox.list.prefetch({
environmentId: params.environmentId,
});
return {
props: {
trpcState: helpers.dehydrate(),
projectId: params.projectId,
environmentId: params.environmentId,
},
};
} catch {
return { redirect: { permanent: false, destination: "/" } };
}
}
return { redirect: { permanent: false, destination: "/" } };
}