mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
feat(sandbox): sandboxes list and detail UI
This commit is contained in:
@@ -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>
|
||||
|
||||
+230
@@ -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: "/" } };
|
||||
}
|
||||
+135
@@ -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: "/" } };
|
||||
}
|
||||
Reference in New Issue
Block a user