Merge branch 'canary' into feat/session-management

This commit is contained in:
Mauricio Siu
2026-08-13 00:40:34 -06:00
438 changed files with 155695 additions and 27922 deletions
@@ -187,7 +187,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
To use a cluster feature, you need to configure at least
a registry first. Please, go to{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-foreground"
>
Settings
@@ -185,7 +185,7 @@ export const ShowImport = ({ composeId }: Props) => {
</Button>
</div>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="max-w-[50vw]">
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
@@ -199,7 +199,7 @@ export const ShowImport = ({ composeId }: Props) => {
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-y-auto pr-1">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
@@ -207,12 +207,14 @@ export const ShowImport = ({ composeId }: Props) => {
Docker Compose
</h3>
</div>
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
<div className="max-h-[45vh] overflow-auto rounded-md border">
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
</div>
<Separator />
@@ -234,7 +234,7 @@ export const HandleRedirect = ({
<FormItem>
<FormLabel>Replacement</FormLabel>
<FormControl>
<Input placeholder="http://mydomain/$${1}" {...field} />
<Input placeholder="http://mydomain/$1" {...field} />
</FormControl>
<FormMessage />
@@ -88,6 +88,7 @@ const mySchema = z.discriminatedUnion("buildType", [
z.object({
buildType: z.literal(BuildType.nixpacks),
publishDirectory: z.string().optional(),
isStaticSpa: z.boolean().default(false),
}),
z.object({
buildType: z.literal(BuildType.railpack),
@@ -138,6 +139,7 @@ const resetData = (data: ApplicationData): AddTemplate => {
return {
buildType: BuildType.nixpacks,
publishDirectory: data.publishDirectory || undefined,
isStaticSpa: data.isStaticSpa ?? false,
};
case BuildType.paketo_buildpacks:
return {
@@ -179,6 +181,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const buildType = form.watch("buildType");
const railpackVersion = form.watch("railpackVersion");
const publishDirectory = form.watch("publishDirectory");
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
useEffect(() => {
@@ -224,7 +227,10 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
? data.herokuVersion
: null,
isStaticSpa:
data.buildType === BuildType.static ? data.isStaticSpa : null,
data.buildType === BuildType.static ||
data.buildType === BuildType.nixpacks
? data.isStaticSpa
: null,
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.15.4"
@@ -419,6 +425,30 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
)}
/>
)}
{buildType === BuildType.nixpacks && publishDirectory && (
<FormField
control={form.control}
name="isStaticSpa"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-x-2 p-2">
<Checkbox
id="checkboxIsStaticSpaNixpacks"
value={String(field.value)}
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel htmlFor="checkboxIsStaticSpaNixpacks">
Single Page Application (SPA)
</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{buildType === BuildType.static && (
<FormField
control={form.control}
@@ -63,6 +63,9 @@ export const ShowDeployments = ({
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const [removingDeploymentIds, setRemovingDeploymentIds] = useState<
Set<string>
>(new Set());
const { data: deployments, isPending: isLoadingDeployments } =
api.deployment.allByType.useQuery(
{
@@ -81,7 +84,7 @@ export const ShowDeployments = ({
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isPending: isKillingProcess } =
api.deployment.killProcess.useMutation();
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
const { mutateAsync: removeDeployment } =
api.deployment.removeDeployment.useMutation();
// Cancel deployment mutations
@@ -408,6 +411,11 @@ export const ShowDeployments = ({
description="Are you sure you want to delete this deployment? This action cannot be undone."
type="default"
onClick={async () => {
setRemovingDeploymentIds((deploymentIds) => {
const nextDeploymentIds = new Set(deploymentIds);
nextDeploymentIds.add(deployment.deploymentId);
return nextDeploymentIds;
});
try {
await removeDeployment({
deploymentId: deployment.deploymentId,
@@ -415,13 +423,25 @@ export const ShowDeployments = ({
toast.success("Deployment deleted successfully");
} catch (error) {
toast.error("Error deleting deployment");
} finally {
setRemovingDeploymentIds((deploymentIds) => {
const nextDeploymentIds = new Set(
deploymentIds,
);
nextDeploymentIds.delete(
deployment.deploymentId,
);
return nextDeploymentIds;
});
}
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isRemovingDeployment}
isLoading={removingDeploymentIds.has(
deployment.deploymentId,
)}
>
Delete
<Trash2 className="size-4" />
@@ -14,6 +14,7 @@ import Link from "next/link";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
@@ -35,7 +36,9 @@ interface ColumnsProps {
validationStates: ValidationStates;
handleValidateDomain: (host: string) => Promise<void>;
handleDeleteDomain: (domainId: string) => Promise<void>;
handleToggleEnable: (domainId: string) => Promise<void>;
isDeleting: boolean;
isToggling: boolean;
serverIp?: string;
canCreateDomain: boolean;
canDeleteDomain: boolean;
@@ -47,7 +50,9 @@ export const createColumns = ({
validationStates,
handleValidateDomain,
handleDeleteDomain,
handleToggleEnable,
isDeleting,
isToggling,
serverIp,
canCreateDomain,
canDeleteDomain,
@@ -209,7 +214,9 @@ export const createColumns = ({
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
{validationState?.isValid && validationState?.message ? (
<p>{validationState.message}</p>
) : validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">Error:</p>
<p>{validationState.error}</p>
@@ -247,6 +254,42 @@ export const createColumns = ({
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => {
const domain = row.original;
if (!canCreateDomain) {
return (
<Badge variant={domain.enabled ? "outline" : "secondary"}>
{domain.enabled ? "Enabled" : "Disabled"}
</Badge>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Switch
checked={domain.enabled}
onCheckedChange={() => handleToggleEnable(domain.domainId)}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{domain.enabled
? "Domain is active. Toggle to disable routing without deleting it."
: "Domain is disabled and not routed. Toggle to enable it again."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
id: "actions",
header: "Actions",
@@ -46,6 +46,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
export type CacheType = "fetch" | "cache";
@@ -300,7 +301,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
})
.then(async () => {
toast.success(dictionary.success);
toast.success(
dictionary.success,
data.domainType === "compose"
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
if (data.domainType === "application") {
await utils.domain.byApplicationId.invalidate({
@@ -337,12 +343,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{type === "compose" && (
<AlertBlock type="info" className="mb-4">
Whenever you make changes to domains, remember to redeploy your
compose to apply the changes.
</AlertBlock>
)}
{type === "compose" && <ComposeRedeployAlert className="mb-4" />}
<Form {...form}>
<form
@@ -0,0 +1,19 @@
import { AlertBlock } from "@/components/shared/alert-block";
/**
* Domains attached to a compose service are rendered as docker labels and only
* take effect on the next deployment. These strings keep the "redeploy required"
* wording consistent across the add/edit dialog, the domains list and the
* toasts shown after create/update/delete/toggle operations.
*/
export const COMPOSE_REDEPLOY_HINT =
"Whenever you make changes to domains, remember to redeploy your compose to apply the changes.";
export const COMPOSE_REDEPLOY_TOAST =
"Redeploy the compose to apply the changes.";
export const ComposeRedeployAlert = ({ className }: { className?: string }) => (
<AlertBlock type="info" className={className}>
{COMPOSE_REDEPLOY_HINT}
</AlertBlock>
);
@@ -44,6 +44,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
@@ -58,11 +59,13 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import { HandleForwardAuth } from "./handle-forward-auth";
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
export type ValidationState = {
isLoading: boolean;
@@ -146,12 +149,34 @@ export const ShowDomains = ({ id, type }: Props) => {
api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation();
const { mutateAsync: toggleEnable, isPending: isToggling } =
api.domain.toggleEnable.useMutation();
const handleToggleEnable = async (domainId: string) => {
try {
const result = await toggleEnable({ domainId });
refetch();
toast.success(
result.enabled ? "Domain enabled" : "Domain disabled",
result.requiresRedeploy
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
} catch {
toast.error("Error updating the domain");
}
};
const handleDeleteDomain = async (domainId: string) => {
try {
await deleteDomain({ domainId });
refetch();
toast.success("Domain deleted successfully");
toast.success(
"Domain deleted successfully",
type === "compose"
? { description: COMPOSE_REDEPLOY_TOAST }
: undefined,
);
} catch {
toast.error("Error deleting domain");
}
@@ -200,7 +225,9 @@ export const ShowDomains = ({ id, type }: Props) => {
validationStates,
handleValidateDomain,
handleDeleteDomain,
handleToggleEnable,
isDeleting: isRemoving,
isToggling,
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
canCreateDomain,
canDeleteDomain,
@@ -265,6 +292,11 @@ export const ShowDomains = ({ id, type }: Props) => {
)}
</div>
</CardHeader>
{type === "compose" && data && data.length > 0 && (
<div className="px-6 pb-4">
<ComposeRedeployAlert />
</div>
)}
<CardContent className="flex w-full flex-row gap-4">
{isLoadingDomains ? (
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
@@ -413,7 +445,10 @@ export const ShowDomains = ({ id, type }: Props) => {
return (
<Card
key={item.domainId}
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
className={cn(
"relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit",
!item.enabled && "opacity-60",
)}
>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
@@ -466,18 +501,7 @@ export const ShowDomains = ({ id, type }: Props) => {
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
await handleDeleteDomain(item.domainId);
}}
>
<Button
@@ -492,15 +516,41 @@ export const ShowDomains = ({ id, type }: Props) => {
)}
</div>
</div>
<div className="w-full break-all">
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4 min-w-4" />
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="w-full break-all">
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4 min-w-4" />
</Link>
</div>
{canCreateDomain && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center shrink-0">
<Switch
checked={item.enabled}
onCheckedChange={() =>
handleToggleEnable(item.domainId)
}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{item.enabled
? "Domain is active. Toggle to disable routing without deleting it."
: "Domain is disabled and not routed. Toggle to enable it again."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{/* Domain Details */}
@@ -626,7 +676,10 @@ export const ShowDomains = ({ id, type }: Props) => {
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
{validationState?.isValid &&
validationState?.message ? (
<p>{validationState.message}</p>
) : validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">
Error:
@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -16,16 +17,20 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Toggle } from "@/components/ui/toggle";
import { api } from "@/utils/api";
import type { ServiceType } from "../advanced/show-resources";
const addEnvironmentSchema = z.object({
environment: z.string(),
createEnvFile: z.boolean(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -54,6 +59,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const [isEnvVisible, setIsEnvVisible] = useState(true);
const completionSource = useEnvCompletionSource({
projectEnv: data?.environment?.project?.env,
environmentEnv: data?.environment?.env,
projectId: data?.environment?.projectId,
environmentId: data?.environment?.environmentId,
});
const mutationMap = {
compose: () => api.compose.saveEnvironment.useMutation(),
@@ -71,18 +82,32 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const form = useForm<EnvironmentSchema>({
defaultValues: {
environment: "",
createEnvFile: true,
},
resolver: zodResolver(addEnvironmentSchema),
});
// Watch form value
const currentEnvironment = form.watch("environment");
const hasChanges = currentEnvironment !== (data?.env || "");
const currentCreateEnvFile = form.watch("createEnvFile");
const composeData =
type === "compose"
? (data as { createEnvFile?: boolean; sourceType?: string } | undefined)
: undefined;
const showCreateEnvFileToggle =
type === "compose" &&
(composeData?.sourceType !== "raw" || composeData?.createEnvFile === false);
const hasChanges =
currentEnvironment !== (data?.env || "") ||
(showCreateEnvFileToggle &&
currentCreateEnvFile !== (composeData?.createEnvFile ?? true));
useEffect(() => {
if (data) {
form.reset({
environment: data.env || "",
createEnvFile: composeData?.createEnvFile ?? true,
});
}
}, [data, form]);
@@ -97,6 +122,9 @@ export const ShowEnvironment = ({ id, type }: Props) => {
postgresId: id || "",
redisId: id || "",
env: formData.environment,
...(type === "compose" && {
createEnvFile: formData.createEnvFile,
}),
})
.then(async () => {
toast.success("Environments Added");
@@ -110,6 +138,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const handleCancel = () => {
form.reset({
environment: data?.env || "",
createEnvFile: composeData?.createEnvFile ?? true,
});
};
@@ -176,6 +205,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
} as CSSProperties
}
language="properties"
completionSource={completionSource}
disabled={isEnvVisible}
className="font-mono"
wrapperClassName="compose-file-editor"
@@ -190,6 +220,34 @@ PORT=3000
)}
/>
{showCreateEnvFileToggle && (
<FormField
control={form.control}
name="createEnvFile"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-xs">
<div className="space-y-0.5">
<FormLabel>Create Environment File</FormLabel>
<FormDescription>
When enabled, an .env file will be created in the same
directory as your compose file on every deploy.
Disable this to keep a repository-provided .env; the
variables above will then be ignored. Takes effect on
the next deploy.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canWrite}
/>
</FormControl>
</FormItem>
)}
/>
)}
{canWrite && (
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
@@ -3,6 +3,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
@@ -45,6 +46,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
projectEnv: data?.environment?.project?.env,
environmentEnv: data?.environment?.env,
projectId: data?.environment?.projectId,
environmentId: data?.environment?.environmentId,
});
const form = useForm<EnvironmentSchema>({
defaultValues: {
env: "",
@@ -60,14 +68,16 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
const currentCreateEnvFile = form.watch("createEnvFile");
const { isDirty } = form.formState;
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "") ||
currentBuildSecrets !== (data?.buildSecrets || "") ||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
// Skip reset while editing so background refetches don't wipe edits
useEffect(() => {
if (data) {
if (data && !isDirty) {
form.reset({
env: data.env || "",
buildArgs: data.buildArgs || "",
@@ -75,7 +85,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
createEnvFile: data.createEnvFile ?? true,
});
}
}, [data, form]);
}, [data, isDirty, form]);
const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
@@ -87,6 +97,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
})
.then(async () => {
toast.success("Environments Added");
form.reset(formData);
await refetch();
})
.catch(() => {
@@ -139,6 +150,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
completionSource={completionSource}
/>
{data?.buildType === "dockerfile" && (
<Secrets
@@ -160,6 +172,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder="NPM_TOKEN=xyz"
completionSource={completionSource}
/>
)}
{data?.buildType === "dockerfile" && (
@@ -182,6 +195,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</span>
}
placeholder="NPM_TOKEN=xyz"
completionSource={completionSource}
/>
)}
{data?.buildType === "dockerfile" && (
@@ -447,14 +447,18 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -502,14 +506,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -242,14 +242,18 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -298,14 +302,14 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -477,14 +477,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@@ -531,14 +535,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -47,6 +47,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
const GithubProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
@@ -96,6 +97,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
// Enterprise repositories do not live on github.com.
const providerUrl =
githubProviders?.find((provider) => provider.githubId === githubId)
?.githubUrl ?? DEFAULT_GITHUB_URL;
const triggerType = form.watch("triggerType");
const { data: repositories, isPending: isLoadingRepositories } =
@@ -227,7 +233,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -438,8 +444,12 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
</TooltipProvider>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
}}
value={field.value}
>
<FormControl>
@@ -487,14 +497,18 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@@ -543,14 +557,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -467,14 +467,18 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
>
<X className="size-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
</div>
@@ -521,14 +525,14 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -271,6 +271,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
serviceId={applicationId}
>
<Button
variant="outline"
@@ -1,5 +1,5 @@
import DOMPurify from "dompurify";
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -16,7 +16,8 @@ import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
interface ShowIconSettingsProps {
applicationId: string;
serviceId: string;
serviceType: "application" | "compose";
icon?: string | null;
}
@@ -26,7 +27,8 @@ const svgToDataUrl = (icon: BundledIcon): string => {
};
export const ShowIconSettings = ({
applicationId,
serviceId,
serviceType,
icon,
}: ShowIconSettingsProps) => {
const [open, setOpen] = useState(false);
@@ -48,6 +50,17 @@ export const ShowIconSettings = ({
const utils = api.useUtils();
const { mutateAsync: updateApplication } =
api.application.update.useMutation();
const { mutateAsync: updateCompose } = api.compose.update.useMutation();
const updateIcon = async (newIcon: string | null) => {
if (serviceType === "compose") {
await updateCompose({ composeId: serviceId, icon: newIcon });
await utils.compose.one.invalidate({ composeId: serviceId });
} else {
await updateApplication({ applicationId: serviceId, icon: newIcon });
await utils.application.one.invalidate({ applicationId: serviceId });
}
};
useEffect(() => {
if (open) {
@@ -59,12 +72,8 @@ export const ShowIconSettings = ({
const handleIconSelect = async (selectedIcon: BundledIcon) => {
try {
const dataUrl = svgToDataUrl(selectedIcon);
await updateApplication({
applicationId,
icon: dataUrl,
});
await updateIcon(dataUrl);
toast.success("Icon saved successfully");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -73,12 +82,8 @@ export const ShowIconSettings = ({
const handleRemoveIcon = async () => {
try {
await updateApplication({
applicationId,
icon: null,
});
await updateIcon(null);
toast.success("Icon removed");
await utils.application.one.invalidate({ applicationId });
} catch (_error) {
toast.error("Error removing icon");
}
@@ -130,12 +135,8 @@ export const ShowIconSettings = ({
return;
}
try {
await updateApplication({
applicationId,
icon: sanitizedDataUrl,
});
await updateIcon(sanitizedDataUrl);
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -147,12 +148,8 @@ export const ShowIconSettings = ({
reader.onload = async (event) => {
const result = event.target?.result as string;
try {
await updateApplication({
applicationId,
icon: result,
});
await updateIcon(result);
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -172,9 +169,11 @@ export const ShowIconSettings = ({
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
<img
src={icon}
alt="Application icon"
alt="Service icon"
className="h-8 w-8 object-contain"
/>
) : serviceType === "compose" ? (
<CircuitBoard className="h-6 w-6 text-muted-foreground" />
) : (
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
)}
@@ -50,9 +50,10 @@ export const badgeStateColor = (state: string) => {
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogs = ({ appName, serverId }: Props) => {
export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native");
@@ -182,6 +183,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -58,8 +58,12 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
const handleRunManually = async (scheduleId: string) => {
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
try {
await runManually({ scheduleId });
toast.success("Schedule run successfully");
const result = await runManually({ scheduleId });
if (result.status === "error") {
toast.error("Schedule run failed, check the deployment logs");
} else {
toast.success("Schedule run successfully");
}
await refetchSchedules();
} catch {
toast.error("Error running schedule");
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -111,7 +112,10 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Enable Isolated Deployment</CardTitle>
<CardTitle className="text-xl flex items-center gap-2">
Enable Isolated Deployment
<Badge variant="yellow">Deprecated</Badge>
</CardTitle>
<CardDescription>
Configure isolated deployment to the compose file.
<div className="text-sm text-muted-foreground flex flex-col gap-2">
@@ -138,6 +142,11 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
</CardHeader>
<CardContent>
<div className="space-y-4">
<AlertBlock type="warning">
Isolated deployment is deprecated. Use the Networks section above to
attach networks per service and detach them from dokploy-network —
it is declarative and does not break on restarts.
</AlertBlock>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
@@ -55,12 +55,14 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
@@ -112,6 +114,7 @@ export const ShowComposeContainers = ({
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
{appType === "stack" && <TableHead>Node</TableHead>}
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
@@ -119,9 +122,11 @@ export const ShowComposeContainers = ({
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
key={container.name}
container={container}
appType={appType}
serverId={serverId}
serviceId={serviceId}
onActionComplete={() => refetch()}
/>
))}
@@ -140,14 +145,19 @@ interface ContainerRowProps {
name: string;
state: string;
status: string;
node?: string;
};
appType: "stack" | "docker-compose";
serverId?: string;
serviceId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
appType,
serverId,
serviceId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
@@ -187,7 +197,13 @@ const ContainerRow = ({
variant={
container.state === "running"
? "default"
: container.state === "exited"
: [
"exited",
"pending",
"preparing",
"starting",
"ready",
].includes(container.state)
? "secondary"
: "destructive"
}
@@ -196,94 +212,99 @@ const ContainerRow = ({
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
{appType === "stack" && <TableCell>{container.node || "-"}</TableCell>}
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
{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>
{!container.containerId ? null : (
<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>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
serviceId={serviceId}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
View Logs
Restart
</DropdownMenuItem>
</DialogTrigger>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
>
Terminal
</DockerTerminalModal>
<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>
<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"
serviceId={serviceId}
/>
</div>
</DialogContent>
</Dialog>
)}
</TableCell>
</TableRow>
);
@@ -84,19 +84,19 @@ export const ComposeActions = ({ composeId }: Props) => {
)}
{canDeploy && (
<DialogAction
title="Reload Compose"
description="Are you sure you want to reload this compose?"
title="Rebuild Compose"
description="Are you sure you want to rebuild this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose reloaded successfully");
toast.success("Compose rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading compose");
toast.error("Error rebuilding compose");
});
}}
>
@@ -109,12 +109,14 @@ export const ComposeActions = ({ composeId }: Props) => {
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-60">
<p>Reload the compose without rebuilding it</p>
<p>
Rebuilds the compose without downloading the source code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
@@ -206,6 +208,7 @@ export const ComposeActions = ({ composeId }: Props) => {
appName={data?.appName || ""}
serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
>
<Button
variant="outline"
@@ -451,14 +451,18 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -506,14 +510,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -251,14 +251,18 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -306,14 +310,14 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -442,14 +442,18 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -497,14 +501,14 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -47,6 +47,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
const GithubProviderSchema = z.object({
composePath: z.string().min(1),
@@ -98,6 +99,11 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
// Enterprise repositories do not live on github.com.
const providerUrl =
githubProviders?.find((provider) => provider.githubId === githubId)
?.githubUrl ?? DEFAULT_GITHUB_URL;
const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
{
@@ -220,7 +226,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -432,8 +438,12 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
</TooltipProvider>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
}}
value={field.value}
>
<FormControl>
@@ -479,14 +489,18 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -538,14 +552,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -468,14 +468,18 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
<button
type="button"
aria-label="Remove watch path"
className="inline-flex items-center focus-visible:ring-2"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
>
<X className="ml-1 size-3 cursor-pointer" />
</button>
</Badge>
))}
</div>
@@ -523,14 +527,14 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="mt-0!">Enable Submodules</FormLabel>
<FormLabel>Enable Submodules</FormLabel>
</FormItem>
)}
/>
@@ -52,7 +52,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview Compose
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-200">
<DialogContent className="sm:max-w-6xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Converted Compose</DialogTitle>
<DialogDescription>
@@ -62,10 +62,6 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<AlertBlock type="info" className="mb-4">
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</AlertBlock>
{isPending ? (
<div className="flex flex-row items-center justify-center min-h-100 border p-4 rounded-md">
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
@@ -79,7 +75,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</div>
) : (
<>
<div className="flex flex-row gap-2 justify-end my-4">
<div className="flex flex-row gap-2 justify-end">
<Button
variant="secondary"
isLoading={isPending}
@@ -100,14 +96,15 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</Button>
</div>
<pre>
<div className="flex-1 min-h-0 overflow-auto rounded-md border">
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
height="100%"
wrapperClassName="h-full"
/>
</pre>
</div>
</>
)}
</DialogContent>
@@ -35,9 +35,14 @@ export const DockerLogs = dynamic(
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
export const ShowDockerLogsStack = ({
appName,
serverId,
serviceId,
}: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();
@@ -52,7 +57,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
},
);
const { data: containers, isPending: containersLoading } =
const { data, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
@@ -64,6 +69,8 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
},
);
const containers = data?.filter((container) => container.containerId);
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
@@ -167,6 +174,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -35,12 +35,14 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowDockerLogsCompose = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -104,6 +106,7 @@ export const ShowDockerLogsCompose = ({
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType="native"
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -316,7 +316,7 @@ export const RestoreBackup = ({
Restore Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center">
<RotateCcw className="mr-2 size-4" />
@@ -14,10 +14,11 @@ import {
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Boxes,
ChevronLeft,
ChevronRight,
CircuitBoard,
ExternalLink,
GlobeIcon,
Loader2,
Rocket,
Server,
@@ -71,6 +72,7 @@ function getServiceInfo(d: DeploymentRow) {
return {
type: "Application" as const,
name: app.name,
icon: app.icon,
projectId: app.environment.project.projectId,
environmentId: app.environment.environmentId,
projectName: app.environment.project.name,
@@ -83,6 +85,7 @@ function getServiceInfo(d: DeploymentRow) {
return {
type: "Compose" as const,
name: comp.name,
icon: comp.icon,
projectId: comp.environment.project.projectId,
environmentId: comp.environment.environmentId,
projectName: comp.environment.project.name,
@@ -175,10 +178,16 @@ export function ShowDeploymentsTable() {
if (!info) return <span className="text-muted-foreground">—</span>;
return (
<div className="flex items-center gap-2">
{info.type === "Application" ? (
<Rocket className="size-4 text-muted-foreground shrink-0" />
{info.icon ? (
<img
src={info.icon}
alt={info.name}
className="size-4 object-contain shrink-0"
/>
) : info.type === "Application" ? (
<GlobeIcon className="size-4 text-muted-foreground shrink-0" />
) : (
<Boxes className="size-4 text-muted-foreground shrink-0" />
<CircuitBoard className="size-4 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{info.name}</span>
@@ -0,0 +1,428 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Boxes,
Database,
Gauge,
HardDrive,
Layers,
Loader2,
type LucideIcon,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type DiskUsageItem =
inferRouterOutputs<AppRouter>["dockerDiskUsage"]["getDiskUsage"][number];
type BuildCacheBase =
inferRouterOutputs<AppRouter>["dockerDiskUsage"]["getBuildCache"][number];
type BuildCacheRow = BuildCacheBase & { key: string };
interface Props {
serverId?: string;
}
const STAT_CARDS: { type: string; title: string; icon: LucideIcon }[] = [
{ type: "Images", title: "Images", icon: Layers },
{ type: "Containers", title: "Containers", icon: Boxes },
{ type: "Local Volumes", title: "Volumes", icon: HardDrive },
{ type: "Build Cache", title: "Build Cache", icon: Database },
];
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
const StatCard = ({
icon: Icon,
title,
item,
isLoading,
}: {
icon: LucideIcon;
title: string;
item?: DiskUsageItem;
isLoading: boolean;
}) => (
<Card className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Icon className="size-4" />
{title}
</div>
{isLoading ? (
<div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
) : (
<>
<div className="mt-2 text-2xl font-semibold">{item?.size ?? "-"}</div>
<div className="mt-1 flex flex-wrap gap-x-3 text-xs text-muted-foreground">
<span>{item?.totalCount ?? 0} total</span>
<span>{item?.active ?? 0} active</span>
<span>{item?.reclaimable ?? "-"} reclaimable</span>
</div>
</>
)}
</Card>
);
export const ShowDiskUsage = ({ serverId }: Props) => {
const utils = api.useUtils();
const [sorting, setSorting] = useState<SortingState>([
{ id: "Size", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: diskUsage, isLoading: isLoadingDiskUsage } =
api.dockerDiskUsage.getDiskUsage.useQuery({ serverId });
const { data: buildCache, isLoading: isLoadingBuildCache } =
api.dockerDiskUsage.getBuildCache.useQuery({ serverId });
const { mutateAsync: pruneBuildCache, isPending: isPruning } =
api.dockerDiskUsage.pruneBuildCache.useMutation();
const usageByType = useMemo(
() => new Map((diskUsage ?? []).map((item) => [item.type, item])),
[diskUsage],
);
const rows = useMemo<BuildCacheRow[]>(
() =>
(buildCache ?? []).map((entry) => ({
...entry,
key: entry.id,
})),
[buildCache],
);
const filteredData = useMemo(() => {
if (!globalFilter.trim()) {
return rows;
}
const query = globalFilter.toLowerCase();
return rows.filter(
(entry) =>
entry.id.toLowerCase().includes(query) ||
entry.description.toLowerCase().includes(query) ||
entry.type.toLowerCase().includes(query),
);
}, [rows, globalFilter]);
const handlePrune = async () => {
try {
await pruneBuildCache({ serverId });
toast.success("Build cache pruned");
await Promise.all([
utils.dockerDiskUsage.getBuildCache.invalidate(),
utils.dockerDiskUsage.getDiskUsage.invalidate(),
]);
} catch (error) {
toast.error("Error pruning build cache", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const columns = useMemo<ColumnDef<BuildCacheRow>[]>(
() => [
{
accessorKey: "id",
header: ({ column }) => (
<SortableHeader column={column} title="Cache ID" />
),
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.id.slice(0, 16)}
</span>
),
},
{
accessorKey: "type",
header: ({ column }) => <SortableHeader column={column} title="Type" />,
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
},
{
accessorKey: "description",
header: "Description",
enableSorting: false,
cell: ({ row }) => (
<div
className="max-w-[360px] truncate text-xs text-muted-foreground"
title={row.original.description}
>
{row.original.description || "-"}
</div>
),
},
{
id: "Size",
accessorFn: (entry) => entry.sizeBytes,
header: ({ column }) => <SortableHeader column={column} title="Size" />,
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.size}</span>
),
},
{
id: "created",
accessorKey: "createdSince",
header: "Created",
enableSorting: false,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{row.original.createdSince}
</span>
),
},
{
id: "lastUsed",
accessorKey: "lastUsedSince",
header: "Last Used",
enableSorting: false,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{row.original.lastUsedSince}
</span>
),
},
{
accessorKey: "usageCount",
header: ({ column }) => (
<SortableHeader column={column} title="Usage" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.usageCount}
</span>
),
},
{
id: "flags",
enableSorting: false,
header: "Flags",
cell: ({ row }) => (
<div className="flex gap-1">
{row.original.inUse && <Badge variant="blue">In use</Badge>}
{row.original.shared && <Badge variant="outline">Shared</Badge>}
</div>
),
},
],
[],
);
const table = useReactTable({
data: filteredData,
columns,
getRowId: (row) => row.key,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{STAT_CARDS.map((stat) => (
<StatCard
key={stat.type}
icon={stat.icon}
title={stat.title}
item={usageByType.get(stat.type)}
isLoading={isLoadingDiskUsage}
/>
))}
</div>
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<Gauge className="size-6 text-muted-foreground self-center" />
Build Cache
</CardTitle>
<CardDescription>
Cache layers kept by the Docker builder on the selected
server.
</CardDescription>
</div>
<DialogAction
title="Prune build cache"
description="This will remove all build cache entries that are not currently in use. This action cannot be undone."
onClick={handlePrune}
disabled={isPruning}
>
<Button variant="outline" size="sm" disabled={isPruning}>
<Trash2 className="size-4 mr-1" />
Prune build cache
</Button>
</DialogAction>
</div>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoadingBuildCache ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !buildCache?.length ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<Database className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No build cache found</p>
<p className="max-w-sm text-sm text-muted-foreground">
Layers cached by "docker build" on this server will appear
here.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by id, type or description..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No build cache entries match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,425 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Activity, ArrowUpDown, Loader2, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api, type RouterOutputs } from "@/utils/api";
interface Props {
serverId?: string;
}
type DockerEvent = RouterOutputs["docker"]["getEvents"]["events"][number];
type EventRow = DockerEvent & { key: string };
const RANGE_OPTIONS = [
{ label: "Last 5 minutes", value: 5 },
{ label: "Last 15 minutes", value: 15 },
{ label: "Last hour", value: 60 },
{ label: "Last 6 hours", value: 360 },
{ label: "Last 24 hours", value: 1440 },
];
type BadgeVariant = "blue" | "green" | "yellow" | "orange" | "red" | "blank";
const TYPE_VARIANTS: Record<string, BadgeVariant> = {
container: "blue",
image: "green",
volume: "yellow",
network: "orange",
service: "blue",
node: "orange",
};
const ACTION_VARIANTS: Record<string, BadgeVariant> = {
create: "green",
start: "green",
pull: "green",
connect: "green",
die: "red",
destroy: "red",
kill: "red",
stop: "red",
remove: "red",
disconnect: "red",
pause: "yellow",
unpause: "yellow",
};
const getTypeVariant = (type?: string): BadgeVariant =>
(type && TYPE_VARIANTS[type]) || "blank";
const getActionVariant = (action?: string): BadgeVariant =>
(action && ACTION_VARIANTS[action]) || "blank";
const getResource = (event: DockerEvent) =>
event.Actor?.Attributes?.name ?? event.Actor?.ID ?? "-";
const getAttributesText = (event: DockerEvent) => {
const attributes = Object.entries(event.Actor?.Attributes ?? {}).filter(
([key]) => key !== "name",
);
return attributes.map(([key, value]) => `${key}=${value}`).join(" ") || "-";
};
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
export const ShowDockerEvents = ({ serverId }: Props) => {
const [minutes, setMinutes] = useState(15);
const [search, setSearch] = useState("");
const [sorting, setSorting] = useState<SortingState>([
{ id: "time", desc: true },
]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 15,
});
const { data, isLoading, isRefetching, refetch, error } =
api.docker.getEvents.useQuery({
serverId,
minutes,
});
const events = useMemo<EventRow[]>(
() =>
(data?.events ?? []).map((event, index) => ({
...event,
key: `${event.time}-${event.Action}-${index}`,
})),
[data],
);
const filteredEvents = useMemo(() => {
if (!search.trim()) return events;
const query = search.toLowerCase();
return events.filter((event) => {
return (
event.Type?.toLowerCase().includes(query) ||
event.Action?.toLowerCase().includes(query) ||
event.Actor?.Attributes?.name?.toLowerCase().includes(query) ||
event.Actor?.ID?.toLowerCase().includes(query)
);
});
}, [events, search]);
const columns = useMemo<ColumnDef<EventRow>[]>(
() => [
{
id: "time",
accessorFn: (event) => event.time ?? 0,
header: ({ column }) => <SortableHeader column={column} title="Time" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{row.original.time
? new Date(row.original.time * 1000).toLocaleTimeString()
: "-"}
</span>
),
},
{
id: "type",
accessorFn: (event) => event.Type ?? "",
header: ({ column }) => <SortableHeader column={column} title="Type" />,
cell: ({ row }) => (
<Badge variant={getTypeVariant(row.original.Type)}>
{row.original.Type ?? "unknown"}
</Badge>
),
},
{
id: "action",
accessorFn: (event) => event.Action ?? "",
header: ({ column }) => (
<SortableHeader column={column} title="Action" />
),
cell: ({ row }) => (
<Badge variant={getActionVariant(row.original.Action)}>
{row.original.Action ?? "-"}
</Badge>
),
},
{
id: "resource",
accessorFn: (event) => getResource(event),
header: ({ column }) => (
<SortableHeader column={column} title="Resource" />
),
cell: ({ row }) => {
const resource = getResource(row.original);
return (
<div
className="max-w-[220px] truncate font-mono text-xs"
title={resource}
>
{resource}
</div>
);
},
},
{
id: "attributes",
accessorFn: (event) => getAttributesText(event),
header: "Attributes",
enableSorting: false,
cell: ({ row }) => {
const attributesText = getAttributesText(row.original);
return (
<div
className="max-w-[360px] truncate text-xs text-muted-foreground"
title={attributesText}
>
{attributesText}
</div>
);
},
},
],
[],
);
const table = useReactTable({
data: filteredEvents,
columns,
getRowId: (row) => row.key,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<Activity className="size-6 text-muted-foreground self-center" />
Docker Events
</CardTitle>
<CardDescription>
Events reported by the Docker daemon, equivalent to running
"docker events".
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`size-4 mr-1 ${isRefetching ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{error && (
<p className="text-sm text-destructive">{error.message}</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Filter by type, action or name..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
className="max-w-xs"
/>
<Select
value={String(minutes)}
onValueChange={(value) => setMinutes(Number(value))}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{RANGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
<div className="flex flex-row items-center justify-center gap-2">
<span>Loading events...</span>
<Loader2 className="size-4 animate-spin" />
</div>
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No events found in the selected time range.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Go to</span>
<Input
type="number"
min={1}
max={table.getPageCount()}
defaultValue={table.getState().pagination.pageIndex + 1}
key={table.getState().pagination.pageIndex}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
const value = Number(
(e.target as HTMLInputElement).value,
);
if (!Number.isFinite(value)) return;
const page = Math.min(
Math.max(value, 1),
table.getPageCount(),
);
table.setPageIndex(page - 1);
}}
onBlur={(e) => {
const value = Number(e.target.value);
if (!Number.isFinite(value)) return;
const page = Math.min(
Math.max(value, 1),
table.getPageCount(),
);
table.setPageIndex(page - 1);
}}
className="w-16 h-8"
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,401 @@
import {
ChevronRight,
Download,
File,
Folder,
FolderOpen,
Loader2,
RefreshCw,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
type Props = {
serverId?: string;
children?: React.ReactNode;
asDropdownItem?: boolean;
} & (
| { containerId: string; volumeName?: undefined }
| { volumeName: string; containerId?: undefined }
);
const joinPath = (base: string, name: string) =>
base === "/" ? `/${name}` : `${base}/${name}`;
const decodeBase64 = (base64: string) => {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
export const FilesExplorerModal = ({
containerId,
volumeName,
serverId,
children,
asDropdownItem = true,
}: Props) => {
const [open, setOpen] = useState(false);
const [path, setPath] = useState("/");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [editorContent, setEditorContent] = useState("");
const isVolume = !!volumeName;
const utils = api.useUtils();
const containerEntries = api.docker.listContainerFiles.useQuery(
{ containerId: containerId ?? "", path, serverId },
{ enabled: open && !isVolume, retry: false },
);
const volumeEntries = api.dockerVolume.listVolumeFiles.useQuery(
{ volumeName: volumeName ?? "", path, serverId },
{ enabled: open && isVolume, retry: false },
);
const {
data: entries,
isLoading: isLoadingEntries,
error: entriesError,
refetch: refetchEntries,
isRefetching,
} = isVolume ? volumeEntries : containerEntries;
const containerFile = api.docker.readContainerFile.useQuery(
{ containerId: containerId ?? "", path: selectedFile ?? "/", serverId },
{ enabled: open && !isVolume && !!selectedFile, retry: false },
);
const volumeFile = api.dockerVolume.readVolumeFile.useQuery(
{ volumeName: volumeName ?? "", path: selectedFile ?? "/", serverId },
{ enabled: open && isVolume && !!selectedFile, retry: false },
);
const {
data: file,
isLoading: isLoadingFile,
error: fileError,
refetch: refetchFile,
} = isVolume ? volumeFile : containerFile;
const fileBytes = useMemo(
() => (file ? decodeBase64(file.content) : null),
[file],
);
const isBinary = useMemo(
() => !!fileBytes?.some((byte) => byte === 0),
[fileBytes],
);
useEffect(() => {
if (fileBytes && !isBinary) {
setEditorContent(new TextDecoder().decode(fileBytes));
}
}, [fileBytes, isBinary]);
const writeContainerFile = api.docker.writeContainerFile.useMutation();
const writeVolumeFile = api.dockerVolume.writeVolumeFile.useMutation();
const deleteContainerFile = api.docker.deleteContainerFile.useMutation();
const deleteVolumeFile = api.dockerVolume.deleteVolumeFile.useMutation();
const isSaving = writeContainerFile.isPending || writeVolumeFile.isPending;
const isDeleting =
deleteContainerFile.isPending || deleteVolumeFile.isPending;
const saveFile = async () => {
if (!selectedFile) return;
try {
if (isVolume) {
await writeVolumeFile.mutateAsync({
volumeName,
path: selectedFile,
content: editorContent,
serverId,
});
} else {
await writeContainerFile.mutateAsync({
containerId: containerId ?? "",
path: selectedFile,
content: editorContent,
serverId,
});
}
toast.success("File saved");
refetchFile();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to save file",
);
}
};
const deleteEntry = async (entryPath: string) => {
try {
if (isVolume) {
await deleteVolumeFile.mutateAsync({
volumeName,
path: entryPath,
serverId,
});
await utils.dockerVolume.listVolumeFiles.invalidate();
} else {
await deleteContainerFile.mutateAsync({
containerId: containerId ?? "",
path: entryPath,
serverId,
});
await utils.docker.listContainerFiles.invalidate();
}
toast.success("Deleted");
if (selectedFile === entryPath) {
setSelectedFile(null);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to delete");
}
};
const breadcrumbs = path.split("/").filter(Boolean);
const navigate = (nextPath: string) => {
setPath(nextPath);
setSelectedFile(null);
};
const handleOpenChange = (value: boolean) => {
setOpen(value);
if (!value) {
setPath("/");
setSelectedFile(null);
setEditorContent("");
}
};
const downloadFile = () => {
if (!fileBytes || !selectedFile) return;
const blob = new Blob([fileBytes]);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = selectedFile.split("/").pop() ?? "file";
anchor.click();
URL.revokeObjectURL(url);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{asDropdownItem ? (
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
) : (
children
)}
</DialogTrigger>
<DialogContent className="sm:max-w-6xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="size-5" />
{isVolume ? "Volume Files" : "Container Files"}
</DialogTitle>
<DialogDescription>
{isVolume
? `Browse and edit files inside the "${volumeName}" volume`
: "Browse and edit files inside the container's filesystem"}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-1 text-sm font-mono flex-wrap">
<button
type="button"
className="hover:underline text-muted-foreground"
onClick={() => navigate("/")}
>
/
</button>
{breadcrumbs.map((segment, index) => (
<span
key={`/${breadcrumbs.slice(0, index + 1).join("/")}`}
className="flex items-center gap-1"
>
<button
type="button"
className="hover:underline text-muted-foreground"
onClick={() =>
navigate(`/${breadcrumbs.slice(0, index + 1).join("/")}`)
}
>
{segment}
</button>
{index < breadcrumbs.length - 1 && (
<ChevronRight className="size-3 text-muted-foreground" />
)}
</span>
))}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7"
onClick={() => refetchEntries()}
>
<RefreshCw
className={`size-4 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<div className="flex gap-4 min-h-[55vh] max-h-[65vh]">
<div className="w-72 shrink-0 border rounded-lg overflow-y-auto">
{isLoadingEntries ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground p-4">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : entriesError ? (
<div className="p-3">
<AlertBlock type="error">{entriesError.message}</AlertBlock>
</div>
) : (
<div className="flex flex-col p-1">
{path !== "/" && (
<button
type="button"
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted text-left"
onClick={() =>
navigate(`/${breadcrumbs.slice(0, -1).join("/")}` || "/")
}
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
..
</button>
)}
{entries?.length === 0 && (
<span className="px-2 py-1.5 text-sm text-muted-foreground">
Empty directory
</span>
)}
{entries?.map((entry) => {
const entryPath = joinPath(path, entry.name);
return (
<div
key={entry.name}
className={`group flex items-center rounded-md hover:bg-muted ${
selectedFile === entryPath ? "bg-muted" : ""
}`}
>
<button
type="button"
className="flex flex-1 items-center gap-2 px-2 py-1.5 text-sm text-left min-w-0"
onClick={() =>
entry.isDirectory
? navigate(entryPath)
: setSelectedFile(entryPath)
}
>
{entry.isDirectory ? (
<Folder className="size-4 shrink-0 text-muted-foreground" />
) : (
<File className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{entry.name}</span>
</button>
<DialogAction
title={`Delete ${entry.name}?`}
description={`This will permanently delete ${entryPath}${entry.isDirectory ? " and all its contents" : ""}.`}
onClick={() => deleteEntry(entryPath)}
>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 shrink-0"
isLoading={isDeleting}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</DialogAction>
</div>
);
})}
</div>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col gap-2">
{!selectedFile ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
Select a file to view or edit it
</div>
) : isLoadingFile ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground rounded-lg border">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : fileError ? (
<AlertBlock type="error">{fileError.message}</AlertBlock>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-mono truncate text-muted-foreground">
{selectedFile}
</span>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={downloadFile}>
<Download className="size-4" />
Download
</Button>
<Button
size="sm"
isLoading={isSaving}
disabled={isBinary || file?.truncated}
onClick={saveFile}
>
Save
</Button>
</div>
</div>
{file?.truncated ? (
<AlertBlock type="warning">
This file is larger than 512KB. Showing a truncated preview;
editing is disabled. Use Download to get the truncated
content or the terminal for full access.
</AlertBlock>
) : isBinary ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
Binary file — use Download instead
</div>
) : (
<div className="flex-1 overflow-auto rounded-lg border">
<CodeEditor
lineWrapping
value={editorContent}
onChange={(value) => setEditorContent(value)}
wrapperClassName="h-full font-mono"
/>
</div>
)}
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,470 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import { ArrowUpDown, Eye, Layers, Loader2, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { DialogAction } from "@/components/shared/dialog-action";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
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 { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type ImageBase =
inferRouterOutputs<AppRouter>["dockerImage"]["getImages"][number];
type ImageRow = ImageBase & { key: string };
interface Props {
serverId?: string;
}
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1e3,
mb: 1e6,
gb: 1e9,
tb: 1e12,
};
const parseSize = (size?: string) => {
if (!size) return -1;
const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim());
if (!match?.[1] || !match[2]) return -1;
return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1);
};
const getReference = (image: ImageBase) =>
image.Repository !== "<none>" && image.Tag !== "<none>"
? `${image.Repository}:${image.Tag}`
: image.ID;
const FORCE_HINT_REGEX =
/must be forced|image is being used|referenced in multiple repositories/i;
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
const ShowImageConfig = ({
imageRef,
serverId,
}: {
imageRef: string;
serverId?: string;
}) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.dockerImage.getImageConfig.useQuery(
{ imageRef, serverId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View image config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Image Config</DialogTitle>
<DialogDescription>
docker image inspect output for "{imageRef}"
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap wrap-break-word">
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
)}
</DialogContent>
</Dialog>
);
};
export const ShowImages = ({ serverId }: Props) => {
const utils = api.useUtils();
const [sorting, setSorting] = useState<SortingState>([
{ id: "Repository", desc: false },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [forcePrompt, setForcePrompt] = useState<{
image: ImageRow;
message: string;
} | null>(null);
const { data: images, isLoading } = api.dockerImage.getImages.useQuery({
serverId,
});
const { mutateAsync: removeImage } =
api.dockerImage.removeImage.useMutation();
const rows = useMemo<ImageRow[]>(
() =>
(images ?? []).map((image) => ({
...image,
key: `${image.ID}-${image.Repository}-${image.Tag}`,
})),
[images],
);
const filteredData = useMemo(() => {
if (!globalFilter.trim()) {
return rows;
}
const query = globalFilter.toLowerCase();
return rows.filter(
(image) =>
image.Repository.toLowerCase().includes(query) ||
image.Tag.toLowerCase().includes(query) ||
image.ID.toLowerCase().includes(query),
);
}, [rows, globalFilter]);
const handleDelete = async (image: ImageRow, force = false) => {
try {
await removeImage({
repository: image.Repository,
tag: image.Tag,
id: image.ID,
force,
serverId,
});
toast.success("Image deleted");
setForcePrompt(null);
await utils.dockerImage.getImages.invalidate();
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (!force && FORCE_HINT_REGEX.test(message)) {
setForcePrompt({ image, message });
} else {
toast.error("Error deleting image", { description: message });
}
}
};
const columns = useMemo<ColumnDef<ImageRow>[]>(
() => [
{
accessorKey: "Repository",
header: ({ column }) => (
<SortableHeader column={column} title="Repository" />
),
cell: ({ row }) => (
<div
className="max-w-[280px] truncate font-medium"
title={row.original.Repository}
>
{row.original.Repository}
</div>
),
},
{
accessorKey: "Tag",
header: ({ column }) => <SortableHeader column={column} title="Tag" />,
cell: ({ row }) => <Badge variant="outline">{row.original.Tag}</Badge>,
},
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Image ID" />
),
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.ID}
</span>
),
},
{
id: "Size",
accessorFn: (image) => parseSize(image.Size),
header: ({ column }) => <SortableHeader column={column} title="Size" />,
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.Size}</span>
),
},
{
id: "Created",
accessorFn: (image) => new Date(image.CreatedAt).getTime() || 0,
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap">
{row.original.CreatedSince}
</span>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<ShowImageConfig
imageRef={getReference(row.original)}
serverId={serverId}
/>
<DialogAction
title="Delete image"
description={`The image "${getReference(row.original)}" will be removed from Docker. This action cannot be undone.`}
onClick={() => handleDelete(row.original)}
>
<Button variant="ghost" size="icon-sm" aria-label="Delete image">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[serverId],
);
const table = useReactTable({
data: filteredData,
columns,
getRowId: (row) => row.key,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Layers className="size-6 text-muted-foreground self-center" />
Images
</CardTitle>
<CardDescription>
Manage the Docker images of the selected server.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !images?.length ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<Layers className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No images found</p>
<p className="max-w-sm text-sm text-muted-foreground">
Docker images pulled or built on this server will appear
here.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by repository, tag or id..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No images match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
<AlertDialog
open={!!forcePrompt}
onOpenChange={(open) => !open && setForcePrompt(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Image is in use</AlertDialogTitle>
<AlertDialogDescription>
{forcePrompt?.message} Do you want to force the deletion? This may
affect containers still using this image.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setForcePrompt(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() =>
forcePrompt && handleDelete(forcePrompt.image, true)
}
>
Force delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
@@ -23,6 +23,7 @@ interface Props {
containerId: string;
serverId?: string | null;
runType: "swarm" | "native";
serviceId?: string;
}
export const priorities = [
@@ -52,6 +53,7 @@ export const DockerLogsId: React.FC<Props> = ({
containerId,
serverId,
runType,
serviceId,
}) => {
const { data } = api.docker.getConfig.useQuery(
{
@@ -157,6 +159,10 @@ export const DockerLogsId: React.FC<Props> = ({
params.append("serverId", serverId);
}
if (serviceId) {
params.append("serviceId", serviceId);
}
const wsUrl = `${protocol}//${
window.location.host
}/docker-container-logs?${params.toString()}`;
@@ -222,7 +228,7 @@ export const DockerLogsId: React.FC<Props> = ({
ws.close();
}
};
}, [containerId, serverId, lines, search, since]);
}, [containerId, serverId, serviceId, lines, search, since]);
const handleDownload = () => {
const logContent = filteredLogs
@@ -72,8 +72,68 @@ export function parseLogs(logString: string): LogLine[] {
.filter((log) => log !== null);
}
const LEVEL_NAME_TO_TYPE: Record<string, LogType> = {
trace: "debug",
debug: "debug",
info: "info",
information: "info",
notice: "info",
warn: "warning",
warning: "warning",
error: "error",
err: "error",
fatal: "error",
critical: "error",
panic: "error",
alert: "error",
emergency: "error",
};
const numericLevelToType = (level: number): LogType => {
// pino/bunyan scale: 10=trace 20=debug 30=info 40=warn 50=error 60=fatal
if (level >= 50) return "error";
if (level >= 40) return "warning";
if (level >= 30) return "info";
if (level >= 10) return "debug";
// syslog/GELF scale: 0=emergency ... 7=debug
if (level <= 3) return "error";
if (level === 4) return "warning";
if (level <= 6) return "info";
return "debug";
};
// Extract the log level explicitly declared by structured loggers
// (pino, bunyan, winston, zap, slog, logfmt, GCP severity)
const getExplicitLevelType = (message: string): LogType | null => {
// JSON string levels: {"level":"error"} / {"severity":"ERROR"} / {"log.level":"warn"}
const jsonStringMatch = message.match(
/"(?:level|severity|log\.level|loglevel)"\s*:\s*"([a-z]+)"/i,
);
if (jsonStringMatch?.[1]) {
return LEVEL_NAME_TO_TYPE[jsonStringMatch[1].toLowerCase()] ?? null;
}
// JSON numeric levels: {"level":50}
const jsonNumericMatch = message.match(/"level"\s*:\s*(\d{1,2})\b/);
if (jsonNumericMatch?.[1]) {
return numericLevelToType(Number(jsonNumericMatch[1]));
}
// logfmt: level=error
const logfmtMatch = message.match(/(?:^|\s)(?:level|severity)=([a-z]+)\b/i);
if (logfmtMatch?.[1]) {
return LEVEL_NAME_TO_TYPE[logfmtMatch[1].toLowerCase()] ?? null;
}
return null;
};
// Detect log type based on message content
export const getLogType = (message: string): LogStyle => {
// A level explicitly declared by the logger wins over any inference
const explicitType = getExplicitLevelType(message);
if (explicitType) return LOG_STYLES[explicitType];
// Detect HTTP statusCode
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
@@ -97,17 +157,27 @@ export const getLogType = (message: string): LogStyle => {
return LOG_STYLES.info;
}
// Key/value pairs that explicitly report a non-error (e.g. "error: none",
// "failed: false") must not trigger the error keyword patterns below
const nonErrorColonPairs =
/\b(?:error|err|errors|failed|failure|failures)\s*:\s*(?:none|null|nil|false|0|no|-|""|'')(?=[,;.)\]]|$)/gi;
const nonErrorLogfmtPairs =
/\b(?:error|err|errors|failed|failure|failures)\s*=\s*(?:none|null|nil|false|0|no|-|""|'')(?=[\s,;.)\]]|$)/gi;
const errorScope = lowerMessage
.replace(nonErrorColonPairs, "")
.replace(nonErrorLogfmtPairs, "");
if (
/(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) ||
/\b(?:exception|failed|failure)\b/i.test(lowerMessage) ||
/(?:stack\s?trace):\s*$/i.test(lowerMessage) ||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) ||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) ||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) ||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) ||
/\[(?:error|err|fatal)\]/i.test(lowerMessage) ||
/\b(?:crash|critical|fatal)\b/i.test(lowerMessage) ||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage)
/(?:^|\s)(?:error|err):?\s/i.test(errorScope) ||
/\b(?:exception|failed|failure)\b/i.test(errorScope) ||
/(?:stack\s?trace):\s*$/i.test(errorScope) ||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(errorScope) ||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(errorScope) ||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(errorScope) ||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(errorScope) ||
/\[(?:error|err|fatal)\]/i.test(errorScope) ||
/\b(?:crash|critical|fatal)\b/i.test(errorScope) ||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(errorScope)
) {
return LOG_STYLES.error;
}
@@ -12,6 +12,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { FilesExplorerModal } from "../files/files-explorer-modal";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
@@ -173,6 +174,12 @@ export const columns: ColumnDef<Container>[] = [
>
Terminal
</DockerTerminalModal>
<FilesExplorerModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Browse Files
</FilesExplorerModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}
@@ -23,12 +23,14 @@ interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
serviceId?: string;
}
export const DockerTerminalModal = ({
children,
containerId,
serverId,
serviceId,
}: Props) => {
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
@@ -74,6 +76,7 @@ export const DockerTerminalModal = ({
id="terminal"
containerId={containerId}
serverId={serverId || ""}
serviceId={serviceId}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
@@ -3,19 +3,23 @@ import React, { useEffect, useRef } from "react";
import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { useTheme } from "next-themes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
interface Props {
id: string;
containerId?: string;
serverId?: string;
serviceId?: string;
}
export const DockerTerminal: React.FC<Props> = ({
id,
containerId,
serverId,
serviceId,
}) => {
const termRef = useRef(null);
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
@@ -38,11 +42,14 @@ export const DockerTerminal: React.FC<Props> = ({
const addonFit = new FitAddon();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
const clipboardAddon = new ClipboardAddon();
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
@@ -51,6 +58,7 @@ export const DockerTerminal: React.FC<Props> = ({
addonFit.fit();
return () => {
ws.readyState === WebSocket.OPEN && ws.close();
term.dispose();
};
}, [containerId, activeWay, id]);
@@ -0,0 +1,434 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Eye,
FolderOpen,
HardDrive,
Loader2,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { FilesExplorerModal } from "@/components/dashboard/docker/files/files-explorer-modal";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { DialogAction } from "@/components/shared/dialog-action";
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 { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type VolumeRow =
inferRouterOutputs<AppRouter>["dockerVolume"]["getVolumes"][number];
interface Props {
serverId?: string;
}
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1e3,
mb: 1e6,
gb: 1e9,
tb: 1e12,
};
const parseSize = (size?: string) => {
if (!size) return -1;
const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim());
if (!match?.[1] || !match[2]) return -1;
return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1);
};
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
const ShowVolumeConfig = ({
volumeName,
serverId,
}: {
volumeName: string;
serverId?: string;
}) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.dockerVolume.getVolumeConfig.useQuery(
{ volumeName, serverId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View volume config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Volume Config</DialogTitle>
<DialogDescription>
docker volume inspect output for "{volumeName}"
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap wrap-break-word">
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
)}
</DialogContent>
</Dialog>
);
};
export const ShowVolumes = ({ serverId }: Props) => {
const utils = api.useUtils();
const [sorting, setSorting] = useState<SortingState>([
{ id: "Name", desc: false },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: volumes, isLoading } = api.dockerVolume.getVolumes.useQuery({
serverId,
});
const { data: sizes, isLoading: isLoadingSizes } =
api.dockerVolume.getVolumesSize.useQuery({ serverId });
const sizeByName = useMemo(
() => new Map((sizes ?? []).map((entry) => [entry.name, entry.size])),
[sizes],
);
const { mutateAsync: removeVolume } =
api.dockerVolume.removeVolume.useMutation();
const filteredData = useMemo(() => {
const list = volumes ?? [];
if (!globalFilter.trim()) {
return list;
}
const query = globalFilter.toLowerCase();
return list.filter((volume) => volume.Name.toLowerCase().includes(query));
}, [volumes, globalFilter]);
const columns = useMemo<ColumnDef<VolumeRow>[]>(
() => [
{
accessorKey: "Name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<div
className="max-w-[280px] truncate font-medium"
title={row.original.Name}
>
{row.original.Name}
</div>
),
},
{
accessorKey: "Driver",
header: ({ column }) => (
<SortableHeader column={column} title="Driver" />
),
cell: ({ row }) => (
<Badge variant="outline">{row.original.Driver}</Badge>
),
},
{
accessorKey: "Scope",
header: ({ column }) => (
<SortableHeader column={column} title="Scope" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.Scope}</span>
),
},
{
id: "Size",
header: ({ column }) => <SortableHeader column={column} title="Size" />,
sortingFn: (a, b) =>
parseSize(sizeByName.get(a.original.Name) ?? undefined) -
parseSize(sizeByName.get(b.original.Name) ?? undefined),
cell: ({ row }) =>
isLoadingSizes ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : (
<span className="text-muted-foreground">
{sizeByName.get(row.original.Name) ?? "-"}
</span>
),
},
{
accessorKey: "Mountpoint",
header: ({ column }) => (
<SortableHeader column={column} title="Mountpoint" />
),
cell: ({ row }) => (
<div
className="max-w-[320px] truncate font-mono text-xs text-muted-foreground"
title={row.original.Mountpoint}
>
{row.original.Mountpoint}
</div>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<FilesExplorerModal
volumeName={row.original.Name}
serverId={serverId}
asDropdownItem={false}
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Browse volume files"
>
<FolderOpen className="size-4" />
</Button>
</FilesExplorerModal>
<ShowVolumeConfig
volumeName={row.original.Name}
serverId={serverId}
/>
<DialogAction
title="Delete volume"
description={`The volume "${row.original.Name}" will be removed from Docker. This action cannot be undone.`}
onClick={async () => {
try {
await removeVolume({
volumeName: row.original.Name,
serverId,
});
toast.success("Volume deleted");
await utils.dockerVolume.getVolumes.invalidate();
} catch (error) {
toast.error("Error deleting volume", {
description:
error instanceof Error ? error.message : "Unknown error",
});
}
}}
>
<Button variant="ghost" size="icon-sm" aria-label="Delete volume">
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[serverId, removeVolume, utils, sizeByName, isLoadingSizes],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<HardDrive className="size-6 text-muted-foreground self-center" />
Volumes
</CardTitle>
<CardDescription>
Manage the Docker volumes of the selected server.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !volumes?.length ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<HardDrive className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No volumes found</p>
<p className="max-w-sm text-sm text-muted-foreground">
Docker volumes created on this server will appear here.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No volumes match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -229,6 +229,7 @@ export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
)}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.libsqlId}
serverId={data?.serverId || ""}
>
<Button
@@ -236,6 +236,7 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
))}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mariadbId}
serverId={data?.serverId || ""}
>
<Button
@@ -230,6 +230,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mongoId}
serverId={data?.serverId || ""}
>
<Button
@@ -1,3 +1,4 @@
import { formatMb, toMb } from "@dokploy/server/monitoring/units";
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
@@ -29,8 +30,8 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
readMb: item.value.readMb,
writeMb: item.value.writeMb,
readMb: toMb(item.value.readMb),
writeMb: toMb(item.value.writeMb),
}));
return (
@@ -77,13 +78,14 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
}}
formatter={(value, name) => {
const label = name === "readMb" ? "Read" : "Write";
return [`${value} MB`, label];
return [formatMb(Number(value)), label];
}}
/>
}
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="readMb"
stroke="var(--color-readMb)"
fill="url(#fillBlockRead)"
@@ -91,6 +93,7 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="writeMb"
stroke="var(--color-writeMb)"
fill="url(#fillBlockWrite)"
@@ -69,6 +69,7 @@ export const DockerCpuChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillCpu)"
@@ -71,6 +71,7 @@ export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usedGb"
stroke="var(--color-usedGb)"
fill="url(#fillDiskUsed)"
@@ -75,6 +75,7 @@ export const DockerMemoryChart = ({
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillMemory)"
@@ -1,3 +1,4 @@
import { formatMb, toMb } from "@dokploy/server/monitoring/units";
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
@@ -29,8 +30,8 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb,
outMB: item.value.outputMb,
inMB: toMb(item.value.inputMb),
outMB: toMb(item.value.outputMb),
}));
return (
@@ -73,13 +74,14 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
}}
formatter={(value, name) => {
const label = name === "inMB" ? "In" : "Out";
return [`${value} MB`, label];
return [formatMb(Number(value)), label];
}}
/>
}
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="inMB"
stroke="var(--color-inMB)"
fill="url(#fillNetIn)"
@@ -87,6 +89,7 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => {
/>
<Area
type="monotone"
isAnimationActive={false}
dataKey="outMB"
stroke="var(--color-outMB)"
fill="url(#fillNetOut)"
@@ -1,3 +1,4 @@
import { formatMb } from "@dokploy/server/monitoring/units";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
@@ -305,7 +306,7 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
{`Read: ${formatMb(currentData.block.value.readMb)} / Write: ${formatMb(currentData.block.value.writeMb)}`}
</span>
<DockerBlockChart accumulativeData={accumulativeData.block} />
</div>
@@ -318,7 +319,7 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
{`In: ${formatMb(currentData.network.value.inputMb)} / Out: ${formatMb(currentData.network.value.outputMb)}`}
</span>
<DockerNetworkChart accumulativeData={accumulativeData.network} />
</div>
@@ -154,6 +154,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
name="Write"
dataKey="write"
type="monotone"
isAnimationActive={false}
fill="url(#fillWrite)"
stroke="hsl(142, 71%, 45%)"
strokeWidth={2}
@@ -163,6 +164,7 @@ export const ContainerBlockChart = ({ data }: Props) => {
name="Read"
dataKey="read"
type="monotone"
isAnimationActive={false}
fill="url(#fillRead)"
stroke="hsl(217, 91%, 60%)"
strokeWidth={2}
@@ -111,6 +111,7 @@ export const ContainerCPUChart = ({ data }: Props) => {
name="CPU"
dataKey="cpu"
type="monotone"
isAnimationActive={false}
fill="url(#fillCPU)"
stroke="hsl(var(--chart-1))"
strokeWidth={2}
@@ -132,6 +132,7 @@ export const ContainerMemoryChart = ({ data }: Props) => {
name="Memory"
dataKey="memory"
type="monotone"
isAnimationActive={false}
fill="url(#fillMemory)"
stroke="hsl(var(--chart-2))"
strokeWidth={2}
@@ -161,6 +161,7 @@ export const ContainerNetworkChart = ({ data }: Props) => {
name="Input"
dataKey="input"
type="monotone"
isAnimationActive={false}
fill="url(#fillInput)"
stroke="hsl(var(--chart-3))"
strokeWidth={2}
@@ -169,6 +170,7 @@ export const ContainerNetworkChart = ({ data }: Props) => {
name="Output"
dataKey="output"
type="monotone"
isAnimationActive={false}
fill="url(#fillOutput)"
stroke="hsl(var(--chart-4))"
strokeWidth={2}
@@ -98,6 +98,7 @@ export function CPUChart({ data }: CPUChartProps) {
name="CPU"
dataKey="cpu"
type="monotone"
isAnimationActive={false}
fill="url(#fillCPU)"
stroke="hsl(var(--chart-1))"
strokeWidth={2}
@@ -115,6 +115,7 @@ export function MemoryChart({ data }: MemoryChartProps) {
yAxisId="left"
dataKey="memUsed"
type="monotone"
isAnimationActive={false}
fill="url(#fillMemory)"
stroke="hsl(var(--chart-2))"
strokeWidth={2}
@@ -120,6 +120,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
name="Network In"
dataKey="networkIn"
type="monotone"
isAnimationActive={false}
fill="url(#fillNetworkIn)"
stroke="hsl(var(--chart-3))"
strokeWidth={2}
@@ -128,6 +129,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
name="Network Out"
dataKey="networkOut"
type="monotone"
isAnimationActive={false}
fill="url(#fillNetworkOut)"
stroke="hsl(var(--chart-4))"
strokeWidth={2}
@@ -228,6 +228,7 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mysqlId}
serverId={data?.serverId || ""}
>
<Button
@@ -0,0 +1,347 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, ChevronsUpDown, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
interface Props {
composeId: string;
}
const serviceSchema = z.object({
serviceName: z.string(),
networkIds: z.array(z.string()),
detachDokployNetwork: z.boolean(),
});
const formSchema = z.object({
services: z.array(serviceSchema),
});
type FormValues = z.infer<typeof formSchema>;
export const AssignComposeNetworks = ({ composeId }: Props) => {
const [cacheType, setCacheType] = useState<"cache" | "fetch">("cache");
const { data: compose } = api.compose.one.useQuery({ composeId });
const {
data: services,
isLoading: isLoadingServices,
error: servicesError,
refetch: refetchServices,
isRefetching: isRefetchingServices,
} = api.compose.loadServices.useQuery(
{ composeId, type: cacheType },
{ retry: false },
);
const onRetry = () => {
setCacheType("fetch");
setTimeout(() => refetchServices(), 0);
};
const { data: networks } = api.network.all.useQuery(
{ serverId: compose?.serverId ?? undefined },
{ enabled: compose !== undefined },
);
const { mutateAsync, isPending } = api.compose.update.useMutation();
const utils = api.useUtils();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { services: [] },
});
const { fields } = useFieldArray({
control: form.control,
name: "services",
});
useEffect(() => {
if (!services) return;
const serviceNetworks = compose?.serviceNetworks ?? [];
form.reset({
services: services.map((serviceName) => {
const config = serviceNetworks.find(
(s) => s.serviceName === serviceName,
);
return {
serviceName,
networkIds: config?.networkIds ?? [],
detachDokployNetwork: config?.detachDokployNetwork ?? false,
};
}),
});
}, [services, compose?.serviceNetworks, form]);
const allowsBridge = compose?.composeType === "docker-compose";
const availableNetworks = (networks ?? []).filter(
(n) => allowsBridge || n.driver === "overlay",
);
const onSubmit = async (values: FormValues) => {
try {
const serviceNetworks = values.services.filter(
(s) => s.networkIds.length > 0 || s.detachDokployNetwork,
);
await mutateAsync({
composeId,
serviceNetworks,
});
toast.success("Networks updated. Redeploy the compose to apply them.");
await utils.compose.one.invalidate({ composeId });
} catch (error) {
toast.error("Error updating networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-start justify-between flex-wrap gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Networks</CardTitle>
<CardDescription>
Attach Docker networks per service and detach it from
dokploy-network. Takes effect on the next deploy.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
isLoading={isRefetchingServices}
onClick={onRetry}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{servicesError ? (
<div className="flex flex-col gap-3">
<AlertBlock type="error">
Could not load the compose services. If this compose was just
created from a template, it hasn't been cloned yet — click Reload
to clone the repository and read its services.
</AlertBlock>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
isLoading={isRefetchingServices}
onClick={onRetry}
>
<RefreshCw className="size-4" />
Reload services
</Button>
</div>
</div>
) : isLoadingServices ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading services...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !services?.length ? (
<span className="py-6 text-center text-sm text-muted-foreground">
No services found in this compose.
</span>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
{fields.map((fieldItem, index) => (
<ServiceRow
key={fieldItem.id}
control={form.control}
index={index}
service={fieldItem.serviceName}
availableNetworks={availableNetworks}
/>
))}
<div className="flex justify-end">
<Button
type="submit"
disabled={!form.formState.isDirty}
isLoading={isPending}
>
Save
</Button>
</div>
</form>
</Form>
)}
</CardContent>
</Card>
);
};
type NetworkOption = { networkId: string; name: string; driver: string };
const ServiceRow = ({
control,
index,
service,
availableNetworks,
}: {
control: ReturnType<typeof useForm<FormValues>>["control"];
index: number;
service: string;
availableNetworks: NetworkOption[];
}) => {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col gap-3 rounded-lg border p-4">
<FormField
control={control}
name={`services.${index}.detachDokployNetwork`}
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-3 space-y-0">
<span className="text-sm font-medium">{service}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Detach dokploy-network
</span>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
</FormItem>
)}
/>
<FormField
control={control}
name={`services.${index}.networkIds`}
render={({ field }) => {
const selectedNetworks = availableNetworks.filter((n) =>
field.value.includes(n.networkId),
);
const toggle = (networkId: string) => {
field.onChange(
field.value.includes(networkId)
? field.value.filter((id) => id !== networkId)
: [...field.value, networkId],
);
};
return (
<FormItem className="space-y-3">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
aria-expanded={open}
className="w-full justify-between"
>
<span className="text-muted-foreground">
{selectedNetworks.length > 0
? `${selectedNetworks.length} network${
selectedNetworks.length > 1 ? "s" : ""
} selected`
: "Select networks..."}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search networks..."
className="h-9"
/>
<CommandList className="max-h-60">
{availableNetworks.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No networks available on this server.
</div>
) : (
<CommandGroup>
{availableNetworks.map((n) => {
const isSelected = field.value.includes(
n.networkId,
);
return (
<CommandItem
key={n.networkId}
value={n.name}
onSelect={() => toggle(n.networkId)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => toggle(n.networkId)}
/>
<span>{n.name}</span>
<Badge variant="outline" className="ml-2">
{n.driver}
</Badge>
<Check
className={cn(
"ml-auto size-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedNetworks.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedNetworks.map((n) => (
<Badge key={n.networkId} variant="secondary">
{n.name}
</Badge>
))}
</div>
)}
</FormItem>
);
}}
/>
</div>
);
};
@@ -0,0 +1,355 @@
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
type ServiceType =
| "application"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis"
| "libsql";
interface Props {
id: string;
type: ServiceType;
}
export const AssignNetworks = ({ id, type }: Props) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [detached, setDetached] = useState(false);
const {
service,
serverId,
networkIds,
detachDokployNetwork,
updateAsync,
isUpdating,
refetch,
} = useServiceNetworks(id, type);
const { data: networks } = api.network.all.useQuery(
{ serverId: serverId ?? undefined },
{ enabled: service !== undefined },
);
const { data: applicationDomains } = api.domain.byApplicationId.useQuery(
{ applicationId: id },
{ enabled: type === "application" },
);
const hasDomains = (applicationDomains?.length ?? 0) > 0;
useEffect(() => {
setSelected(networkIds ?? []);
setDetached(detachDokployNetwork ?? false);
}, [networkIds, detachDokployNetwork]);
const availableNetworks = (networks ?? []).filter(
(n) => n.driver === "overlay",
);
const selectedNetworks = availableNetworks.filter((n) =>
selected.includes(n.networkId),
);
const isDirty =
selected.length !== (networkIds?.length ?? 0) ||
selected.some((networkId) => !networkIds?.includes(networkId)) ||
detached !== detachDokployNetwork;
const toggle = (networkId: string) => {
setSelected((prev) =>
prev.includes(networkId)
? prev.filter((id) => id !== networkId)
: [...prev, networkId],
);
};
const onSave = async () => {
try {
await updateAsync({
networkIds: selected,
detachDokployNetwork: detached,
});
toast.success("Networks updated. Redeploy the service to apply them.");
await refetch();
} catch (error) {
toast.error("Error updating networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Networks</CardTitle>
<CardDescription>
Attach additional Docker networks to this service so it can reach
services on those networks. Takes effect on the next deploy.
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row items-start justify-between gap-3 rounded-lg border p-4">
<div className="space-y-1 pr-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Detach from dokploy-network
</span>
<Badge variant="secondary">dokploy-network</Badge>
</div>
<p className="text-sm text-muted-foreground">
By default the service joins the shared dokploy-network. Detach it
to keep it reachable only through the networks you attach below.
</p>
</div>
<Switch checked={detached} onCheckedChange={setDetached} />
</div>
{detached && hasDomains && (
<AlertBlock type="warning">
Warning: this service has domains. Detaching it from dokploy-network
will break Traefik routing, and its domains will stop working.
</AlertBlock>
)}
{detached && !hasDomains && selected.length === 0 && (
<AlertBlock type="warning">
This service is detached from dokploy-network but has no other
network attached. It would be unreachable, so dokploy-network will
be kept until you attach a network below.
</AlertBlock>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
className="w-full justify-between"
>
<span className="text-muted-foreground">
{selectedNetworks.length > 0
? `${selectedNetworks.length} network${
selectedNetworks.length > 1 ? "s" : ""
} selected`
: "Select networks..."}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search networks..." className="h-9" />
<CommandList className="max-h-60">
{availableNetworks.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No overlay networks on this server.
</div>
) : (
<>
<CommandEmpty className="py-6 text-center text-sm text-muted-foreground">
No networks found.
</CommandEmpty>
<CommandGroup>
{availableNetworks.map((n) => {
const isSelected = selected.includes(n.networkId);
return (
<CommandItem
key={n.networkId}
value={n.name}
onSelect={() => toggle(n.networkId)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => toggle(n.networkId)}
/>
<span>{n.name}</span>
<Badge variant="outline" className="ml-2">
{n.driver}
</Badge>
<Check
className={cn(
"ml-auto size-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedNetworks.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedNetworks.map((n) => (
<Badge
key={n.networkId}
variant="secondary"
className="flex items-center gap-1 pr-1"
>
{n.name}
<button
type="button"
onClick={() => toggle(n.networkId)}
className="ml-0.5 rounded-full outline-hidden hover:opacity-70"
>
<X className="size-3" />
<span className="sr-only">Remove {n.name}</span>
</button>
</Badge>
))}
</div>
)}
<div className="flex justify-end">
<Button disabled={!isDirty} isLoading={isUpdating} onClick={onSave}>
Save
</Button>
</div>
</CardContent>
</Card>
);
};
// Maps a service type to its one-query and update-mutation, normalizing the
// per-type id field name and the networkIds field.
const useServiceNetworks = (id: string, type: ServiceType) => {
const application = api.application.one.useQuery(
{ applicationId: id },
{ enabled: type === "application" },
);
const postgres = api.postgres.one.useQuery(
{ postgresId: id },
{ enabled: type === "postgres" },
);
const mysql = api.mysql.one.useQuery(
{ mysqlId: id },
{ enabled: type === "mysql" },
);
const mariadb = api.mariadb.one.useQuery(
{ mariadbId: id },
{ enabled: type === "mariadb" },
);
const mongo = api.mongo.one.useQuery(
{ mongoId: id },
{ enabled: type === "mongo" },
);
const redis = api.redis.one.useQuery(
{ redisId: id },
{ enabled: type === "redis" },
);
const libsql = api.libsql.one.useQuery(
{ libsqlId: id },
{ enabled: type === "libsql" },
);
const applicationUpdate = api.application.update.useMutation();
const postgresUpdate = api.postgres.update.useMutation();
const mysqlUpdate = api.mysql.update.useMutation();
const mariadbUpdate = api.mariadb.update.useMutation();
const mongoUpdate = api.mongo.update.useMutation();
const redisUpdate = api.redis.update.useMutation();
const libsqlUpdate = api.libsql.update.useMutation();
const map = {
application: {
query: application,
mutation: applicationUpdate,
save: (payload: SavePayload) =>
applicationUpdate.mutateAsync({ applicationId: id, ...payload }),
},
postgres: {
query: postgres,
mutation: postgresUpdate,
save: (payload: SavePayload) =>
postgresUpdate.mutateAsync({ postgresId: id, ...payload }),
},
mysql: {
query: mysql,
mutation: mysqlUpdate,
save: (payload: SavePayload) =>
mysqlUpdate.mutateAsync({ mysqlId: id, ...payload }),
},
mariadb: {
query: mariadb,
mutation: mariadbUpdate,
save: (payload: SavePayload) =>
mariadbUpdate.mutateAsync({ mariadbId: id, ...payload }),
},
mongo: {
query: mongo,
mutation: mongoUpdate,
save: (payload: SavePayload) =>
mongoUpdate.mutateAsync({ mongoId: id, ...payload }),
},
redis: {
query: redis,
mutation: redisUpdate,
save: (payload: SavePayload) =>
redisUpdate.mutateAsync({ redisId: id, ...payload }),
},
libsql: {
query: libsql,
mutation: libsqlUpdate,
save: (payload: SavePayload) =>
libsqlUpdate.mutateAsync({ libsqlId: id, ...payload }),
},
}[type];
const service = map.query.data;
return {
service,
serverId: service?.serverId ?? null,
networkIds: service?.networkIds ?? [],
detachDokployNetwork: service?.detachDokployNetwork ?? false,
updateAsync: map.save,
isUpdating: map.mutation.isPending,
refetch: map.query.refetch,
};
};
type SavePayload = {
networkIds: string[];
detachDokployNetwork: boolean;
};
@@ -0,0 +1,404 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Network, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useFieldArray, 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,
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 { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
// Only bridge and overlay can be created: "host"/"none" are Docker
// singletons and macvlan/ipvlan need driver options we don't expose.
const networkDriverEnum = ["bridge", "overlay"] as const;
const ipamConfigEntrySchema = z.object({
subnet: z.string().optional(),
ipRange: z.string().optional(),
gateway: z.string().optional(),
});
const networkFormSchema = z
.object({
name: z.string().min(1, "Name is required"),
driver: z.enum(networkDriverEnum),
internal: z.boolean(),
attachable: z.boolean(),
enableIPv4: z.boolean(),
enableIPv6: z.boolean(),
mtu: z
.string()
.refine(
(value) =>
value === "" ||
(/^\d+$/.test(value) && +value >= 68 && +value <= 65535),
{ message: "MTU must be a number between 68 and 65535" },
),
ipamDriver: z.string().optional(),
ipamConfig: z.array(ipamConfigEntrySchema),
})
.superRefine((input, ctx) => {
if (!input.enableIPv4 && !input.enableIPv6) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["enableIPv4"],
message: "IPv4 or IPv6 must be enabled",
});
}
for (const [index, entry] of input.ipamConfig.entries()) {
if (!entry.subnet && (entry.gateway || entry.ipRange)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ipamConfig", index, "subnet"],
message: "Gateway and IP range require a subnet",
});
}
}
});
type NetworkFormValues = z.infer<typeof networkFormSchema>;
const defaultValues: NetworkFormValues = {
name: "",
driver: "bridge",
internal: false,
attachable: false,
enableIPv4: true,
enableIPv6: false,
mtu: "",
ipamDriver: "",
ipamConfig: [],
};
const toggleOptions = [
{
name: "internal",
label: "Internal",
description: "Containers on this network cannot reach external networks.",
},
{
name: "attachable",
label: "Attachable",
description:
"Allow standalone containers to attach (overlay networks only).",
},
{
name: "enableIPv4",
label: "Enable IPv4",
description: "Enable IPv4 addressing on the network.",
},
{
name: "enableIPv6",
label: "Enable IPv6",
description: "Enable IPv6 addressing on the network.",
},
] as const;
interface HandleNetworkProps {
/** Target server; undefined creates on the local Dokploy server */
serverId?: string;
children?: React.ReactNode;
}
// Docker networks are immutable, so this dialog only creates them;
// changing a network means deleting and recreating it.
export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isPending } = api.network.create.useMutation();
const form = useForm<NetworkFormValues>({
resolver: zodResolver(networkFormSchema),
defaultValues,
});
const ipamConfigFieldArray = useFieldArray({
control: form.control,
name: "ipamConfig",
});
const onSubmit = async (data: NetworkFormValues) => {
try {
await mutateAsync({
name: data.name,
driver: data.driver,
serverId,
internal: data.internal,
attachable: data.attachable,
enableIPv4: data.enableIPv4,
enableIPv6: data.enableIPv6,
mtu: data.mtu ? Number(data.mtu) : undefined,
ipam: {
driver: data.ipamDriver || undefined,
config: data.ipamConfig,
},
});
toast.success("Network created");
await utils.network.all.invalidate();
setIsOpen(false);
form.reset(defaultValues);
} catch (error) {
toast.error("Error creating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const trigger = children ?? (
<Button>
<Plus className=" size-4" />
Add network
</Button>
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Network className="size-5 text-muted-foreground" />
Add network
</DialogTitle>
<DialogDescription>
Create a new Docker network for your organization. Networks are
immutable: to change one, delete it and create it again.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-6"
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="my-network" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="driver"
render={({ field }) => (
<FormItem>
<FormLabel>Driver</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select driver" />
</SelectTrigger>
</FormControl>
<SelectContent>
{networkDriverEnum.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription className="text-muted-foreground">
bridge for single-server containers; overlay for Swarm
services.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mtu"
render={({ field }) => (
<FormItem>
<FormLabel>MTU (optional)</FormLabel>
<FormControl>
<Input
placeholder="1500"
inputMode="numeric"
{...field}
/>
</FormControl>
<FormDescription className="text-muted-foreground">
Maximum transmission unit. Leave empty to use Docker's
default.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{toggleOptions.map((option) => (
<FormField
key={option.name}
control={form.control}
name={option.name}
render={({ field }) => (
<FormItem className="flex flex-row items-start justify-between gap-3 space-y-0 rounded-lg border p-4">
<div className="space-y-1 pr-1">
<FormLabel>{option.label}</FormLabel>
<FormDescription className="text-muted-foreground">
{option.description}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
))}
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-1">
<FormLabel>IPAM</FormLabel>
<p className="text-sm text-muted-foreground">
IP address management settings for this network.
</p>
</div>
<FormField
control={form.control}
name="ipamDriver"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
Driver (optional)
</FormLabel>
<FormControl>
<Input {...field} placeholder="default" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel className="text-muted-foreground">
Config (subnet / gateway / IP range)
</FormLabel>
{ipamConfigFieldArray.fields.map((field, index) => (
<div key={field.id} className="flex flex-wrap gap-2">
<FormField
control={form.control}
name={`ipamConfig.${index}.subnet`}
render={({ field: f }) => (
<FormItem className="min-w-[140px] flex-1">
<FormControl>
<Input
{...f}
placeholder="Subnet (e.g. 172.20.0.0/16)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.ipRange`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="IP range" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.gateway`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="Gateway" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Remove IPAM config"
onClick={() => ipamConfigFieldArray.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
ipamConfigFieldArray.append({
subnet: "",
ipRange: "",
gateway: "",
})
}
>
<Plus className="size-4" />
Add IPAM config
</Button>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button type="submit" isLoading={isPending}>
Create network
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,69 @@
"use client";
import { Eye, Loader2 } from "lucide-react";
import { useState } from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
interface Props {
networkId: string;
networkName: string;
}
export const ShowNetworkConfig = ({ networkId, networkName }: Props) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.network.inspect.useQuery(
{ networkId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View network config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Network Config</DialogTitle>
<DialogDescription>
docker network inspect output for "{networkName}"
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap wrap-break-word">
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,494 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Loader2,
Network,
RotateCcw,
ShieldCheck,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { HandleNetwork } from "@/components/dashboard/networks/handle-network";
import { ShowNetworkConfig } from "@/components/dashboard/networks/show-network-config";
import { SyncNetworks } from "@/components/dashboard/networks/sync-networks";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type NetworkRow = inferRouterOutputs<AppRouter>["network"]["all"][number];
interface Props {
/** Selected server; undefined shows the local Dokploy server */
serverId?: string;
}
const getIpamEntries = (row: NetworkRow) =>
(row.ipam?.config ?? []).filter((c) => c.subnet || c.gateway || c.ipRange);
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
export const ShowNetworks = ({ serverId }: Props) => {
const utils = api.useUtils();
const [verified, setVerified] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [driverFilter, setDriverFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: networks, isLoading } = api.network.all.useQuery({ serverId });
const { mutateAsync: removeNetwork } = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
// Same query the Sync dialog uses; "missing" tells us which records
// no longer have a real network in Docker
const {
data: syncStatus,
isFetching: isVerifying,
refetch: refetchVerify,
} = api.network.networksToSync.useQuery({ serverId }, { enabled: verified });
const missingIds = useMemo(
() => new Set(syncStatus?.missing.map((m) => m.networkId) ?? []),
[syncStatus],
);
const onVerify = async () => {
setVerified(true);
const { data: result, error } = await refetchVerify();
if (error) {
toast.error("Error verifying networks", {
description: error.message,
});
return;
}
if (!result) return;
if (result.missing.length === 0) {
toast.success("All networks exist in Docker");
} else {
toast.warning(
`${result.missing.length} network(s) no longer exist in Docker`,
);
}
};
const filteredData = useMemo(() => {
let list = networks ?? [];
if (driverFilter !== "all") {
list = list.filter((n) => n.driver === driverFilter);
}
if (globalFilter.trim()) {
const query = globalFilter.toLowerCase();
list = list.filter(
(n) =>
n.name.toLowerCase().includes(query) ||
(n.ipam?.config ?? []).some(
(c) =>
c.subnet?.toLowerCase().includes(query) ||
c.gateway?.toLowerCase().includes(query) ||
c.ipRange?.toLowerCase().includes(query),
),
);
}
return list;
}, [networks, driverFilter, globalFilter]);
const columns = useMemo<ColumnDef<NetworkRow>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 font-medium">
{row.original.name}
{verified &&
syncStatus &&
(missingIds.has(row.original.networkId) ? (
<>
<Badge variant="red">Missing in Docker</Badge>
<Button
variant="outline"
size="xs"
isLoading={recreateMutation.isPending}
onClick={async () => {
try {
await recreateMutation.mutateAsync({
networkId: row.original.networkId,
});
toast.success(
`Network "${row.original.name}" recreated in Docker`,
);
await utils.network.networksToSync.invalidate();
} catch (error) {
toast.error("Error recreating network", {
description:
error instanceof Error
? error.message
: "Unknown error",
});
}
}}
>
<RotateCcw className="size-3.5" />
Recreate
</Button>
</>
) : (
<Badge variant="green">In sync</Badge>
))}
</div>
),
},
{
accessorKey: "driver",
header: ({ column }) => (
<SortableHeader column={column} title="Driver" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Badge variant="outline">{row.original.driver}</Badge>
<span className="text-xs text-muted-foreground">
{row.original.driver === "overlay" ? "swarm" : "local"}
</span>
</div>
),
},
{
id: "subnet",
accessorFn: (row) => getIpamEntries(row)[0]?.subnet ?? "",
header: ({ column }) => (
<SortableHeader column={column} title="Subnet" />
),
cell: ({ row }) => {
const ipamEntries = getIpamEntries(row.original);
if (ipamEntries.length === 0) {
return <span className="text-muted-foreground">Auto</span>;
}
return (
<div className="flex flex-col gap-1">
{ipamEntries.map((c, index) => (
<div
key={`${row.original.networkId}-ipam-${index}`}
className="flex flex-col"
>
<span>{c.subnet ?? "—"}</span>
{(c.gateway || c.ipRange) && (
<span className="text-xs text-muted-foreground">
{[
c.gateway && `gw ${c.gateway}`,
c.ipRange && `range ${c.ipRange}`,
]
.filter(Boolean)
.join(" · ")}
</span>
)}
</div>
))}
</div>
);
},
},
{
accessorKey: "internal",
header: ({ column }) => (
<SortableHeader column={column} title="Internal" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.internal ? "Yes" : "No"}
</span>
),
},
{
accessorKey: "attachable",
header: ({ column }) => (
<SortableHeader column={column} title="Attachable" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.attachable ? "Yes" : "No"}
</span>
),
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap">
{new Date(row.original.createdAt).toLocaleDateString()}
</span>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<ShowNetworkConfig
networkId={row.original.networkId}
networkName={row.original.name}
/>
<DialogAction
title="Delete network"
description={`The network "${row.original.name}" will be removed from Docker and Dokploy. This action cannot be undone.`}
onClick={async () => {
try {
await removeNetwork({
networkId: row.original.networkId,
});
toast.success("Network deleted");
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
} catch (error) {
toast.error("Error deleting network", {
description:
error instanceof Error ? error.message : "Unknown error",
});
}
}}
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete network"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[verified, syncStatus, missingIds, removeNetwork, recreateMutation, utils],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Network className="size-6 text-muted-foreground self-center" />
Networks
</CardTitle>
<CardDescription>
Manage the Docker networks of the selected server.
</CardDescription>
<CardAction className="self-center">
<div className="flex items-center gap-2">
{networks && networks.length > 0 && (
<Button
variant="outline"
isLoading={isVerifying}
onClick={onVerify}
>
<ShieldCheck className="size-4" />
Verify
</Button>
)}
<SyncNetworks serverId={serverId} />
{networks && networks.length > 0 && (
<HandleNetwork serverId={serverId} />
)}
</div>
</CardAction>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !networks?.length ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<Network className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No networks yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Create Docker networks for your organization and optionally
attach them to a server. Add your first network to get
started.
</p>
</div>
<HandleNetwork serverId={serverId} />
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, subnet, gateway..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={driverFilter} onValueChange={setDriverFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Driver" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All drivers</SelectItem>
<SelectItem value="bridge">bridge</SelectItem>
<SelectItem value="overlay">overlay</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No networks match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,249 @@
"use client";
import { Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { api } from "@/utils/api";
interface Props {
serverId?: string;
}
export const SyncNetworks = ({ serverId }: Props) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const utils = api.useUtils();
const { data, isLoading, error, refetch } =
api.network.networksToSync.useQuery({ serverId }, { enabled: open });
const importMutation = api.network.import.useMutation();
const removeMutation = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
const toggleSelected = (name: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const onImport = async () => {
try {
const result = await importMutation.mutateAsync({
serverId,
names: Array.from(selected),
});
if (result.imported.length > 0) {
toast.success(`Imported ${result.imported.length} network(s)`);
}
for (const failure of result.errors) {
toast.error(`Could not import "${failure.name}"`, {
description: failure.error,
});
}
setSelected(new Set());
await utils.network.all.invalidate();
setOpen(false);
await refetch();
} catch (error) {
toast.error("Error importing networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const onRemoveStale = async (networkId: string, name: string) => {
try {
await removeMutation.mutateAsync({ networkId });
toast.success(`Removed stale record "${name}"`);
await utils.network.all.invalidate();
await refetch();
} catch (error) {
toast.error("Error removing record", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const onRecreate = async (networkId: string, name: string) => {
try {
await recreateMutation.mutateAsync({ networkId });
toast.success(`Network "${name}" recreated in Docker`);
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
await refetch();
} catch (error) {
toast.error("Error recreating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
setOpen(value);
if (!value) setSelected(new Set());
}}
>
<DialogTrigger asChild>
<Button variant="outline">
<RefreshCw className="size-4" />
Sync
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="size-5 text-muted-foreground" />
Sync networks
</DialogTitle>
<DialogDescription>
Import networks that exist in Docker but not in Dokploy, and clean
up records whose network no longer exists.
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Scanning Docker networks...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Found in Docker ({data?.importable.length ?? 0})
</span>
{data?.importable.length ? (
data.importable.map((dockerNetwork) => (
<label
key={dockerNetwork.name}
htmlFor={`import-network-${dockerNetwork.name}`}
className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<Checkbox
id={`import-network-${dockerNetwork.name}`}
checked={selected.has(dockerNetwork.name)}
onCheckedChange={() =>
toggleSelected(dockerNetwork.name)
}
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{dockerNetwork.name}
</span>
{dockerNetwork.subnets.length > 0 && (
<span className="text-xs text-muted-foreground">
{dockerNetwork.subnets.join(" · ")}
</span>
)}
</div>
</div>
<Badge variant="outline">{dockerNetwork.driver}</Badge>
</label>
))
) : (
<span className="text-sm text-muted-foreground">
Nothing to import — everything is in sync.
</span>
)}
</div>
{!!data?.missing.length && (
<>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Missing in Docker ({data.missing.length})
</span>
<span className="text-xs text-muted-foreground">
These records exist in Dokploy but their network is gone
from Docker.
</span>
{data.missing.map((stale) => (
<div
key={stale.networkId}
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
>
<span className="text-sm">{stale.name}</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
isLoading={recreateMutation.isPending}
onClick={() =>
onRecreate(stale.networkId, stale.name)
}
>
<RotateCcw className="size-3.5" />
Recreate
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove stale record ${stale.name}`}
isLoading={removeMutation.isPending}
onClick={() =>
onRemoveStale(stale.networkId, stale.name)
}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</>
)}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Close
</Button>
<Button
type="button"
disabled={selected.size === 0}
isLoading={importMutation.isPending}
onClick={onImport}
>
Import selected ({selected.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -14,7 +14,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
@@ -103,16 +102,19 @@ export function AddOrganization({ organizationId }: Props) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{organizationId ? (
<DropdownMenuItem
className="group cursor-pointer hover:bg-blue-500/10"
onSelect={(e) => e.preventDefault()}
<Button
type="button"
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
title="Edit organization"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</DropdownMenuItem>
</Button>
) : (
<DropdownMenuItem
className="gap-2 p-2"
onSelect={(e) => e.preventDefault()}
<button
type="button"
className="flex w-full items-center gap-2 rounded-md p-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
@@ -120,7 +122,7 @@ export function AddOrganization({ organizationId }: Props) {
<div className="font-medium text-muted-foreground">
Add organization
</div>
</DropdownMenuItem>
</button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
@@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useFieldArray, 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -18,6 +19,19 @@ import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import type { ServiceType } from "../../application/advanced/show-resources";
const getPostgresMountPath = (dockerImage: string): string => {
const versionMatch = dockerImage.match(/postgres:(\d+)/);
if (versionMatch?.[1]) {
const version = Number.parseInt(versionMatch[1], 10);
if (version >= 18) {
return `/var/lib/postgresql/${version}/docker`;
}
}
return "/var/lib/postgresql/data";
};
const POSTGRES_DATA_PATH_REGEX = /^\/var\/lib\/postgresql(\/|$)/;
const addDockerImage = z.object({
dockerImage: z.string().min(1, "Docker image is required"),
command: z.string(),
@@ -91,6 +105,27 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
}
}, [data, form]);
const dockerImage = form.watch("dockerImage");
const mountPathWarning = (() => {
if (type !== "postgres" || !dockerImage) return null;
const mounts = data && "mounts" in data ? data.mounts : [];
const dataMounts = mounts.filter(
(mount) =>
mount.type === "volume" &&
POSTGRES_DATA_PATH_REGEX.test(mount.mountPath),
);
if (dataMounts.length === 0) return null;
const expectedPath = getPostgresMountPath(dockerImage);
if (dataMounts.some((mount) => mount.mountPath === expectedPath)) {
return null;
}
return {
expectedPath,
currentPath: dataMounts.map((mount) => mount.mountPath).join(", "),
};
})();
const onSubmit = async (formData: AddDockerImage) => {
await mutateAsync({
mongoId: id || "",
@@ -139,6 +174,18 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
</FormItem>
)}
/>
{mountPathWarning && (
<AlertBlock type="warning">
This image expects its data directory under{" "}
<code>{mountPathWarning.expectedPath}</code>, but the
volume of this database is mounted at{" "}
<code>{mountPathWarning.currentPath}</code>. Changing the
image does not migrate existing data — Postgres may
crash-loop or start with an empty database. Adjust the
volume mount path in the Volumes section or keep a
compatible image before saving.
</AlertBlock>
)}
</div>
<FormField
control={form.control}
@@ -234,6 +234,7 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.postgresId}
serverId={data?.serverId || ""}
>
<Button
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -55,6 +56,11 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
includeShared: false,
projectId: data?.projectId,
environmentId,
});
const form = useForm<UpdateEnvironment>({
defaultValues: {
env: data?.env ?? "",
@@ -134,7 +140,9 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
<AlertBlock type="info">
Use this syntax to reference environment-level variables in your
service environments:{" "}
<code>API_URL=${"{{environment.API_URL}}"}</code>
<code>API_URL=${"{{environment.API_URL}}"}</code>. You can also
reference secrets from a configured vault provider:{" "}
<code>DB_URL=${"{{vault.<provider>.<secret>}}"}</code>
</AlertBlock>
<div className="grid gap-4">
<div className="grid items-center gap-4">
@@ -151,6 +159,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
completionSource={completionSource}
lineWrapping
language="properties"
readOnly={!canWrite}
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -55,6 +56,10 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
},
);
const completionSource = useEnvCompletionSource({
includeShared: false,
projectId,
});
const form = useForm<UpdateProject>({
defaultValues: {
env: data?.env ?? "",
@@ -77,6 +82,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
.then(() => {
toast.success("Project env updated successfully");
utils.project.all.invalidate();
utils.project.one.invalidate({ projectId });
})
.catch(() => {
toast.error("Error updating the env");
@@ -149,6 +155,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
completionSource={completionSource}
lineWrapping
language="properties"
readOnly={!canWrite}
@@ -229,6 +229,7 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.redisId}
serverId={data?.serverId || ""}
>
<Button
@@ -91,6 +91,7 @@ export const RequestDistributionChart = ({
<Area
dataKey="count"
type="monotone"
isAnimationActive={false}
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
@@ -328,7 +328,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
open={!!selectedRow}
onOpenChange={(_open) => setSelectedRow(undefined)}
>
<SheetContent className="sm:max-w-[740px] flex flex-col">
<SheetContent className="w-full sm:max-w-[740px]! flex flex-col">
<SheetHeader>
<SheetTitle>Request log</SheetTitle>
<SheetDescription>
@@ -63,7 +63,7 @@ export const ShowRequests = () => {
const [dateRange, setDateRange] = useState<{
from: Date | undefined;
to: Date | undefined;
}>(getDefaultDateRange());
}>(() => getDefaultDateRange());
// Check if logs exist to determine if traefik has been reloaded
// Only fetch when active to minimize network calls
@@ -13,10 +13,14 @@ import {
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { HandleAi } from "./handle-ai";
import { HandleAiProviders } from "./handle-ai-providers";
export const AiForm = () => {
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
const { data: currentUser } = api.user.get.useQuery();
const isOrgAdmin =
currentUser?.role === "owner" || currentUser?.role === "admin";
return (
<div className="w-full">
@@ -30,7 +34,10 @@ export const AiForm = () => {
</CardTitle>
<CardDescription>Manage your AI configurations</CardDescription>
</div>
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
<div className="flex flex-row gap-2">
{isOrgAdmin && <HandleAiProviders />}
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
</div>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
@@ -52,13 +52,14 @@ export const ShowNodes = ({ serverId }: Props) => {
serverId,
});
const { data: registry } = api.registry.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation();
const haveAtLeastOneRegistry = !!(registry && registry?.length > 0);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
<div className="flex flex-col gap-2">
@@ -68,7 +69,7 @@ export const ShowNodes = ({ serverId }: Props) => {
</CardTitle>
<CardDescription>Add nodes to your cluster</CardDescription>
</div>
{haveAtLeastOneRegistry && (
{haveAtLeastOneRegistry && permissions?.server.create && (
<div className="flex flex-row gap-2">
<AddNode serverId={serverId} />
</div>
@@ -144,34 +145,35 @@ export const ShowNodes = ({ serverId }: Props) => {
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<ShowNodeData data={node} />
{!node?.ManagerStatus?.Leader && (
<DialogAction
title="Delete Node"
description="Are you sure you want to delete this node from the cluster?"
type="destructive"
onClick={async () => {
await deleteNode({
nodeId: node.ID,
serverId,
})
.then(() => {
refetch();
toast.success(
"Node deleted successfully",
);
{!node?.ManagerStatus?.Leader &&
permissions?.server.delete && (
<DialogAction
title="Delete Node"
description="Are you sure you want to delete this node from the cluster?"
type="destructive"
onClick={async () => {
await deleteNode({
nodeId: node.ID,
serverId,
})
.catch(() => {
toast.error("Error deleting node");
});
}}
>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
.then(() => {
refetch();
toast.success(
"Node deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting node");
});
}}
>
Delete
</DropdownMenuItem>
</DialogAction>
)}
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
Delete
</DropdownMenuItem>
</DialogAction>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -0,0 +1,340 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
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 { api } from "@/utils/api";
const providerLabels = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
} as const;
type ProviderType = keyof typeof providerLabels;
const DnsProviderSchema = z.object({
name: z
.string()
.min(1, { message: "Name is required" })
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum(["cloudflare", "route53"]),
apiToken: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
const defaultValues: DnsProviderForm = {
name: "",
providerType: "cloudflare",
apiToken: "",
accessKeyId: "",
secretAccessKey: "",
};
const buildConfig = (data: DnsProviderForm) => {
switch (data.providerType) {
case "cloudflare":
return {
providerType: "cloudflare" as const,
apiToken: data.apiToken,
};
case "route53":
return {
providerType: "route53" as const,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
};
}
};
const extractErrorMessage = (err: unknown) => {
if (!(err instanceof Error)) return undefined;
try {
const issues = JSON.parse(err.message) as { message?: string }[];
if (Array.isArray(issues)) {
return issues
.map((issue) => issue.message)
.filter(Boolean)
.join(", ");
}
} catch {}
return err.message;
};
interface Props {
dnsProviderId?: string;
}
export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: provider } = api.dnsProvider.one.useQuery(
{ dnsProviderId: dnsProviderId || "" },
{ enabled: !!dnsProviderId && isOpen },
);
const { mutateAsync, isPending, error, isError } = dnsProviderId
? api.dnsProvider.update.useMutation()
: api.dnsProvider.create.useMutation();
const { mutateAsync: testConnection, isPending: isTesting } =
api.dnsProvider.testConnection.useMutation();
const form = useForm<DnsProviderForm>({
defaultValues,
resolver: zodResolver(DnsProviderSchema),
});
const providerType = form.watch("providerType");
useEffect(() => {
if (provider) {
form.reset({
...defaultValues,
name: provider.name,
providerType: provider.config.providerType,
...(provider.config.providerType === "cloudflare" && {
apiToken: provider.config.apiToken,
}),
...(provider.config.providerType === "route53" && {
accessKeyId: provider.config.accessKeyId,
secretAccessKey: provider.config.secretAccessKey,
}),
});
} else if (!dnsProviderId) {
form.reset(defaultValues);
}
}, [provider, dnsProviderId, form, isOpen]);
const onSubmit = async (data: DnsProviderForm) => {
const payload: any = {
name: data.name,
config: buildConfig(data),
...(dnsProviderId && { dnsProviderId }),
};
await mutateAsync(payload)
.then(() => {
toast.success(
dnsProviderId ? "DNS provider updated" : "DNS provider created",
);
utils.dnsProvider.all.invalidate();
setIsOpen(false);
})
.catch(() => {});
};
const onTestConnection = async () => {
const isValid = await form.trigger();
if (!isValid) {
return;
}
const data = form.getValues();
await testConnection({
config: buildConfig(data),
...(dnsProviderId && { dnsProviderId }),
})
.then(() => {
toast.success("Connection successful");
})
.catch((err) => {
toast.error("Connection failed", {
description: extractErrorMessage(err),
});
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{dnsProviderId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-4 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add Provider
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{dnsProviderId ? "Update DNS Provider" : "Add DNS Provider"}
</DialogTitle>
<DialogDescription>
Connect a DNS provider to create records for your domains
automatically instead of setting them up by hand.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="prod-cloudflare" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="providerType"
render={({ field }) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={!!dnsProviderId}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(providerLabels).map(([value, label]) => {
const ProviderIcon =
dnsProviderIcons[value as ProviderType];
return (
<SelectItem key={value} value={value}>
<div className="flex flex-row items-center gap-2">
<ProviderIcon className="size-4 shrink-0" />
{label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{providerType === "cloudflare" && (
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create a token scoped to Zone → DNS → Edit for the zones
you want Dokploy to manage. Avoid the Global API Key.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{providerType === "route53" && (
<>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Use an IAM user/role scoped to{" "}
<code>route53:ListHostedZones</code>,{" "}
<code>route53:ListResourceRecordSets</code> and{" "}
<code>route53:ChangeResourceRecordSets</code> — avoid
root account credentials.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
variant="secondary"
isLoading={isTesting}
onClick={onTestConnection}
>
Test Connection
</Button>
<Button type="submit" isLoading={isPending}>
{dnsProviderId ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,271 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
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 { api } from "@/utils/api";
const DnsRecordSchema = z.object({
type: z.enum(["A", "CNAME"]),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
});
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
interface DnsRecordValue {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}
interface Props {
dnsProviderId: string;
zoneId: string;
zoneName: string;
record?: DnsRecordValue;
}
export const HandleDnsRecord = ({
dnsProviderId,
zoneId,
zoneName,
record,
}: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isPending, error, isError } = record
? api.dnsProvider.updateRecord.useMutation()
: api.dnsProvider.createRecord.useMutation();
const { data: servers } = api.server.all.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, {
enabled: isOpen,
});
const panelIp = panelPublicIp || panelStoredIp;
const ipSuggestions = [
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
...(servers ?? []).map((server) => ({
ip: server.ipAddress,
label: server.name,
})),
].filter(
(suggestion, index, all) =>
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
);
const form = useForm<DnsRecordForm>({
defaultValues: record
? {
type: record.type === "CNAME" ? "CNAME" : "A",
name: record.name,
content: record.content,
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
}
: { type: "A", name: "", content: "", ttl: "" },
resolver: zodResolver(DnsRecordSchema),
});
const type = form.watch("type");
const onSubmit = async (data: DnsRecordForm) => {
const name = data.name.trim() === "@" ? zoneName : data.name;
const payload = {
dnsProviderId,
zoneId,
type: data.type,
name,
content: data.content,
ttl: data.ttl ? Number(data.ttl) : undefined,
...(record && { recordId: record.id }),
};
await mutateAsync(payload as any)
.then(() => {
toast.success(record ? "Record updated" : "Record created");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
setIsOpen(false);
})
.catch(() => {});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{record ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 size-6"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button variant="outline" size="sm">
<PlusIcon className="size-3.5 mr-1.5" />
Add Record
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{record ? "Edit Record" : "Add Record"}</DialogTitle>
<DialogDescription>
{record
? "Update this DNS record."
: "Create a new A or CNAME record in this zone."}
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="A">A</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="app.example.com" {...field} />
</FormControl>
<FormDescription>
Use <code>@</code> for the root domain.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{type === "A" && ipSuggestions.length > 0 && (
<FormItem>
<FormLabel>Fill from server (optional)</FormLabel>
<Select
onValueChange={(ip) =>
form.setValue("content", ip, { shouldValidate: true })
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ipSuggestions.map((suggestion) => (
<SelectItem key={suggestion.ip} value={suggestion.ip}>
<div className="flex flex-row items-center gap-2">
<span>{suggestion.label}</span>
<span className="text-xs text-muted-foreground">
{suggestion.ip}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>
{type === "A" ? "IPv4 Address" : "Target"}
</FormLabel>
<FormControl>
<Input
placeholder={
type === "A" ? "203.0.113.10" : "app.example.com"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (optional)</FormLabel>
<FormControl>
<Input type="number" placeholder="Auto" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" isLoading={isPending}>
{record ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,218 @@
import {
ChevronDown,
ChevronRight,
Globe,
Loader2,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { HandleDnsRecord } from "./handle-dns-record";
interface ZoneRecordsProps {
dnsProviderId: string;
zoneId: string;
zoneName: string;
}
const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: ZoneRecordsProps) => {
const utils = api.useUtils();
const { data, isLoading, isError, error } =
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteRecord, isPending: isDeleting } =
api.dnsProvider.deleteRecord.useMutation();
const canWrite = !!permissions?.dnsProvider.update;
const canDelete = !!permissions?.dnsProvider.delete;
return (
<div className="flex flex-col gap-1 pl-8 pb-2 pr-3">
{isLoading && (
<div className="flex flex-row gap-2 items-center text-sm text-muted-foreground py-3">
<span>Loading records...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No records found in this zone.
</p>
)}
{data?.map((record) => {
const isEditable = record.type === "A" || record.type === "CNAME";
return (
<div
key={record.id}
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs"
>
<Badge variant="outline" className="shrink-0">
{record.type}
</Badge>
<span className="font-medium truncate">{record.name}</span>
<span className="text-muted-foreground truncate flex-1">
→ {record.content}
</span>
{canWrite && isEditable && (
<HandleDnsRecord
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
record={record}
/>
)}
{canDelete && (
<DialogAction
title="Delete Record"
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
type="destructive"
onClick={async () => {
await deleteRecord({
dnsProviderId,
zoneId,
recordId: record.id,
})
.then(() => {
toast.success("Record deleted");
utils.dnsProvider.listRecords.invalidate({
dnsProviderId,
zoneId,
});
})
.catch(() => {
toast.error("Error deleting the record");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 size-6"
isLoading={isDeleting}
>
<Trash2 className="size-3.5 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
})}
{canWrite && (
<div className="pt-1">
<HandleDnsRecord
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
/>
</div>
)}
</div>
);
};
interface Props {
dnsProviderId: string;
providerName: string;
}
export const ShowDnsProviderZones = ({
dnsProviderId,
providerName,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [expandedZoneId, setExpandedZoneId] = useState<string | null>(null);
const { data, isLoading, isError, error } =
api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen });
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setExpandedZoneId(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Globe className="size-3.5 mr-1.5" />
View Domains
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Domains for {providerName}</DialogTitle>
<DialogDescription>
Zones this provider's API token can manage. Click a zone to see and
manage its records.
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground py-6">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No zones found for this token. Make sure it has access to at least
one zone.
</p>
)}
{data && data.length > 0 && (
<div className="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
{data.map((zone) => {
const isExpanded = expandedZoneId === zone.id;
return (
<div key={zone.id} className="rounded-md border">
<button
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left"
onClick={() =>
setExpandedZoneId(isExpanded ? null : zone.id)
}
>
{isExpanded ? (
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="size-3.5 text-muted-foreground shrink-0" />
)}
<Globe className="size-3.5 text-muted-foreground shrink-0" />
{zone.name}
</button>
{isExpanded && (
<ZoneRecords
dnsProviderId={dnsProviderId}
zoneId={zone.id}
zoneName={zone.name}
/>
)}
</div>
);
})}
</div>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,145 @@
import { Globe, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { HandleDnsProvider } from "./handle-dns-provider";
import { ShowDnsProviderZones } from "./show-dns-provider-zones";
const providerLabels: Record<string, string> = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
};
export const ShowDnsProviders = () => {
const { mutateAsync, isPending: isRemoving } =
api.dnsProvider.remove.useMutation();
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Globe className="size-6 text-muted-foreground self-center" />
DNS Providers
</CardTitle>
<CardDescription>
Connect a DNS provider so Dokploy can create the A/CNAME record
for a domain instead of you setting it up by hand.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Globe className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any DNS providers configured
</span>
{permissions?.dnsProvider.create && <HandleDnsProvider />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg">
{data?.map((provider) => {
const ProviderIcon =
dnsProviderIcons[provider.providerType];
return (
<div
key={provider.dnsProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
<ProviderIcon className="size-7 shrink-0" />
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
{provider.name}
</span>
<Badge variant="outline">
{providerLabels[provider.providerType] ??
provider.providerType}
</Badge>
</div>
</div>
<div className="flex flex-row gap-1">
<ShowDnsProviderZones
dnsProviderId={provider.dnsProviderId}
providerName={provider.name}
/>
{permissions?.dnsProvider.update && (
<HandleDnsProvider
dnsProviderId={provider.dnsProviderId}
/>
)}
{permissions?.dnsProvider.delete && (
<DialogAction
title="Delete DNS Provider"
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
type="destructive"
onClick={async () => {
await mutateAsync({
dnsProviderId: provider.dnsProviderId,
})
.then(() => {
toast.success("DNS provider deleted");
refetch();
})
.catch(() => {
toast.error(
"Error deleting the DNS provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
);
})}
</div>
{permissions?.dnsProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleDnsProvider />
</div>
)}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -13,6 +13,7 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false);
@@ -23,6 +24,9 @@ export const AddGithubProvider = () => {
const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false);
const [organizationName, setOrganization] = useState("");
const [githubUrl, setGithubUrl] = useState(DEFAULT_GITHUB_URL);
const { baseUrl, error: githubUrlError } = resolveGithubBaseUrl(githubUrl);
const randomString = () => Math.random().toString(36).slice(2, 8);
@@ -30,7 +34,7 @@ export const AddGithubProvider = () => {
const url = document.location.origin;
const manifest = JSON.stringify(
{
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}`,
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}&githubUrl=${encodeURIComponent(baseUrl)}`,
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`,
url: origin,
hook_attributes: {
@@ -52,7 +56,7 @@ export const AddGithubProvider = () => {
);
setManifest(manifest);
}, [activeOrganization?.id, session?.user?.id]);
}, [activeOrganization?.id, session?.user?.id, baseUrl]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -79,6 +83,25 @@ export const AddGithubProvider = () => {
below to get started.
</p>
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm">GitHub URL</span>
<Input
placeholder={DEFAULT_GITHUB_URL}
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
/>
<span className="text-muted-foreground text-xs">
Leave as is for github.com. For GitHub Enterprise, use your
instance URL (e.g. https://acme.ghe.com or
https://github.acme.com).
</span>
{githubUrlError && (
<span className="text-destructive text-xs">
{githubUrlError}
</span>
)}
</div>
<div className="flex flex-row gap-4">
<span>Organization?</span>
<Switch
@@ -98,8 +121,8 @@ export const AddGithubProvider = () => {
<form
action={
isOrganization
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
? `${baseUrl}/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `${baseUrl}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
}
method="post"
>
@@ -116,22 +139,25 @@ export const AddGithubProvider = () => {
<a
href={
isOrganization && organizationName
? `https://github.com/organizations/${organizationName}/settings/installations`
: "https://github.com/settings/installations"
? `${baseUrl}/organizations/${organizationName}/settings/installations`
: `${baseUrl}/settings/installations`
}
className={`text-muted-foreground text-sm hover:underline duration-300
${
isOrganization && !organizationName
? "pointer-events-none opacity-50"
: ""
}`}
}`}
target="_blank"
rel="noopener noreferrer"
>
Unsure if you already have an app?
</a>
<Button
disabled={isOrganization && organizationName.length < 1}
disabled={
!!githubUrlError ||
(isOrganization && organizationName.length < 1)
}
type="submit"
className="self-end"
>
@@ -147,6 +147,15 @@ export const EditGithubProvider = ({ githubId }: Props) => {
)}
/>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">GitHub URL</span>
<Input value={github?.githubUrl ?? ""} readOnly />
<span className="text-muted-foreground text-xs">
Set when the app was created and not editable, the app
credentials belong to this instance.
</span>
</div>
<div className="flex w-full justify-between gap-4 mt-4">
<Button
type="button"
@@ -33,6 +33,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
import { useUrl } from "@/utils/hooks/use-url";
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
@@ -161,6 +162,14 @@ export const ShowGitProviders = () => {
<span className="text-sm font-medium">
{gitProvider.name}
</span>
{isGithub &&
gitProvider.github?.githubUrl &&
gitProvider.github.githubUrl !==
DEFAULT_GITHUB_URL && (
<span className="text-xs text-muted-foreground">
{gitProvider.github.githubUrl}
</span>
)}
<span className="text-xs text-muted-foreground">
{formatDate(
gitProvider.createdAt,
@@ -0,0 +1,158 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PlusIcon, ServerIcon, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const Schema = z.object({
providers: z.array(
z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
}),
),
});
type Schema = z.infer<typeof Schema>;
export const HandleAiProviders = () => {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { data: providers } = api.ai.getCustomProviders.useQuery();
const { mutateAsync, isPending } = api.ai.saveCustomProviders.useMutation();
const form = useForm<Schema>({
resolver: zodResolver(Schema),
defaultValues: {
providers: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "providers",
});
useEffect(() => {
if (open) {
form.reset({ providers: providers ?? [] });
}
}, [open, providers, form]);
const onSubmit = async (data: Schema) => {
try {
await mutateAsync({ providers: data.providers });
await utils.ai.getCustomProviders.invalidate();
toast.success("Custom providers saved successfully");
setOpen(false);
} catch (error) {
toast.error("Failed to save custom providers", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="cursor-pointer space-x-3">
<ServerIcon className="h-4 w-4" />
Custom Presets
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Custom AI Providers</DialogTitle>
<DialogDescription>
Define your own AI providers, like an internal LLM platform. When at
least one is defined, only these providers can be used in AI
configurations.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No custom providers defined. The built-in provider list will be
used.
</p>
)}
{fields.map((fieldItem, index) => (
<div key={fieldItem.id} className="flex gap-2 items-start">
<FormField
control={form.control}
name={`providers.${index}.name`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="Internal LLM" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`providers.${index}.apiUrl`}
render={({ field }) => (
<FormItem className="flex-[2]">
<FormControl>
<Input
placeholder="https://llm.internal.company/v1"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
onClick={() => remove(index)}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</div>
))}
<div className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => append({ name: "", apiUrl: "" })}
>
<PlusIcon className="h-4 w-4" />
Add Provider
</Button>
<Button type="submit" isLoading={isPending}>
Save
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -99,6 +99,11 @@ export const HandleAi = ({ aiId }: Props) => {
enabled: !!aiId,
},
);
const { data: customProviders } = api.ai.getCustomProviders.useQuery();
const hasCustomProviders = (customProviders?.length ?? 0) > 0;
const providerOptions: { name: string; apiUrl: string }[] = hasCustomProviders
? (customProviders ?? [])
: [...AI_PROVIDERS];
const { mutateAsync, isPending } = aiId
? api.ai.update.useMutation()
: api.ai.create.useMutation();
@@ -210,7 +215,9 @@ export const HandleAi = ({ aiId }: Props) => {
<FormLabel>Provider</FormLabel>
<Select
onValueChange={(value) => {
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
const provider = providerOptions.find(
(p) => p.apiUrl === value,
);
if (provider) {
form.setValue("name", provider.name);
form.setValue("apiUrl", provider.apiUrl);
@@ -222,15 +229,20 @@ export const HandleAi = ({ aiId }: Props) => {
<SelectValue placeholder="Select a provider preset..." />
</SelectTrigger>
<SelectContent>
{AI_PROVIDERS.map((provider) => (
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
{providerOptions.map((provider) => (
<SelectItem
key={`${provider.name}-${provider.apiUrl}`}
value={provider.apiUrl}
>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.8rem] text-muted-foreground">
Quick-fill provider name and URL, or configure manually below
{hasCustomProviders
? "Select one of the providers defined by your organization"
: "Quick-fill provider name and URL, or configure manually below"}
</p>
</div>
@@ -260,6 +272,7 @@ export const HandleAi = ({ aiId }: Props) => {
<FormControl>
<Input
placeholder="https://api.openai.com/v1"
disabled={hasCustomProviders}
{...field}
onChange={(e) => {
field.onChange(e);
@@ -271,7 +284,9 @@ export const HandleAi = ({ aiId }: Props) => {
/>
</FormControl>
<FormDescription>
The base URL for your AI provider's API
{hasCustomProviders
? "The API URL is defined by your organization's providers"
: "The base URL for your AI provider's API"}
</FormDescription>
<FormMessage />
</FormItem>
@@ -100,7 +100,7 @@ export const Enable2FA = () => {
});
if (result.error) {
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
if (result.error.code === "INVALID_CODE") {
toast.error("Invalid verification code");
return;
}
@@ -0,0 +1,170 @@
import { Fingerprint, Loader2, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
export const ManagePasskeys = () => {
const utils = api.useUtils();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { data: passkeys, isLoading } = api.user.listPasskeys.useQuery(
undefined,
{
enabled: isDialogOpen,
},
);
const [name, setName] = useState("");
const [isAdding, setIsAdding] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleAddPasskey = async (e: React.FormEvent) => {
e.preventDefault();
setIsAdding(true);
try {
const result = await authClient.passkey.addPasskey({
name: name.trim() || undefined,
});
if (result?.error) {
toast.error(result.error.message);
return;
}
toast.success("Passkey added successfully");
setName("");
utils.user.listPasskeys.invalidate();
} catch {
toast.error("Failed to add passkey");
} finally {
setIsAdding(false);
}
};
const handleDeletePasskey = async (id: string) => {
setDeletingId(id);
try {
const result = await authClient.passkey.deletePasskey({ id });
if (result?.error) {
toast.error(result.error.message);
return;
}
toast.success("Passkey removed");
utils.user.listPasskeys.invalidate();
} catch {
toast.error("Failed to remove passkey");
} finally {
setDeletingId(null);
}
};
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary">
<Fingerprint className="size-4 text-muted-foreground" />
Passkeys
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Passkeys</DialogTitle>
<DialogDescription>
Sign in without a password using your device's biometrics, security
key, or password manager.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[10vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : passkeys && passkeys.length > 0 ? (
<div className="grid gap-3">
{passkeys.map((passkey) => (
<div
key={passkey.id}
className="flex items-center justify-between gap-2 p-4 border rounded-lg"
>
<div className="flex flex-col gap-1 min-w-0">
<span className="font-medium flex items-center gap-2">
<Fingerprint className="size-4 text-muted-foreground shrink-0" />
<span className="truncate">
{passkey.name || "Unnamed passkey"}
</span>
<Badge variant="outline">
{passkey.deviceType === "singleDevice"
? "Device"
: "Synced"}
</Badge>
</span>
{passkey.createdAt && (
<DateTooltip
date={passkey.createdAt.toString()}
className="text-sm"
>
Added
</DateTooltip>
)}
</div>
<DialogAction
title="Remove this passkey?"
description="You will no longer be able to sign in with it. This action cannot be undone."
onClick={() => handleDeletePasskey(passkey.id)}
>
<Button
variant="ghost"
size="icon"
isLoading={deletingId === passkey.id}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center gap-2 py-6 text-sm text-muted-foreground border rounded-lg">
<Fingerprint className="size-6" />
<span>No passkeys registered yet</span>
</div>
)}
<form onSubmit={handleAddPasskey} className="flex flex-col gap-2">
<Label htmlFor="passkey-name">Passkey Name</Label>
<div className="flex gap-2">
<Input
id="passkey-name"
placeholder="e.g. MacBook Touch ID"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button type="submit" isLoading={isAdding}>
<Plus className="size-4" />
Add Passkey
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
);
};
@@ -31,6 +31,7 @@ import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import { Configure2FA } from "./configure-2fa";
import { Enable2FA } from "./enable-2fa";
import { ManagePasskeys } from "./manage-passkeys";
const profileSchema = z.object({
email: z
@@ -162,7 +163,10 @@ export const ProfileForm = () => {
</CardDescription>
</div>
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
<div className="flex flex-row gap-2 flex-wrap">
<ManagePasskeys />
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
</div>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -19,8 +19,6 @@ export const ShowDokployActions = () => {
const { mutateAsync: reloadServer, isPending } =
api.settings.reloadServer.useMutation();
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
const { mutateAsync: cleanAllDeploymentQueue } =
api.settings.cleanAllDeploymentQueue.useMutation();
@@ -70,21 +68,6 @@ export const ShowDokployActions = () => {
</DropdownMenuItem>
</UpdateServerIp>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await cleanRedis()
.then(async () => {
toast.success("Redis cleaned");
})
.catch(() => {
toast.error("Error cleaning Redis");
});
}}
>
Clean Redis
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
@@ -99,21 +82,6 @@ export const ShowDokployActions = () => {
>
Clean all deployment queue
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await reloadRedis()
.then(async () => {
toast.success("Redis reloaded");
})
.catch(() => {
toast.error("Error reloading Redis");
});
}}
>
Reload Redis
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
@@ -70,9 +70,9 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
<TooltipContent side="top" className="max-w-sm">
<p>
Runs a full Docker cleanup daily, pruning stopped containers,
unused images, volumes, build cache, and system resources. This
may remove images built for Compose services that run on-demand
(backup runners, cron jobs, one-off tasks).
unused images, build cache, and system resources. This may remove
images built for Compose services that run on-demand (backup
runners, cron jobs, one-off tasks).
</p>
<p className="mt-1">
For custom cleanup strategies, use{" "}
@@ -106,6 +106,7 @@ export const AddInvitation = () => {
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
api.user.createUserWithCredentials.useMutation();
const { data: customRoles } = api.customRole.all.useQuery();
const { data: activeOrganization } = api.organization.active.useQuery();
const [error, setError] = useState<string | null>(null);
const form = useForm<AddInvitation>({
@@ -132,6 +133,16 @@ export const AddInvitation = () => {
}
}, [form, isCloud]);
useEffect(() => {
if (
activeOrganization?.defaultRole &&
activeOrganization.defaultRole !== "owner" &&
!form.formState.dirtyFields.role
) {
form.setValue("role", activeOrganization.defaultRole);
}
}, [form, activeOrganization?.defaultRole]);
const onSubmit = async (data: AddInvitation) => {
setError(null);
@@ -267,10 +278,7 @@ export const AddInvitation = () => {
return (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
import { Loader2, Trash2, Vault } from "lucide-react";
import { toast } from "sonner";
import { vaultProviderIcons } from "@/components/icons/vault-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { HandleVaultProvider } from "./handle-vault-provider";
const providerLabels: Record<string, string> = {
hashicorp: "HashiCorp Vault",
infisical: "Infisical",
aws: "AWS Secrets Manager",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
};
export const ShowVaultProviders = () => {
const { mutateAsync, isPending: isRemoving } =
api.vaultProvider.remove.useMutation();
const { data, isPending, refetch } = api.vaultProvider.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Vault className="size-6 text-muted-foreground self-center" />
Secrets Providers
</CardTitle>
<CardDescription>
Connect external secret managers and reference their secrets in
environment variables with{" "}
<code>{"${{vault.<name>.<secret>}}"}</code>
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Vault className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any secrets providers configured
</span>
{permissions?.vaultProvider.create && (
<HandleVaultProvider />
)}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg">
{data?.map((provider) => {
const ProviderIcon =
vaultProviderIcons[provider.providerType];
return (
<div
key={provider.vaultProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
<ProviderIcon className="size-7 shrink-0" />
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
{provider.name}
</span>
<div className="flex flex-row gap-2 items-center">
{provider.assignments.length === 0 ? (
<Badge variant="destructive">
Not assigned
</Badge>
) : (
<Badge variant="secondary">
{provider.assignments.length}{" "}
{provider.assignments.length === 1
? "project"
: "projects"}
</Badge>
)}
<Badge variant="outline">
{providerLabels[provider.providerType] ??
provider.providerType}
</Badge>
<span className="text-xs text-muted-foreground">
{"${{vault." + provider.name + ".…}}"}
</span>
</div>
</div>
</div>
<div className="flex flex-row gap-1">
{permissions?.vaultProvider.update && (
<HandleVaultProvider
vaultProviderId={provider.vaultProviderId}
/>
)}
{permissions?.vaultProvider.delete && (
<DialogAction
title="Delete Secrets Provider"
description="Deployments referencing this provider will fail. Are you sure?"
type="destructive"
onClick={async () => {
await mutateAsync({
vaultProviderId:
provider.vaultProviderId,
})
.then(() => {
toast.success(
"Secrets provider deleted",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting the secrets provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
);
})}
</div>
{permissions?.vaultProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleVaultProvider />
</div>
)}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -40,6 +40,7 @@ interface Props {
children?: React.ReactNode;
serverId?: string;
appType?: "stack" | "docker-compose";
serviceId?: string;
}
export const DockerTerminalModal = ({
@@ -47,6 +48,7 @@ export const DockerTerminalModal = ({
appName,
serverId,
appType,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -131,6 +133,7 @@ export const DockerTerminalModal = ({
serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"}
serviceId={serviceId}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
@@ -32,7 +32,9 @@ export const TerminalModal = ({
serverId,
asButton = false,
}: Props) => {
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
const [terminalKey, setTerminalKey] = useState<string>(() =>
getTerminalKey(),
);
const [isOpen, setIsOpen] = useState(false);
const isLocalServer = serverId === "local";
@@ -6,6 +6,7 @@ import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { useTheme } from "next-themes";
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
import { getLocalServerData } from "./local-server-config";
interface Props {
@@ -58,6 +59,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
const addonAttach = new AttachAddon(ws);
const clipboardAddon = new ClipboardAddon();
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-ignore
term.open(termRef.current);
@@ -30,7 +30,6 @@ type ServiceStatus = {
type HealthResult = {
postgres: ServiceStatus;
redis: ServiceStatus;
traefik: ServiceStatus;
};
@@ -89,7 +88,6 @@ export const UpdateWebServer = ({
const allHealthy =
healthResult &&
healthResult.postgres.status === "healthy" &&
healthResult.redis.status === "healthy" &&
healthResult.traefik.status === "healthy";
const checkIsUpdateFinished = async () => {
@@ -179,7 +177,7 @@ export const UpdateWebServer = ({
{modalState === "checking" && (
<span className="flex items-center gap-2">
<Loader2 className="animate-spin h-4 w-4" />
Checking PostgreSQL, Redis and Traefik...
Checking PostgreSQL and Traefik...
</span>
)}
@@ -190,10 +188,6 @@ export const UpdateWebServer = ({
name="PostgreSQL"
service={healthResult.postgres}
/>
<ServiceStatusItem
name="Redis"
service={healthResult.redis}
/>
<ServiceStatusItem
name="Traefik"
service={healthResult.traefik}
@@ -1,5 +1,6 @@
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
import { RebuildDatabase } from "./rebuild-database";
@@ -21,6 +22,7 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
<ShowClusterSettings id={id} type={type} />
) : null}
<ShowVolumes id={id} type={type} />
<AssignNetworks id={id} type={type} />
<ShowResources id={id} type={type} />
<RebuildDatabase id={id} type={type} />
</div>
@@ -35,7 +35,7 @@ export const DocLinks = () => (
<ExternalLink className="h-3 w-3" />
</a>
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
>
Cluster Settings
@@ -85,7 +85,7 @@ export const SwarmNotAvailable = ({
<li>
Check the{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-primary underline underline-offset-4"
>
Cluster Settings
@@ -130,7 +130,7 @@ export const ServicesError = ({
<li>
Network connectivity issues to a remote server &mdash; check{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-primary underline underline-offset-4"
>
Cluster Settings
@@ -183,7 +183,7 @@ export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => (
Worker nodes need to pull images from a shared registry. Configure one
in{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-primary underline underline-offset-4"
>
Cluster Settings
@@ -255,7 +255,7 @@ export const NoRunningContainers = ({
<li>
Images can&apos;t be pulled on worker nodes &mdash; verify your{" "}
<Link
href="/dashboard/settings/cluster"
href="/dashboard/docker?tab=swarm&subtab=nodes"
className="text-primary underline underline-offset-4"
>
registry configuration

Some files were not shown because too many files have changed in this diff Show More