mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-18 05:35:26 +02:00
Compare commits
10 Commits
feat/scim-
...
feat/add-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f508163e5 | ||
|
|
fc75b847b5 | ||
|
|
b1abcb9e06 | ||
|
|
d542972522 | ||
|
|
e7c38d4c54 | ||
|
|
a8f941b5d9 | ||
|
|
c68fac55fd | ||
|
|
efcad7bbf5 | ||
|
|
a0566cdbd7 | ||
|
|
69598821ed |
42
.github/workflows/sync-openapi-docs.yml
vendored
42
.github/workflows/sync-openapi-docs.yml
vendored
@@ -68,45 +68,3 @@ jobs:
|
||||
|
||||
echo "✅ OpenAPI synced to website successfully"
|
||||
|
||||
- name: Sync to MCP repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
|
||||
|
||||
cd mcp-repo
|
||||
|
||||
cp -f ../openapi.json openapi.json
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to MCP repository successfully"
|
||||
|
||||
- name: Sync to CLI repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
|
||||
|
||||
cd cli-repo
|
||||
|
||||
cp -f ../openapi.json openapi.json
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to CLI repository successfully"
|
||||
|
||||
|
||||
80
.github/workflows/sync-version.yml
vendored
80
.github/workflows/sync-version.yml
vendored
@@ -1,80 +0,0 @@
|
||||
name: Sync version to MCP and CLI repos
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-version:
|
||||
name: Sync version to external repos
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Dokploy repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json | sed 's/^v//')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Sync version to MCP repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||
cd /tmp/mcp-repo
|
||||
|
||||
# Regenerate tools from latest OpenAPI spec
|
||||
npm install -g pnpm
|
||||
pnpm install
|
||||
pnpm run fetch-openapi
|
||||
pnpm run generate
|
||||
|
||||
# Bump version after install so pnpm install doesn't overwrite it
|
||||
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Release: ${{ github.event.release.html_url }}" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
|
||||
- name: Sync version to CLI repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||
|
||||
cd /tmp/cli-repo
|
||||
|
||||
# Copy latest openapi spec and regenerate commands
|
||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||
npm install -g pnpm
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
# Bump version after install so pnpm install doesn't overwrite it
|
||||
if [ -f package.json ]; then
|
||||
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
fi
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Release: ${{ github.event.release.html_url }}" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
@@ -666,7 +666,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Custom Entrypoint</FormLabel>
|
||||
<FormDescription>
|
||||
Use custom entrypoint for domain
|
||||
Use custom entrypoint for domina
|
||||
<br />
|
||||
"web" and/or "websecure" is used by default.
|
||||
</FormDescription>
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
|
||||
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
|
||||
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
|
||||
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
|
||||
|
||||
const DockerLogsId = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/docker/logs/docker-logs-id").then(
|
||||
(e) => e.DockerLogsId,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
interface Props {
|
||||
appName: string;
|
||||
serverId?: string;
|
||||
appType: "stack" | "docker-compose";
|
||||
}
|
||||
|
||||
export const ShowComposeContainers = ({
|
||||
appName,
|
||||
appType,
|
||||
serverId,
|
||||
}: Props) => {
|
||||
const { data, isPending, refetch } =
|
||||
api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
appName,
|
||||
appType,
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!appName,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Containers</CardTitle>
|
||||
<CardDescription>
|
||||
Inspect each container in this compose and run basic lifecycle
|
||||
actions.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => refetch()}
|
||||
disabled={isPending}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center h-[20vh]">
|
||||
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[20vh]">
|
||||
<span className="text-muted-foreground">
|
||||
No containers found. Deploy the compose to see containers here.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Container ID</TableHead>
|
||||
<TableHead className="text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((container) => (
|
||||
<ContainerRow
|
||||
key={container.containerId}
|
||||
container={container}
|
||||
serverId={serverId}
|
||||
onActionComplete={() => refetch()}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContainerRowProps {
|
||||
container: {
|
||||
containerId: string;
|
||||
name: string;
|
||||
state: string;
|
||||
status: string;
|
||||
};
|
||||
serverId?: string;
|
||||
onActionComplete: () => void;
|
||||
}
|
||||
|
||||
const ContainerRow = ({
|
||||
container,
|
||||
serverId,
|
||||
onActionComplete,
|
||||
}: ContainerRowProps) => {
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const restartMutation = api.docker.restartContainer.useMutation();
|
||||
const startMutation = api.docker.startContainer.useMutation();
|
||||
const stopMutation = api.docker.stopContainer.useMutation();
|
||||
const killMutation = api.docker.killContainer.useMutation();
|
||||
|
||||
const handleAction = async (
|
||||
action: string,
|
||||
mutationFn: typeof restartMutation,
|
||||
) => {
|
||||
setActionLoading(action);
|
||||
try {
|
||||
await mutationFn.mutateAsync({
|
||||
containerId: container.containerId,
|
||||
serverId,
|
||||
});
|
||||
toast.success(`Container ${action} successfully`);
|
||||
onActionComplete();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{container.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
container.state === "running"
|
||||
? "default"
|
||||
: container.state === "exited"
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{container.state}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{container.status}</TableCell>
|
||||
<TableCell className="font-mono text-sm text-muted-foreground">
|
||||
{container.containerId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
{actionLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
View Logs
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
interface Mount {
|
||||
Type: string;
|
||||
Source: string;
|
||||
Destination: string;
|
||||
Mode: string;
|
||||
RW: boolean;
|
||||
Propagation: string;
|
||||
Name?: string;
|
||||
Driver?: string;
|
||||
}
|
||||
|
||||
export const ShowContainerMounts = ({ containerId, serverId }: Props) => {
|
||||
const { data } = api.docker.getConfig.useQuery(
|
||||
{
|
||||
containerId,
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!containerId,
|
||||
},
|
||||
);
|
||||
|
||||
const mounts: Mount[] = data?.Mounts ?? [];
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
View Mounts
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Container Mounts</DialogTitle>
|
||||
<DialogDescription>
|
||||
Volume and bind mounts for this container
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto max-h-[70vh]">
|
||||
{mounts.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
No mounts found for this container.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead>Destination</TableHead>
|
||||
<TableHead>Mode</TableHead>
|
||||
<TableHead>Read/Write</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mounts.map((mount, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{mount.Type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
||||
{mount.Name || mount.Source}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
||||
{mount.Destination}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{mount.Mode || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={mount.RW ? "default" : "secondary"}>
|
||||
{mount.RW ? "RW" : "RO"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
interface Network {
|
||||
IPAMConfig: unknown;
|
||||
Links: unknown;
|
||||
Aliases: string[] | null;
|
||||
MacAddress: string;
|
||||
NetworkID: string;
|
||||
EndpointID: string;
|
||||
Gateway: string;
|
||||
IPAddress: string;
|
||||
IPPrefixLen: number;
|
||||
IPv6Gateway: string;
|
||||
GlobalIPv6Address: string;
|
||||
GlobalIPv6PrefixLen: number;
|
||||
DriverOpts: unknown;
|
||||
}
|
||||
|
||||
export const ShowContainerNetworks = ({ containerId, serverId }: Props) => {
|
||||
const { data } = api.docker.getConfig.useQuery(
|
||||
{
|
||||
containerId,
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!containerId,
|
||||
},
|
||||
);
|
||||
|
||||
const networks: Record<string, Network> =
|
||||
data?.NetworkSettings?.Networks ?? {};
|
||||
const entries = Object.entries(networks);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
View Networks
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Container Networks</DialogTitle>
|
||||
<DialogDescription>
|
||||
Networks attached to this container
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto max-h-[70vh]">
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
No networks found for this container.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Network</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Gateway</TableHead>
|
||||
<TableHead>MAC Address</TableHead>
|
||||
<TableHead>Aliases</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map(([name, network]) => (
|
||||
<TableRow key={name}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{name}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{network.IPAddress
|
||||
? `${network.IPAddress}/${network.IPPrefixLen}`
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{network.Gateway || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{network.MacAddress || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{network.Aliases?.join(", ") || "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ShowContainerConfig } from "../config/show-container-config";
|
||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||
import { ShowContainerMounts } from "../mounts/show-container-mounts";
|
||||
import { ShowContainerNetworks } from "../networks/show-container-networks";
|
||||
import { RemoveContainerDialog } from "../remove/remove-container";
|
||||
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
||||
import { UploadFileModal } from "../upload/upload-file-modal";
|
||||
@@ -125,14 +123,6 @@ export const columns: ColumnDef<Container>[] = [
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
/>
|
||||
<ShowContainerMounts
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
/>
|
||||
<ShowContainerNetworks
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
/>
|
||||
<DockerTerminalModal
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ArrowRight, Rocket, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type DeploymentStatus = "idle" | "running" | "done" | "error";
|
||||
|
||||
const statusDotClass: Record<string, string> = {
|
||||
done: "bg-emerald-500",
|
||||
running: "bg-amber-500",
|
||||
error: "bg-red-500",
|
||||
idle: "bg-muted-foreground/40",
|
||||
};
|
||||
|
||||
function getServiceInfo(d: any) {
|
||||
const app = d.application;
|
||||
const comp = d.compose;
|
||||
const serverName: string =
|
||||
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
|
||||
if (app?.environment?.project && app.environment) {
|
||||
return {
|
||||
name: app.name as string,
|
||||
environment: app.environment.name as string,
|
||||
projectName: app.environment.project.name as string,
|
||||
serverName,
|
||||
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
|
||||
};
|
||||
}
|
||||
if (comp?.environment?.project && comp.environment) {
|
||||
return {
|
||||
name: comp.name as string,
|
||||
environment: comp.environment.name as string,
|
||||
projectName: comp.environment.project.name as string,
|
||||
serverName,
|
||||
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
delta?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-3xl font-semibold tracking-tight">{value}</span>
|
||||
{delta && (
|
||||
<span className="text-xs text-muted-foreground">{delta}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusListCard({
|
||||
label,
|
||||
items,
|
||||
}: {
|
||||
label: string;
|
||||
items: { dotClass: string; label: string; count: number }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{items.map((item) => (
|
||||
<li key={item.label} className="flex items-center gap-2.5 text-sm">
|
||||
<span
|
||||
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="font-semibold tabular-nums w-8">{item.count}</span>
|
||||
<span className="text-muted-foreground">{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ShowHome = () => {
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: homeStats } = api.project.homeStats.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canReadDeployments = !!permissions?.deployment.read;
|
||||
const { data: deployments } = api.deployment.allCentralized.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: canReadDeployments,
|
||||
refetchInterval: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
const firstName = auth?.user?.firstName?.trim();
|
||||
|
||||
const totals = homeStats ?? {
|
||||
projects: 0,
|
||||
environments: 0,
|
||||
applications: 0,
|
||||
compose: 0,
|
||||
databases: 0,
|
||||
services: 0,
|
||||
};
|
||||
const statusBreakdown = homeStats?.status ?? {
|
||||
running: 0,
|
||||
error: 0,
|
||||
idle: 0,
|
||||
};
|
||||
|
||||
const recentDeployments = useMemo(() => {
|
||||
if (!deployments) return [];
|
||||
return [...deployments]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)
|
||||
.slice(0, 10);
|
||||
}, [deployments]);
|
||||
|
||||
const deployStats = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const lastStart = now - weekMs;
|
||||
const prevStart = now - 2 * weekMs;
|
||||
|
||||
const last: NonNullable<typeof deployments> = [];
|
||||
const prev: NonNullable<typeof deployments> = [];
|
||||
for (const d of deployments ?? []) {
|
||||
const t = new Date(d.createdAt).getTime();
|
||||
if (t >= lastStart) last.push(d);
|
||||
else if (t >= prevStart) prev.push(d);
|
||||
}
|
||||
|
||||
const lastCount = last.length;
|
||||
const prevCount = prev.length;
|
||||
let delta: string | undefined;
|
||||
if (prevCount > 0) {
|
||||
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
|
||||
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
|
||||
} else if (lastCount > 0) {
|
||||
delta = "no prior data";
|
||||
} else {
|
||||
delta = "no activity yet";
|
||||
}
|
||||
|
||||
return { value: String(lastCount), delta };
|
||||
}, [deployments]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[85vh]">
|
||||
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6 h-full">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
|
||||
</h1>
|
||||
<Button asChild variant="secondary" className="w-fit">
|
||||
<Link href="/dashboard/projects">
|
||||
Go to projects
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Projects"
|
||||
value={String(totals.projects)}
|
||||
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Services"
|
||||
value={String(totals.services)}
|
||||
delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Deploys / 7d"
|
||||
value={deployStats.value}
|
||||
delta={deployStats.delta}
|
||||
/>
|
||||
<StatusListCard
|
||||
label="Status"
|
||||
items={[
|
||||
{
|
||||
dotClass: "bg-emerald-500",
|
||||
label: "running",
|
||||
count: statusBreakdown.running,
|
||||
},
|
||||
{
|
||||
dotClass: "bg-red-500",
|
||||
label: "errored",
|
||||
count: statusBreakdown.error,
|
||||
},
|
||||
{
|
||||
dotClass: "bg-muted-foreground/40",
|
||||
label: "idle",
|
||||
count: statusBreakdown.idle,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-background">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Rocket className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">Recent deployments</h2>
|
||||
</div>
|
||||
{canReadDeployments && (
|
||||
<Link
|
||||
href="/dashboard/deployments"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
view all →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{!canReadDeployments ? (
|
||||
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
|
||||
<Rocket className="size-8 opacity-40" />
|
||||
<span>You do not have permission to view deployments.</span>
|
||||
</div>
|
||||
) : recentDeployments.length === 0 ? (
|
||||
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
|
||||
<Rocket className="size-8 opacity-40" />
|
||||
<span>No deployments yet.</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{recentDeployments.map((d) => {
|
||||
const info = getServiceInfo(d);
|
||||
if (!info) return null;
|
||||
const status = (d.status ?? "idle") as DeploymentStatus;
|
||||
return (
|
||||
<li key={d.deploymentId}>
|
||||
<Link
|
||||
href={info.href}
|
||||
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<span
|
||||
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm truncate">{info.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{info.projectName} · {info.environment}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
|
||||
<Server className="size-3 shrink-0" />
|
||||
<span className="truncate">{info.serverName}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
|
||||
{status}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
|
||||
{formatDistanceToNow(new Date(d.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
logs →
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
563
apps/dokploy/components/dashboard/networks/handle-network.tsx
Normal file
563
apps/dokploy/components/dashboard/networks/handle-network.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Network, Pencil, Plus } 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,
|
||||
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";
|
||||
|
||||
const networkDriverEnum = [
|
||||
"bridge",
|
||||
"host",
|
||||
"overlay",
|
||||
"macvlan",
|
||||
"none",
|
||||
"ipvlan",
|
||||
] as const;
|
||||
|
||||
/** Sentinel for "no scope" */
|
||||
const SCOPE_EMPTY = "__scope_none__";
|
||||
|
||||
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).default("bridge"),
|
||||
scope: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
internal: z.boolean().default(false),
|
||||
attachable: z.boolean().default(false),
|
||||
ingress: z.boolean().default(false),
|
||||
configOnly: z.boolean().default(false),
|
||||
enableIPv4: z.boolean().default(true),
|
||||
enableIPv6: z.boolean().default(false),
|
||||
ipamDriver: z.string().optional(),
|
||||
ipamConfig: z.array(ipamConfigEntrySchema).default([]),
|
||||
});
|
||||
|
||||
type NetworkFormValues = z.infer<typeof networkFormSchema>;
|
||||
|
||||
const defaultValues: NetworkFormValues = {
|
||||
name: "",
|
||||
driver: "bridge",
|
||||
scope: SCOPE_EMPTY,
|
||||
serverId: undefined,
|
||||
internal: false,
|
||||
attachable: false,
|
||||
ingress: false,
|
||||
configOnly: false,
|
||||
enableIPv4: true,
|
||||
enableIPv6: false,
|
||||
ipamDriver: "",
|
||||
ipamConfig: [],
|
||||
};
|
||||
|
||||
interface HandleNetworkProps {
|
||||
networkId?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const isEdit = !!networkId;
|
||||
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const { data: network, isLoading: isLoadingNetwork } =
|
||||
api.network.one.useQuery(
|
||||
{ networkId: networkId! },
|
||||
{ enabled: isEdit && !!networkId },
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading: isPending } = networkId
|
||||
? api.network.update.useMutation()
|
||||
: api.network.create.useMutation();
|
||||
|
||||
const form = useForm<NetworkFormValues>({
|
||||
resolver: zodResolver(networkFormSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const ipamConfigFieldArray = useFieldArray({
|
||||
control: form.control,
|
||||
name: "ipamConfig",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && network && isOpen) {
|
||||
const ipam = network.ipam ?? {};
|
||||
const ipamConfigArr = (ipam.config ?? []).map((c) => ({
|
||||
subnet: c.subnet ?? "",
|
||||
ipRange: c.ipRange ?? "",
|
||||
gateway: c.gateway ?? "",
|
||||
}));
|
||||
form.reset({
|
||||
...defaultValues,
|
||||
name: network.name,
|
||||
driver: network.driver,
|
||||
scope: network.scope ?? SCOPE_EMPTY,
|
||||
serverId: network.serverId || undefined,
|
||||
internal: network.internal,
|
||||
attachable: network.attachable,
|
||||
enableIPv4: network.enableIPv4,
|
||||
enableIPv6: network.enableIPv6,
|
||||
ipamDriver: ipam.driver ?? "",
|
||||
ipamConfig: ipamConfigArr,
|
||||
ingress: network.ingress,
|
||||
configOnly: network.configOnly,
|
||||
});
|
||||
}
|
||||
}, [isEdit, isOpen, network, form]);
|
||||
|
||||
const onSubmit = async (data: NetworkFormValues) => {
|
||||
const scope =
|
||||
data.scope && data.scope !== SCOPE_EMPTY ? data.scope : undefined;
|
||||
|
||||
try {
|
||||
await mutateAsync({
|
||||
networkId: networkId ?? "",
|
||||
name: data.name,
|
||||
driver: data.driver,
|
||||
scope,
|
||||
serverId: data.serverId || undefined,
|
||||
internal: data.internal,
|
||||
attachable: data.attachable,
|
||||
ingress: data.ingress,
|
||||
configOnly: data.configOnly,
|
||||
enableIPv4: data.enableIPv4,
|
||||
enableIPv6: data.enableIPv6,
|
||||
ipam: {
|
||||
driver: data.ipamDriver,
|
||||
config: data.ipamConfig,
|
||||
},
|
||||
});
|
||||
|
||||
await utils.network.all.invalidate();
|
||||
if (networkId) await utils.network.one.invalidate({ networkId });
|
||||
setIsOpen(false);
|
||||
form.reset(defaultValues);
|
||||
} catch {
|
||||
toast.error(isEdit ? "Error updating network" : "Error creating network");
|
||||
}
|
||||
};
|
||||
|
||||
const trigger =
|
||||
children ??
|
||||
(isEdit ? (
|
||||
<Button size="sm" variant="outline">
|
||||
<Pencil className=" size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<Plus className=" size-4" />
|
||||
Add network
|
||||
</Button>
|
||||
));
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Network className="size-5 text-muted-foreground" />
|
||||
{isEdit ? "Edit network" : "Add network"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Update this Docker network. Changes apply to name, driver, and server assignment."
|
||||
: "Create a new Docker network for your organization. You can optionally assign it to a server."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isEdit && isLoadingNetwork ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
Loading network…
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? undefined}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{!isCloud && (
|
||||
<SelectItem value={undefined}>
|
||||
Dokploy server
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
{isCloud
|
||||
? "Server where this network will be created."
|
||||
: "Dokploy server is the default local server; or choose a specific server."}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scope"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Scope (optional)</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? SCOPE_EMPTY}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select scope" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={SCOPE_EMPTY}>None</SelectItem>
|
||||
<SelectItem value="local">local</SelectItem>
|
||||
<SelectItem value="swarm">swarm</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="internal"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Internal</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Restrict external access; containers on this network
|
||||
cannot reach external networks.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attachable"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Attachable</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Allow standalone containers to attach to this network
|
||||
(e.g. in Swarm, not only services).
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableIPv4"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Enable IPv4</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Enable IPv4 addressing on the network.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableIPv6"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Enable IPv6</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Enable IPv6 addressing on the network.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ingress"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Ingress</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Use as the routing-mesh network in Swarm mode (load
|
||||
balancing between nodes).
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="configOnly"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Config only</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Create a placeholder network whose config is reused by
|
||||
other networks; cannot run containers on it.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<FormLabel>IPAM</FormLabel>
|
||||
<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"
|
||||
onClick={() => ipamConfigFieldArray.remove(index)}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
ipamConfigFieldArray.append({
|
||||
subnet: "",
|
||||
ipRange: "",
|
||||
gateway: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add IPAM config
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending
|
||||
? isEdit
|
||||
? "Updating…"
|
||||
: "Creating…"
|
||||
: isEdit
|
||||
? "Update network"
|
||||
: "Create network"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
118
apps/dokploy/components/dashboard/networks/show-networks.tsx
Normal file
118
apps/dokploy/components/dashboard/networks/show-networks.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Network } from "lucide-react";
|
||||
import { HandleNetwork } from "@/components/dashboard/networks/handle-network";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const ShowNetworks = () => {
|
||||
const { data: networks, isLoading } = api.network.all.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Network className="size-6 text-muted-foreground self-center" />
|
||||
Networks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage Docker networks for your organization. Networks can be
|
||||
scoped to a server (optional).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{networks && networks?.length > 0 && <HandleNetwork />}
|
||||
</div>
|
||||
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<div className="gap-4 pb-20 w-full">
|
||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||
<div className="rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground h-[55vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : !networks?.length ? (
|
||||
<div className="flex min-h-[55vh] 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 />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Driver</TableHead>
|
||||
<TableHead>Scope</TableHead>
|
||||
<TableHead>Internal</TableHead>
|
||||
<TableHead>Attachable</TableHead>
|
||||
<TableHead>Server</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{networks.map((n) => (
|
||||
<TableRow key={n.networkId}>
|
||||
<TableCell className="font-medium">
|
||||
{n.name}
|
||||
</TableCell>
|
||||
<TableCell>{n.driver}</TableCell>
|
||||
<TableCell>{n.scope ?? "—"}</TableCell>
|
||||
<TableCell>{n.internal ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>{n.attachable ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
{n.serverId ?? "Dokploy server"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(n.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HandleNetwork networkId={n.networkId}>
|
||||
<Button variant="ghost" size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
</HandleNetwork>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -166,7 +166,6 @@ export const ShowProjects = () => {
|
||||
return (
|
||||
total +
|
||||
(env.applications?.length || 0) +
|
||||
(env.libsql?.length || 0) +
|
||||
(env.mariadb?.length || 0) +
|
||||
(env.mongo?.length || 0) +
|
||||
(env.mysql?.length || 0) +
|
||||
@@ -179,7 +178,6 @@ export const ShowProjects = () => {
|
||||
return (
|
||||
total +
|
||||
(env.applications?.length || 0) +
|
||||
(env.libsql?.length || 0) +
|
||||
(env.mariadb?.length || 0) +
|
||||
(env.mongo?.length || 0) +
|
||||
(env.mysql?.length || 0) +
|
||||
|
||||
@@ -167,7 +167,7 @@ export const SearchCommand = () => {
|
||||
<CommandGroup heading={"Application"} hidden={true}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -425,7 +425,7 @@ export const WelcomeSubscription = () => {
|
||||
onClick={() => {
|
||||
if (stepper.isLast) {
|
||||
setIsOpen(false);
|
||||
push("/dashboard/home");
|
||||
push("/dashboard/projects");
|
||||
} else {
|
||||
stepper.next();
|
||||
}
|
||||
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
Forward,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
House,
|
||||
Key,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
LogIn,
|
||||
type LucideIcon,
|
||||
Network,
|
||||
Package,
|
||||
Palette,
|
||||
PieChart,
|
||||
@@ -149,12 +149,6 @@ type Menu = {
|
||||
// The `isEnabled` function is called to determine if the item should be displayed
|
||||
const MENU: Menu = {
|
||||
home: [
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Home",
|
||||
url: "/dashboard/home",
|
||||
icon: House,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Projects",
|
||||
@@ -213,6 +207,20 @@ const MENU: Menu = {
|
||||
isEnabled: ({ permissions, isCloud }) =>
|
||||
!!(permissions?.docker.read && !isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Networks",
|
||||
url: "/dashboard/networks",
|
||||
icon: Network,
|
||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!(
|
||||
(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canAccessToDocker) &&
|
||||
!isCloud
|
||||
),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Requests",
|
||||
|
||||
@@ -80,7 +80,7 @@ export const UserNav = () => {
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
}}
|
||||
>
|
||||
Projects
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ScimDialog = ({ children }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const baseURL = useUrl();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newProviderId, setNewProviderId] = useState("");
|
||||
const [justCreatedToken, setJustCreatedToken] = useState<{
|
||||
providerId: string;
|
||||
token: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data: providers = [], isPending } = api.scim.listProviders.useQuery(
|
||||
undefined,
|
||||
{ enabled: open },
|
||||
);
|
||||
const { mutateAsync: generateToken, isPending: isGenerating } =
|
||||
api.scim.generateToken.useMutation();
|
||||
const { mutateAsync: deleteProvider, isPending: isDeleting } =
|
||||
api.scim.deleteProvider.useMutation();
|
||||
|
||||
const scimUrl = `${baseURL || "{baseURL}"}/api/auth/scim/v2`;
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const providerId = newProviderId.trim().toLowerCase();
|
||||
if (!providerId) return;
|
||||
try {
|
||||
const result = await generateToken({ providerId });
|
||||
setJustCreatedToken({
|
||||
providerId: result.providerId,
|
||||
token: result.scimToken,
|
||||
});
|
||||
setNewProviderId("");
|
||||
await utils.scim.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to generate SCIM token",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (providerId: string) => {
|
||||
try {
|
||||
await deleteProvider({ providerId });
|
||||
toast.success("SCIM provider removed");
|
||||
await utils.scim.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to delete SCIM provider",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (value: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(`${label} copied`);
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) setJustCreatedToken(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-5" />
|
||||
SCIM provisioning
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Automatically provision, update, and deactivate users from your
|
||||
identity provider (Okta, Entra ID, etc.). Configure the SCIM endpoint
|
||||
below in your IdP.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
SCIM 2.0 endpoint URL
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="flex-1 break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||
{scimUrl}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() => handleCopy(scimUrl, "Endpoint URL")}
|
||||
disabled={!baseURL}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{justCreatedToken && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
|
||||
<p className="text-sm font-medium">
|
||||
Bearer token for {justCreatedToken.providerId}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Copy this token now — it will not be shown again. Paste it into
|
||||
your IdP's SCIM configuration.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<p className="flex-1 break-all rounded-md bg-background px-2 py-1.5 font-mono text-xs">
|
||||
{justCreatedToken.token}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() =>
|
||||
handleCopy(justCreatedToken.token, "Bearer token")
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Generate token for a new provider
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newProviderId}
|
||||
onChange={(e) => setNewProviderId(e.target.value)}
|
||||
placeholder="okta, entra, jumpcloud..."
|
||||
className="font-mono text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleGenerate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={!newProviderId.trim() || isGenerating}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose a unique identifier for this IdP connection (lowercase,
|
||||
alphanumeric, dashes).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Existing providers</Label>
|
||||
{isPending ? (
|
||||
<div className="flex items-center gap-2 justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No SCIM providers configured yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{providers.map((provider) => (
|
||||
<li
|
||||
key={provider.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
|
||||
>
|
||||
<span className="flex-1 font-mono text-sm">
|
||||
{provider.providerId}
|
||||
</span>
|
||||
<DialogAction
|
||||
title="Remove SCIM provider"
|
||||
description={`Remove "${provider.providerId}"? Existing provisioned users will stay but the IdP will no longer be able to sync.`}
|
||||
type="destructive"
|
||||
onClick={() => handleDelete(provider.providerId)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 text-destructive hover:text-destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -44,7 +44,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.sso({
|
||||
email: values.email,
|
||||
callbackURL: "/dashboard/home",
|
||||
callbackURL: "/dashboard/projects",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to sign in with SSO");
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
Eye,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Pencil,
|
||||
@@ -35,7 +34,6 @@ import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||
import { ScimDialog } from "./scim-dialog";
|
||||
|
||||
type ProviderForDetails = {
|
||||
id: string | null;
|
||||
@@ -171,22 +169,15 @@ export const SSOSettings = () => {
|
||||
Users can sign in with their organization's IdP.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setManageOriginsOpen(true)}
|
||||
>
|
||||
<Shield className="mr-2 size-4" />
|
||||
Manage origins
|
||||
</Button>
|
||||
<ScimDialog>
|
||||
<Button variant="outline" size="sm">
|
||||
<KeyRound className="mr-2 size-4" />
|
||||
Manage SCIM
|
||||
</Button>
|
||||
</ScimDialog>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setManageOriginsOpen(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Shield className="mr-2 size-4" />
|
||||
Manage origins
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
|
||||
@@ -116,14 +116,6 @@ export function TagSelector({
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{tags.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags created yet.
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.id);
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
CREATE TABLE "scim_provider" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"scim_token" text NOT NULL,
|
||||
"organization_id" text,
|
||||
CONSTRAINT "scim_provider_provider_id_unique" UNIQUE("provider_id"),
|
||||
CONSTRAINT "scim_provider_scim_token_unique" UNIQUE("scim_token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "two_factor" ADD COLUMN "verified" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "scim_provider" ADD CONSTRAINT "scim_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
27
apps/dokploy/drizzle/0166_pale_multiple_man.sql
Normal file
27
apps/dokploy/drizzle/0166_pale_multiple_man.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
CREATE TYPE "public"."networkDriver" AS ENUM('bridge', 'host', 'overlay', 'macvlan', 'none', 'ipvlan');--> statement-breakpoint
|
||||
CREATE TABLE "network" (
|
||||
"networkId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"driver" "networkDriver" DEFAULT 'bridge' NOT NULL,
|
||||
"scope" text,
|
||||
"internal" boolean DEFAULT false NOT NULL,
|
||||
"attachable" boolean DEFAULT false NOT NULL,
|
||||
"ingress" boolean DEFAULT false NOT NULL,
|
||||
"configOnly" boolean DEFAULT false NOT NULL,
|
||||
"enableIPv4" boolean DEFAULT true NOT NULL,
|
||||
"enableIPv6" boolean DEFAULT false NOT NULL,
|
||||
"ipam" jsonb DEFAULT '{}'::jsonb,
|
||||
"createdAt" text NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
"serverId" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "compose" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "mariadb" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "mongo" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "mysql" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "postgres" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "redis" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "network" ADD CONSTRAINT "network_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "network" ADD CONSTRAINT "network_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "0e5a80ea-2d66-4bd2-a41d-b92c419e0116",
|
||||
"id": "16f45cf1-97ff-471f-a936-9a5773b8b469",
|
||||
"prevId": "ef484e78-f78d-4c3f-ae4b-aa123dc77e61",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -789,13 +789,6 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"verified": {
|
||||
"name": "verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -1570,6 +1563,13 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"networkIds": {
|
||||
"name": "networkIds",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -2582,6 +2582,13 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"networkIds": {
|
||||
"name": "networkIds",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -4097,6 +4104,13 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"networkIds": {
|
||||
"name": "networkIds",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -4342,6 +4356,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
},
|
||||
"networkIds": {
|
||||
"name": "networkIds",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -4604,6 +4625,139 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.network": {
|
||||
"name": "network",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"networkId": {
|
||||
"name": "networkId",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"driver": {
|
||||
"name": "driver",
|
||||
"type": "networkDriver",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'bridge'"
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"internal": {
|
||||
"name": "internal",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"attachable": {
|
||||
"name": "attachable",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"ingress": {
|
||||
"name": "ingress",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"configOnly": {
|
||||
"name": "configOnly",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"enableIPv4": {
|
||||
"name": "enableIPv4",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"enableIPv6": {
|
||||
"name": "enableIPv6",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"ipam": {
|
||||
"name": "ipam",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"organizationId": {
|
||||
"name": "organizationId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"serverId": {
|
||||
"name": "serverId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"network_organizationId_organization_id_fk": {
|
||||
"name": "network_organizationId_organization_id_fk",
|
||||
"tableFrom": "network",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": [
|
||||
"organizationId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"network_serverId_server_serverId_fk": {
|
||||
"name": "network_serverId_server_serverId_fk",
|
||||
"tableFrom": "network",
|
||||
"tableTo": "server",
|
||||
"columnsFrom": [
|
||||
"serverId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"serverId"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mysql": {
|
||||
"name": "mysql",
|
||||
"schema": "",
|
||||
@@ -4808,6 +4962,13 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"networkIds": {
|
||||
"name": "networkIds",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -6020,6 +6181,13 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"networkIds": {
|
||||
"name": "networkIds",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -6513,6 +6681,13 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"networkIds": {
|
||||
"name": "networkIds",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -6872,72 +7047,6 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.scim_provider": {
|
||||
"name": "scim_provider",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scim_token": {
|
||||
"name": "scim_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"scim_provider_organization_id_organization_id_fk": {
|
||||
"name": "scim_provider_organization_id_organization_id_fk",
|
||||
"tableFrom": "scim_provider",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": [
|
||||
"organization_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"scim_provider_provider_id_unique": {
|
||||
"name": "scim_provider_provider_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"provider_id"
|
||||
]
|
||||
},
|
||||
"scim_provider_scim_token_unique": {
|
||||
"name": "scim_provider_scim_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"scim_token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.security": {
|
||||
"name": "security",
|
||||
"schema": "",
|
||||
@@ -8251,6 +8360,18 @@
|
||||
"libsql"
|
||||
]
|
||||
},
|
||||
"public.networkDriver": {
|
||||
"name": "networkDriver",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"bridge",
|
||||
"host",
|
||||
"overlay",
|
||||
"macvlan",
|
||||
"none",
|
||||
"ipvlan"
|
||||
]
|
||||
},
|
||||
"public.notificationType": {
|
||||
"name": "notificationType",
|
||||
"schema": "public",
|
||||
|
||||
@@ -1167,8 +1167,8 @@
|
||||
{
|
||||
"idx": 166,
|
||||
"version": "7",
|
||||
"when": 1776576422440,
|
||||
"tag": "0166_overjoyed_big_bertha",
|
||||
"when": 1776117573148,
|
||||
"tag": "0166_pale_multiple_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/api-key": "1.6.5",
|
||||
"@better-auth/sso": "1.6.5",
|
||||
"@better-auth/api-key": "1.5.4",
|
||||
"@better-auth/sso": "1.5.4",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
@@ -67,7 +67,6 @@
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
@@ -101,7 +100,7 @@
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "1.6.5",
|
||||
"better-auth": "1.5.4",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.67.3",
|
||||
@@ -113,7 +112,7 @@
|
||||
"dockerode": "4.0.2",
|
||||
"dompurify": "^3.3.3",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"drizzle-zod": "0.8.3",
|
||||
"fancy-ansi": "^0.1.3",
|
||||
"input-otp": "^1.4.2",
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
|
||||
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
|
||||
<Link
|
||||
href="/dashboard/home"
|
||||
href="/dashboard/projects"
|
||||
className={buttonVariants({
|
||||
variant: "secondary",
|
||||
className: "flex flex-row gap-2",
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowHome } from "@/components/dashboard/home/show-home";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Home = () => {
|
||||
return <ShowHome />;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
Home.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
await helpers.user.get.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
84
apps/dokploy/pages/dashboard/networks.tsx
Normal file
84
apps/dokploy/pages/dashboard/networks.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Dashboard = () => {
|
||||
return <ShowNetworks />;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
Dashboard.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { req } = ctx;
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: ctx.res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
try {
|
||||
await helpers.project.all.prefetch();
|
||||
|
||||
if (user.role === "member") {
|
||||
const userR = await helpers.user.one.fetch({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!userR?.canAccessToDocker) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await helpers.network.all.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Loader2,
|
||||
Play,
|
||||
PlusIcon,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ServerIcon,
|
||||
SquareTerminal,
|
||||
@@ -69,14 +68,6 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -433,7 +424,6 @@ const EnvironmentPage = (
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [deleteVolumes, setDeleteVolumes] = useState(false);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>("all");
|
||||
const [serviceToDelete, setServiceToDelete] = useState<Services | null>(null);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedServices.length === filteredServices.length) {
|
||||
@@ -509,14 +499,6 @@ const EnvironmentPage = (
|
||||
deploy: api.mongo.deploy.useMutation(),
|
||||
};
|
||||
|
||||
const libsqlActions = {
|
||||
start: api.libsql.start.useMutation(),
|
||||
stop: api.libsql.stop.useMutation(),
|
||||
move: api.libsql.move.useMutation(),
|
||||
delete: api.libsql.remove.useMutation(),
|
||||
deploy: api.libsql.deploy.useMutation(),
|
||||
};
|
||||
|
||||
const handleBulkStart = async () => {
|
||||
let success = 0;
|
||||
setIsBulkActionLoading(true);
|
||||
@@ -549,9 +531,6 @@ const EnvironmentPage = (
|
||||
case "mongo":
|
||||
await mongoActions.start.mutateAsync({ mongoId: serviceId });
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.start.mutateAsync({ libsqlId: serviceId });
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch {
|
||||
@@ -599,9 +578,6 @@ const EnvironmentPage = (
|
||||
case "mongo":
|
||||
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.stop.mutateAsync({ libsqlId: serviceId });
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch {
|
||||
@@ -678,12 +654,6 @@ const EnvironmentPage = (
|
||||
targetEnvironmentId: selectedTargetEnvironment,
|
||||
});
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.move.mutateAsync({
|
||||
libsqlId: serviceId,
|
||||
targetEnvironmentId: selectedTargetEnvironment,
|
||||
});
|
||||
break;
|
||||
}
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
@@ -753,11 +723,6 @@ const EnvironmentPage = (
|
||||
mongoId: serviceId,
|
||||
});
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.delete.mutateAsync({
|
||||
libsqlId: serviceId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
@@ -824,11 +789,6 @@ const EnvironmentPage = (
|
||||
mongoId: serviceId,
|
||||
});
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.deploy.mutateAsync({
|
||||
libsqlId: serviceId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch (error) {
|
||||
@@ -854,110 +814,6 @@ const EnvironmentPage = (
|
||||
setIsBulkActionLoading(false);
|
||||
};
|
||||
|
||||
const getServiceActions = (service: Services) => {
|
||||
switch (service.type) {
|
||||
case "application":
|
||||
return applicationActions;
|
||||
case "compose":
|
||||
return composeActions;
|
||||
case "postgres":
|
||||
return postgresActions;
|
||||
case "mysql":
|
||||
return mysqlActions;
|
||||
case "mariadb":
|
||||
return mariadbActions;
|
||||
case "redis":
|
||||
return redisActions;
|
||||
case "mongo":
|
||||
return mongoActions;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getServiceIdKey = (service: Services) => {
|
||||
switch (service.type) {
|
||||
case "application":
|
||||
return "applicationId";
|
||||
case "compose":
|
||||
return "composeId";
|
||||
case "postgres":
|
||||
return "postgresId";
|
||||
case "mysql":
|
||||
return "mysqlId";
|
||||
case "mariadb":
|
||||
return "mariadbId";
|
||||
case "redis":
|
||||
return "redisId";
|
||||
case "mongo":
|
||||
return "mongoId";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServiceAction = async (
|
||||
service: Services,
|
||||
action: "start" | "stop" | "deploy",
|
||||
) => {
|
||||
const actions = getServiceActions(service);
|
||||
const idKey = getServiceIdKey(service);
|
||||
if (!actions || !idKey) return;
|
||||
|
||||
const actionLabels = {
|
||||
start: { loading: "Starting", success: "started", error: "starting" },
|
||||
stop: { loading: "Stopping", success: "stopped", error: "stopping" },
|
||||
deploy: {
|
||||
loading: "Deploying",
|
||||
success: "queued for deployment",
|
||||
error: "deploying",
|
||||
},
|
||||
};
|
||||
|
||||
const labels = actionLabels[action];
|
||||
|
||||
toast.promise(
|
||||
(async () => {
|
||||
await actions[action].mutateAsync({
|
||||
[idKey]: service.id,
|
||||
} as any);
|
||||
})(),
|
||||
{
|
||||
loading: `${labels.loading} ${service.name}...`,
|
||||
success: () => {
|
||||
utils.environment.one.invalidate({ environmentId });
|
||||
return `${service.name} ${labels.success} successfully`;
|
||||
},
|
||||
error: (error) =>
|
||||
`Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleServiceDelete = async (service: Services) => {
|
||||
const actions = getServiceActions(service);
|
||||
const idKey = getServiceIdKey(service);
|
||||
if (!actions || !idKey) return;
|
||||
|
||||
toast.promise(
|
||||
(async () => {
|
||||
await actions.delete.mutateAsync({
|
||||
[idKey]: service.id,
|
||||
} as any);
|
||||
})(),
|
||||
{
|
||||
loading: `Deleting ${service.name}...`,
|
||||
success: () => {
|
||||
utils.environment.one.invalidate({ environmentId });
|
||||
return `${service.name} deleted successfully`;
|
||||
},
|
||||
error: (error) =>
|
||||
`Error deleting ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
);
|
||||
setServiceToDelete(null);
|
||||
};
|
||||
|
||||
// Get unique servers from services
|
||||
const availableServers = useMemo(() => {
|
||||
if (!applications) return [];
|
||||
@@ -1616,156 +1472,110 @@ const EnvironmentPage = (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="gap-5 pb-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredServices?.map((service) => (
|
||||
<ContextMenu key={service.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Link
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
|
||||
className="block"
|
||||
<Link
|
||||
key={service.id}
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
|
||||
{service.serverId && (
|
||||
<div className="absolute -left-1 -top-2">
|
||||
<ServerIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={service.status} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
|
||||
selectedServices.includes(service.id)
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
||||
)}
|
||||
onClick={(e) =>
|
||||
handleServiceSelect(service.id, e)
|
||||
}
|
||||
>
|
||||
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
|
||||
{service.serverId && (
|
||||
<div className="absolute -left-1 -top-2">
|
||||
<ServerIcon className="size-4 text-muted-foreground" />
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={selectedServices.includes(
|
||||
service.id,
|
||||
)}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
||||
{service.name}
|
||||
</span>
|
||||
{service.description && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{service.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground self-start">
|
||||
{service.type === "postgres" && (
|
||||
<PostgresqlIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "redis" && (
|
||||
<RedisIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mariadb" && (
|
||||
<MariadbIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mongo" && (
|
||||
<MongodbIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mysql" && (
|
||||
<MysqlIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "application" &&
|
||||
(service.icon ? (
|
||||
// biome-ignore lint/performance/noImgElement: application icon is data URL
|
||||
<img
|
||||
src={service.icon}
|
||||
alt={service.name}
|
||||
className="size-7 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<GlobeIcon className="h-6 w-6" />
|
||||
))}
|
||||
{service.type === "compose" && (
|
||||
<CircuitBoard className="h-6 w-6" />
|
||||
)}
|
||||
{service.type === "libsql" && (
|
||||
<LibsqlIcon className="h-6 w-6" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="mt-auto">
|
||||
<div className="space-y-1 text-sm w-full">
|
||||
{service.serverName && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||
<ServerIcon className="size-3" />
|
||||
<span className="truncate">
|
||||
{service.serverName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={service.status} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
|
||||
selectedServices.includes(service.id)
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
||||
)}
|
||||
onClick={(e) =>
|
||||
handleServiceSelect(service.id, e)
|
||||
}
|
||||
>
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={selectedServices.includes(
|
||||
service.id,
|
||||
)}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
||||
{service.name}
|
||||
</span>
|
||||
{service.description && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{service.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground self-start">
|
||||
{service.type === "postgres" && (
|
||||
<PostgresqlIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "redis" && (
|
||||
<RedisIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mariadb" && (
|
||||
<MariadbIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mongo" && (
|
||||
<MongodbIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mysql" && (
|
||||
<MysqlIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "application" &&
|
||||
(service.icon ? (
|
||||
// biome-ignore lint/performance/noImgElement: application icon is data URL
|
||||
<img
|
||||
src={service.icon}
|
||||
alt={service.name}
|
||||
className="size-7 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<GlobeIcon className="h-6 w-6" />
|
||||
))}
|
||||
{service.type === "compose" && (
|
||||
<CircuitBoard className="h-6 w-6" />
|
||||
)}
|
||||
{service.type === "libsql" && (
|
||||
<LibsqlIcon className="h-6 w-6" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="mt-auto">
|
||||
<div className="space-y-1 text-sm w-full">
|
||||
{service.serverName && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||
<ServerIcon className="size-3" />
|
||||
<span className="truncate">
|
||||
{service.serverName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<DateTooltip date={service.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
</ContextMenuTrigger>
|
||||
{service.type !== "libsql" && (
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuLabel className="truncate">
|
||||
{service.name}
|
||||
</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() =>
|
||||
handleServiceAction(service, "start")
|
||||
}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Start
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() =>
|
||||
handleServiceAction(service, "deploy")
|
||||
}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Deploy
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="flex items-center gap-2 text-orange-500 focus:text-orange-500"
|
||||
onClick={() =>
|
||||
handleServiceAction(service, "stop")
|
||||
}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
Stop
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="flex items-center gap-2 text-red-500 focus:text-red-500"
|
||||
onClick={() => setServiceToDelete(service)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)}
|
||||
</ContextMenu>
|
||||
<DateTooltip date={service.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1776,38 +1586,6 @@ const EnvironmentPage = (
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Single Service Delete Dialog */}
|
||||
<Dialog
|
||||
open={!!serviceToDelete}
|
||||
onOpenChange={(open) => !open && setServiceToDelete(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Service</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold">{serviceToDelete?.name}</span>?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setServiceToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (serviceToDelete) {
|
||||
handleServiceDelete(serviceToDelete);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1886,7 +1664,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -490,7 +490,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
|
||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
||||
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
||||
@@ -61,7 +60,6 @@ type TabState =
|
||||
| "advanced"
|
||||
| "deployments"
|
||||
| "domains"
|
||||
| "containers"
|
||||
| "monitoring"
|
||||
| "volumeBackups";
|
||||
|
||||
@@ -233,9 +231,6 @@ const Service = (
|
||||
Deployments
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{permissions?.service.read && (
|
||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TabsTrigger value="backups">Backups</TabsTrigger>
|
||||
)}
|
||||
@@ -303,18 +298,6 @@ const Service = (
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
{permissions?.service.read && (
|
||||
<TabsContent value="containers">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowComposeContainers
|
||||
serverId={data?.serverId || undefined}
|
||||
appName={data?.appName || ""}
|
||||
appType={data?.composeType || "docker-compose"}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{permissions?.monitoring.read && (
|
||||
<TabsContent value="monitoring">
|
||||
<div className="pt-2.5">
|
||||
@@ -492,7 +475,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -376,7 +376,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,16 +82,6 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const isEmailNotVerified =
|
||||
error.code === "EMAIL_NOT_VERIFIED" ||
|
||||
error.message?.toLowerCase().includes("email not verified");
|
||||
if (isEmailNotVerified) {
|
||||
const msg =
|
||||
"Your email is not verified. We've sent a new verification link to your email.";
|
||||
toast.info(msg);
|
||||
setError(msg);
|
||||
return;
|
||||
}
|
||||
toast.error(error.message);
|
||||
setError(error.message || "An error occurred while logging in");
|
||||
return;
|
||||
@@ -106,7 +96,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
}
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
} catch {
|
||||
toast.error("An error occurred while logging in");
|
||||
} finally {
|
||||
@@ -133,7 +123,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
}
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
} catch {
|
||||
toast.error("An error occurred while verifying 2FA code");
|
||||
} finally {
|
||||
@@ -163,7 +153,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
}
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
} catch {
|
||||
toast.error("An error occurred while verifying backup code");
|
||||
} finally {
|
||||
@@ -408,7 +398,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -437,7 +427,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ const Invitation = ({
|
||||
});
|
||||
|
||||
toast.success("Account created successfully");
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
} catch {
|
||||
toast.error("An error occurred while creating your account");
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { mariadbRouter } from "./routers/mariadb";
|
||||
import { mongoRouter } from "./routers/mongo";
|
||||
import { mountRouter } from "./routers/mount";
|
||||
import { mysqlRouter } from "./routers/mysql";
|
||||
import { networkRouter } from "./routers/network";
|
||||
import { notificationRouter } from "./routers/notification";
|
||||
import { organizationRouter } from "./routers/organization";
|
||||
import { patchRouter } from "./routers/patch";
|
||||
@@ -31,7 +32,6 @@ import { projectRouter } from "./routers/project";
|
||||
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { scimRouter } from "./routers/proprietary/scim";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||
import { redirectsRouter } from "./routers/redirects";
|
||||
@@ -59,6 +59,7 @@ export const appRouter = createTRPCRouter({
|
||||
application: applicationRouter,
|
||||
backup: backupRouter,
|
||||
bitbucket: bitbucketRouter,
|
||||
network: networkRouter,
|
||||
certificates: certificateRouter,
|
||||
cluster: clusterRouter,
|
||||
compose: composeRouter,
|
||||
@@ -94,7 +95,6 @@ export const appRouter = createTRPCRouter({
|
||||
organization: organizationRouter,
|
||||
licenseKey: licenseKeyRouter,
|
||||
sso: ssoRouter,
|
||||
scim: scimRouter,
|
||||
whitelabeling: whitelabelingRouter,
|
||||
customRole: customRoleRouter,
|
||||
auditLog: auditLogRouter,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import {
|
||||
containerKill,
|
||||
containerRemove,
|
||||
containerRestart,
|
||||
containerStart,
|
||||
containerStop,
|
||||
findServerById,
|
||||
getConfig,
|
||||
getContainers,
|
||||
@@ -38,108 +35,24 @@ export const dockerRouter = createTRPCRouter({
|
||||
return await getContainers(input.serverId);
|
||||
}),
|
||||
|
||||
restartContainer: withPermission("service", "read")
|
||||
restartContainer: withPermission("docker", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(containerIdRegex, "Invalid container id."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
await containerRestart(input.containerId, input.serverId);
|
||||
const result = await containerRestart(input.containerId);
|
||||
await audit(ctx, {
|
||||
action: "start",
|
||||
resourceType: "docker",
|
||||
resourceId: input.containerId,
|
||||
resourceName: input.containerId,
|
||||
});
|
||||
}),
|
||||
|
||||
startContainer: withPermission("service", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(containerIdRegex, "Invalid container id."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
await containerStart(input.containerId, input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "start",
|
||||
resourceType: "docker",
|
||||
resourceId: input.containerId,
|
||||
resourceName: input.containerId,
|
||||
});
|
||||
}),
|
||||
|
||||
stopContainer: withPermission("service", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(containerIdRegex, "Invalid container id."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
await containerStop(input.containerId, input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "stop",
|
||||
resourceType: "docker",
|
||||
resourceId: input.containerId,
|
||||
resourceName: input.containerId,
|
||||
});
|
||||
}),
|
||||
|
||||
killContainer: withPermission("service", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(containerIdRegex, "Invalid container id."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
await containerKill(input.containerId, input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "stop",
|
||||
resourceType: "docker",
|
||||
resourceId: input.containerId,
|
||||
resourceName: input.containerId,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
removeContainer: withPermission("docker", "read")
|
||||
|
||||
71
apps/dokploy/server/api/routers/network.ts
Normal file
71
apps/dokploy/server/api/routers/network.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
createNetwork,
|
||||
findNetworkById,
|
||||
removeNetwork,
|
||||
updateNetwork,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
apiCreateNetwork,
|
||||
apiFindOneNetwork,
|
||||
apiRemoveNetwork,
|
||||
apiUpdateNetwork,
|
||||
network as networkTable,
|
||||
} from "@/server/db/schema";
|
||||
|
||||
export const networkRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(networkTable)
|
||||
.where(eq(networkTable.organizationId, ctx.session.activeOrganizationId))
|
||||
.orderBy(desc(networkTable.createdAt));
|
||||
return rows;
|
||||
}),
|
||||
|
||||
one: protectedProcedure
|
||||
.input(apiFindOneNetwork)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const row = await findNetworkById(input.networkId);
|
||||
if (row.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}),
|
||||
create: protectedProcedure
|
||||
.input(apiCreateNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return createNetwork(input, ctx.session.activeOrganizationId);
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.input(apiUpdateNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const network = await findNetworkById(input.networkId);
|
||||
if (network.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authorized to update this network",
|
||||
});
|
||||
}
|
||||
return updateNetwork(input);
|
||||
}),
|
||||
|
||||
remove: protectedProcedure
|
||||
.input(apiRemoveNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const network = await findNetworkById(input.networkId);
|
||||
if (network.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authorized to delete this network",
|
||||
});
|
||||
}
|
||||
return removeNetwork(input.networkId);
|
||||
}),
|
||||
});
|
||||
@@ -487,148 +487,6 @@ export const projectRouter = createTRPCRouter({
|
||||
},
|
||||
),
|
||||
|
||||
homeStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
const isPrivileged = ctx.user.role === "owner" || ctx.user.role === "admin";
|
||||
|
||||
let accessedProjects: string[] = [];
|
||||
let accessedEnvironments: string[] = [];
|
||||
let accessedServices: string[] = [];
|
||||
|
||||
if (!isPrivileged) {
|
||||
const member = await findMemberByUserId(
|
||||
ctx.user.id,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
accessedProjects = member.accessedProjects;
|
||||
accessedEnvironments = member.accessedEnvironments;
|
||||
accessedServices = member.accessedServices;
|
||||
|
||||
if (accessedProjects.length === 0) {
|
||||
return {
|
||||
projects: 0,
|
||||
environments: 0,
|
||||
applications: 0,
|
||||
compose: 0,
|
||||
databases: 0,
|
||||
services: 0,
|
||||
status: { running: 0, error: 0, idle: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const projectIdFilter = isPrivileged
|
||||
? eq(projects.organizationId, ctx.session.activeOrganizationId)
|
||||
: and(
|
||||
sql`${projects.projectId} IN (${sql.join(
|
||||
accessedProjects.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||
);
|
||||
|
||||
const environmentFilter = isPrivileged
|
||||
? undefined
|
||||
: accessedEnvironments.length === 0
|
||||
? sql`false`
|
||||
: sql`${environments.environmentId} IN (${sql.join(
|
||||
accessedEnvironments.map((envId) => sql`${envId}`),
|
||||
sql`, `,
|
||||
)})`;
|
||||
|
||||
const applyFilter = (col: AnyPgColumn) =>
|
||||
isPrivileged ? undefined : buildServiceFilter(col, accessedServices);
|
||||
|
||||
const rows = await db.query.projects.findMany({
|
||||
where: projectIdFilter,
|
||||
columns: { projectId: true },
|
||||
with: {
|
||||
environments: {
|
||||
where: environmentFilter,
|
||||
columns: { environmentId: true },
|
||||
with: {
|
||||
applications: {
|
||||
where: applyFilter(applications.applicationId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
compose: {
|
||||
where: applyFilter(compose.composeId),
|
||||
columns: { composeStatus: true },
|
||||
},
|
||||
libsql: {
|
||||
where: applyFilter(libsql.libsqlId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
mariadb: {
|
||||
where: applyFilter(mariadb.mariadbId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
mongo: {
|
||||
where: applyFilter(mongo.mongoId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
mysql: {
|
||||
where: applyFilter(mysql.mysqlId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
postgres: {
|
||||
where: applyFilter(postgres.postgresId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
redis: {
|
||||
where: applyFilter(redis.redisId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let applicationsCount = 0;
|
||||
let composeCount = 0;
|
||||
let databasesCount = 0;
|
||||
let environmentsCount = 0;
|
||||
const status = { running: 0, error: 0, idle: 0 };
|
||||
const bump = (s?: string | null) => {
|
||||
if (s === "done") status.running++;
|
||||
else if (s === "error") status.error++;
|
||||
else status.idle++;
|
||||
};
|
||||
|
||||
for (const project of rows) {
|
||||
for (const env of project.environments) {
|
||||
environmentsCount++;
|
||||
applicationsCount += env.applications.length;
|
||||
composeCount += env.compose.length;
|
||||
databasesCount +=
|
||||
env.libsql.length +
|
||||
env.mariadb.length +
|
||||
env.mongo.length +
|
||||
env.mysql.length +
|
||||
env.postgres.length +
|
||||
env.redis.length;
|
||||
|
||||
for (const a of env.applications) bump(a.applicationStatus);
|
||||
for (const c of env.compose) bump(c.composeStatus);
|
||||
for (const s of env.libsql) bump(s.applicationStatus);
|
||||
for (const s of env.mariadb) bump(s.applicationStatus);
|
||||
for (const s of env.mongo) bump(s.applicationStatus);
|
||||
for (const s of env.mysql) bump(s.applicationStatus);
|
||||
for (const s of env.postgres) bump(s.applicationStatus);
|
||||
for (const s of env.redis) bump(s.applicationStatus);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projects: rows.length,
|
||||
environments: environmentsCount,
|
||||
applications: applicationsCount,
|
||||
compose: composeCount,
|
||||
databases: databasesCount,
|
||||
services: applicationsCount + composeCount + databasesCount,
|
||||
status,
|
||||
};
|
||||
}),
|
||||
|
||||
search: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { scimProvider } from "@dokploy/server/db/schema";
|
||||
import { requestToHeaders } from "@dokploy/server/index";
|
||||
import { auth } from "@dokploy/server/lib/auth";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, enterpriseProcedure } from "@/server/api/trpc";
|
||||
|
||||
const providerIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-z0-9][a-z0-9-]*$/,
|
||||
"Provider ID must be lowercase alphanumeric with optional dashes",
|
||||
);
|
||||
|
||||
export const scimRouter = createTRPCRouter({
|
||||
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
|
||||
const providers = await db.query.scimProvider.findMany({
|
||||
where: eq(scimProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
columns: {
|
||||
id: true,
|
||||
providerId: true,
|
||||
organizationId: true,
|
||||
},
|
||||
orderBy: [asc(scimProvider.providerId)],
|
||||
});
|
||||
return providers;
|
||||
}),
|
||||
generateToken: enterpriseProcedure
|
||||
.input(z.object({ providerId: providerIdSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await db.query.scimProvider.findFirst({
|
||||
where: eq(scimProvider.providerId, input.providerId),
|
||||
columns: { id: true, organizationId: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "A SCIM provider with this ID already exists",
|
||||
});
|
||||
}
|
||||
const result = await auth.generateSCIMToken({
|
||||
body: {
|
||||
providerId: input.providerId,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
},
|
||||
headers: requestToHeaders(ctx.req),
|
||||
});
|
||||
return { scimToken: result.scimToken, providerId: input.providerId };
|
||||
}),
|
||||
deleteProvider: enterpriseProcedure
|
||||
.input(z.object({ providerId: providerIdSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [deleted] = await db
|
||||
.delete(scimProvider)
|
||||
.where(
|
||||
and(
|
||||
eq(scimProvider.providerId, input.providerId),
|
||||
eq(
|
||||
scimProvider.organizationId,
|
||||
ctx.session.activeOrganizationId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning({ id: scimProvider.id });
|
||||
if (!deleted) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message:
|
||||
"SCIM provider not found or you do not have permission to delete it",
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -1,311 +1,299 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
integer,
|
||||
index,
|
||||
uniqueIndex,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
firstName: text("first_name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
role: text("role"),
|
||||
banned: boolean("banned").default(false),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamp("ban_expires"),
|
||||
ownerId: text("owner_id"),
|
||||
allowImpersonation: boolean("allow_impersonation").default(false),
|
||||
lastName: text("last_name").default(""),
|
||||
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
|
||||
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
|
||||
id: text("id").primaryKey(),
|
||||
firstName: text("first_name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
role: text("role"),
|
||||
ownerId: text("owner_id"),
|
||||
allowImpersonation: boolean("allow_impersonation").default(false),
|
||||
lastName: text("last_name").default(""),
|
||||
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
|
||||
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
|
||||
});
|
||||
|
||||
export const session = pgTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
impersonatedBy: text("impersonated_by"),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const account = pgTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const verification = pgTable(
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
export const apikey = pgTable(
|
||||
"apikey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
configId: text("config_id").default("default").notNull(),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
referenceId: text("reference_id").notNull(),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: timestamp("last_refill_at"),
|
||||
enabled: boolean("enabled").default(true),
|
||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
||||
rateLimitMax: integer("rate_limit_max").default(10),
|
||||
requestCount: integer("request_count").default(0),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: timestamp("last_request"),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull(),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [
|
||||
index("apikey_configId_idx").on(table.configId),
|
||||
index("apikey_referenceId_idx").on(table.referenceId),
|
||||
index("apikey_key_idx").on(table.key),
|
||||
],
|
||||
"apikey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
configId: text("config_id").default("default").notNull(),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
referenceId: text("reference_id").notNull(),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: timestamp("last_refill_at"),
|
||||
enabled: boolean("enabled").default(true),
|
||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
||||
rateLimitMax: integer("rate_limit_max").default(10),
|
||||
requestCount: integer("request_count").default(0),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: timestamp("last_request"),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull(),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [
|
||||
index("apikey_configId_idx").on(table.configId),
|
||||
index("apikey_referenceId_idx").on(table.referenceId),
|
||||
index("apikey_key_idx").on(table.key),
|
||||
],
|
||||
);
|
||||
|
||||
export const ssoProvider = pgTable("sso_provider", {
|
||||
id: text("id").primaryKey(),
|
||||
issuer: text("issuer").notNull(),
|
||||
oidcConfig: text("oidc_config"),
|
||||
samlConfig: text("saml_config"),
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
organizationId: text("organization_id"),
|
||||
domain: text("domain").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
issuer: text("issuer").notNull(),
|
||||
oidcConfig: text("oidc_config"),
|
||||
samlConfig: text("saml_config"),
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
organizationId: text("organization_id"),
|
||||
domain: text("domain").notNull(),
|
||||
});
|
||||
|
||||
export const twoFactor = pgTable(
|
||||
"two_factor",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
verified: boolean("verified").default(true),
|
||||
},
|
||||
(table) => [
|
||||
index("twoFactor_secret_idx").on(table.secret),
|
||||
index("twoFactor_userId_idx").on(table.userId),
|
||||
],
|
||||
"two_factor",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("twoFactor_secret_idx").on(table.secret),
|
||||
index("twoFactor_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const organization = pgTable(
|
||||
"organization",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
logo: text("logo"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||
"organization",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
logo: text("logo"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||
);
|
||||
|
||||
export const organizationRole = pgTable(
|
||||
"organization_role",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(),
|
||||
permission: text("permission").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").$onUpdate(
|
||||
() => /* @__PURE__ */ new Date(),
|
||||
),
|
||||
},
|
||||
(table) => [
|
||||
index("organizationRole_organizationId_idx").on(table.organizationId),
|
||||
index("organizationRole_role_idx").on(table.role),
|
||||
],
|
||||
"organization_role",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(),
|
||||
permission: text("permission").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").$onUpdate(
|
||||
() => /* @__PURE__ */ new Date(),
|
||||
),
|
||||
},
|
||||
(table) => [
|
||||
index("organizationRole_organizationId_idx").on(table.organizationId),
|
||||
index("organizationRole_role_idx").on(table.role),
|
||||
],
|
||||
);
|
||||
|
||||
export const member = pgTable(
|
||||
"member",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").default("member").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("member_organizationId_idx").on(table.organizationId),
|
||||
index("member_userId_idx").on(table.userId),
|
||||
],
|
||||
"member",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").default("member").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("member_organizationId_idx").on(table.organizationId),
|
||||
index("member_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const invitation = pgTable(
|
||||
"invitation",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
status: text("status").default("pending").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
inviterId: text("inviter_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("invitation_organizationId_idx").on(table.organizationId),
|
||||
index("invitation_email_idx").on(table.email),
|
||||
],
|
||||
"invitation",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
status: text("status").default("pending").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
inviterId: text("inviter_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("invitation_organizationId_idx").on(table.organizationId),
|
||||
index("invitation_email_idx").on(table.email),
|
||||
],
|
||||
);
|
||||
|
||||
export const scimProvider = pgTable("scim_provider", {
|
||||
id: text("id").primaryKey(),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
scimToken: text("scim_token").notNull().unique(),
|
||||
organizationId: text("organization_id"),
|
||||
});
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
ssoProviders: many(ssoProvider),
|
||||
twoFactors: many(twoFactor),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
ssoProviders: many(ssoProvider),
|
||||
twoFactors: many(twoFactor),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [ssoProvider.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [ssoProvider.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [twoFactor.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [twoFactor.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
organizationRoles: many(organizationRole),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
organizationRoles: many(organizationRole),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}));
|
||||
|
||||
export const organizationRoleRelations = relations(
|
||||
organizationRole,
|
||||
({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [organizationRole.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}),
|
||||
organizationRole,
|
||||
({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [organizationRole.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const memberRelations = relations(member, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [member.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [member.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
organization: one(organization, {
|
||||
fields: [member.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [member.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [invitation.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [invitation.inviterId],
|
||||
references: [user.id],
|
||||
}),
|
||||
organization: one(organization, {
|
||||
fields: [invitation.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [invitation.inviterId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -37,10 +37,9 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/api-key": "1.6.5",
|
||||
"@better-auth/scim": "^1.6.5",
|
||||
"@better-auth/sso": "1.6.5",
|
||||
"@better-auth/utils": "0.4.0",
|
||||
"@better-auth/api-key": "1.5.4",
|
||||
"@better-auth/sso": "1.5.4",
|
||||
"@better-auth/utils": "0.3.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/rest": "^20.1.2",
|
||||
@@ -52,14 +51,15 @@
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "1.6.5",
|
||||
"better-auth": "1.5.4",
|
||||
"better-call": "2.0.2",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"date-fns": "3.6.0",
|
||||
"dockerode": "4.0.2",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { network } from "./network";
|
||||
import { projects } from "./project";
|
||||
import { server } from "./server";
|
||||
import { ssoProvider } from "./sso";
|
||||
@@ -108,6 +109,7 @@ export const organizationRelations = relations(
|
||||
references: [user.id],
|
||||
}),
|
||||
servers: many(server),
|
||||
networks: many(network),
|
||||
projects: many(projects),
|
||||
members: many(member),
|
||||
ssoProviders: many(ssoProvider),
|
||||
@@ -214,7 +216,6 @@ export const twoFactor = pgTable("two_factor", {
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
verified: boolean("verified").notNull().default(true),
|
||||
});
|
||||
|
||||
export const apikey = pgTable("apikey", {
|
||||
|
||||
@@ -227,6 +227,7 @@ export const applications = pgTable("application", {
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const applicationsRelations = relations(
|
||||
@@ -368,6 +369,7 @@ const createSchema = createInsertSchema(applications, {
|
||||
previewRequireCollaboratorPermissions: z.boolean().optional(),
|
||||
watchPaths: z.array(z.string()).optional().optional(),
|
||||
previewLabels: z.array(z.string()).optional(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
cleanCache: z.boolean().optional(),
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
|
||||
@@ -108,6 +108,7 @@ export const compose = pgTable("compose", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const composeRelations = relations(compose, ({ one, many }) => ({
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from "./libsql";
|
||||
export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
export * from "./network";
|
||||
export * from "./mysql";
|
||||
export * from "./notification";
|
||||
export * from "./patch";
|
||||
@@ -30,7 +31,6 @@ export * from "./redis";
|
||||
export * from "./registry";
|
||||
export * from "./rollbacks";
|
||||
export * from "./schedule";
|
||||
export * from "./scim";
|
||||
export * from "./security";
|
||||
export * from "./server";
|
||||
export * from "./session";
|
||||
|
||||
@@ -87,6 +87,7 @@ export const mariadb = pgTable("mariadb", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
|
||||
|
||||
@@ -91,6 +91,7 @@ export const mongo = pgTable("mongo", {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
replicaSets: boolean("replicaSets").default(false),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const mongoRelations = relations(mongo, ({ one, many }) => ({
|
||||
|
||||
@@ -85,6 +85,7 @@ export const mysql = pgTable("mysql", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const mysqlRelations = relations(mysql, ({ one, many }) => ({
|
||||
|
||||
137
packages/server/src/db/schema/network.ts
Normal file
137
packages/server/src/db/schema/network.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, jsonb, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { server } from "./server";
|
||||
|
||||
/** Docker network driver types */
|
||||
export const networkDriver = pgEnum("networkDriver", [
|
||||
"bridge",
|
||||
"host",
|
||||
"overlay",
|
||||
"macvlan",
|
||||
"none",
|
||||
"ipvlan",
|
||||
]);
|
||||
|
||||
export const network = pgTable("network", {
|
||||
networkId: text("networkId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
driver: networkDriver("driver").notNull().default("bridge"),
|
||||
scope: text("scope"), // e.g. "local", "swarm"
|
||||
internal: boolean("internal").notNull().default(false),
|
||||
attachable: boolean("attachable").notNull().default(false),
|
||||
ingress: boolean("ingress").notNull().default(false),
|
||||
configOnly: boolean("configOnly").notNull().default(false),
|
||||
enableIPv4: boolean("enableIPv4").notNull().default(true),
|
||||
enableIPv6: boolean("enableIPv6").notNull().default(false),
|
||||
ipam: jsonb("ipam")
|
||||
.$type<{
|
||||
driver?: string;
|
||||
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
|
||||
}>()
|
||||
.default({}),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const networkRelations = relations(network, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [network.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [network.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(network, {
|
||||
networkId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
driver: z
|
||||
.enum(["bridge", "host", "overlay", "macvlan", "none", "ipvlan"])
|
||||
.optional(),
|
||||
scope: z.string().optional(),
|
||||
internal: z.boolean().optional(),
|
||||
attachable: z.boolean().optional(),
|
||||
ingress: z.boolean().optional(),
|
||||
configOnly: z.boolean().optional(),
|
||||
enableIPv4: z.boolean().optional(),
|
||||
enableIPv6: z.boolean().optional(),
|
||||
ipam: z
|
||||
.object({
|
||||
driver: z.string().optional(),
|
||||
config: z
|
||||
.array(
|
||||
z.object({
|
||||
subnet: z.string().optional(),
|
||||
gateway: z.string().optional(),
|
||||
ipRange: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
organizationId: z.string().min(1),
|
||||
serverId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const apiCreateNetwork = createSchema
|
||||
.pick({
|
||||
name: true,
|
||||
driver: true,
|
||||
scope: true,
|
||||
internal: true,
|
||||
attachable: true,
|
||||
ingress: true,
|
||||
configOnly: true,
|
||||
enableIPv4: true,
|
||||
enableIPv6: true,
|
||||
ipam: true,
|
||||
serverId: true,
|
||||
})
|
||||
.partial()
|
||||
.required({ name: true });
|
||||
|
||||
export const apiFindOneNetwork = createSchema
|
||||
.pick({
|
||||
networkId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiRemoveNetwork = createSchema
|
||||
.pick({
|
||||
networkId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiUpdateNetwork = createSchema
|
||||
.pick({
|
||||
networkId: true,
|
||||
name: true,
|
||||
driver: true,
|
||||
scope: true,
|
||||
internal: true,
|
||||
attachable: true,
|
||||
ingress: true,
|
||||
configOnly: true,
|
||||
enableIPv4: true,
|
||||
enableIPv6: true,
|
||||
ipam: true,
|
||||
serverId: true,
|
||||
})
|
||||
.partial()
|
||||
.required({ networkId: true });
|
||||
@@ -85,6 +85,7 @@ export const postgres = pgTable("postgres", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const postgresRelations = relations(postgres, ({ one, many }) => ({
|
||||
|
||||
@@ -75,6 +75,7 @@ export const redis = pgTable("redis", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const redisRelations = relations(redis, ({ one, many }) => ({
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { organization } from "./account";
|
||||
|
||||
export const scimProvider = pgTable("scim_provider", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
scimToken: text("scim_token").notNull().unique(),
|
||||
organizationId: text("organization_id").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const scimProviderRelations = relations(scimProvider, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [scimProvider.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}));
|
||||
@@ -19,6 +19,7 @@ import { libsql } from "./libsql";
|
||||
import { mariadb } from "./mariadb";
|
||||
import { mongo } from "./mongo";
|
||||
import { mysql } from "./mysql";
|
||||
import { network } from "./network";
|
||||
import { postgres } from "./postgres";
|
||||
import { redis } from "./redis";
|
||||
import { schedules } from "./schedule";
|
||||
@@ -124,6 +125,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
|
||||
mysql: many(mysql),
|
||||
postgres: many(postgres),
|
||||
certificates: many(certificates),
|
||||
networks: many(network),
|
||||
organization: one(organization, {
|
||||
fields: [server.organizationId],
|
||||
references: [organization.id],
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
export type TemplateProps = {
|
||||
userName: string;
|
||||
verificationUrl: string;
|
||||
};
|
||||
|
||||
export const VerifyEmailTemplate = ({
|
||||
userName = "User",
|
||||
verificationUrl = "https://app.dokploy.com/verify",
|
||||
}: TemplateProps) => {
|
||||
const previewText = "Verify your email address to get started with Dokploy";
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||
{/* Header */}
|
||||
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||
<Img
|
||||
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||
width="190"
|
||||
height="120"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Body */}
|
||||
<Section className="bg-white px-[40px] py-[32px]">
|
||||
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||
Verify Your Email
|
||||
</Heading>
|
||||
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||
Hello {userName}, thank you for signing up for Dokploy. Please
|
||||
verify your email address to activate your account.
|
||||
</Text>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Section className="text-center mb-[24px]">
|
||||
<Button
|
||||
href={verificationUrl}
|
||||
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
|
||||
>
|
||||
Verify Email Address
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
|
||||
If the button above doesn't work, copy and paste the following
|
||||
link into your browser:
|
||||
</Text>
|
||||
<Text className="text-[#71717a] text-[12px] leading-[18px] m-0 text-center break-all">
|
||||
{verificationUrl}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Footer */}
|
||||
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
|
||||
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
|
||||
This is an automated email from{" "}
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
className="text-[#71717a] underline"
|
||||
>
|
||||
Dokploy Cloud
|
||||
</Link>
|
||||
. If you didn't create an account, you can safely ignore this
|
||||
email.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyEmailTemplate;
|
||||
@@ -28,6 +28,7 @@ export * from "./services/mariadb";
|
||||
export * from "./services/mongo";
|
||||
export * from "./services/mount";
|
||||
export * from "./services/mysql";
|
||||
export * from "./services/network";
|
||||
export * from "./services/notification";
|
||||
export * from "./services/patch";
|
||||
export * from "./services/patch-repo";
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { scim } from "@better-auth/scim";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { admin, organization, twoFactor } from "better-auth/plugins";
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema";
|
||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||
|
||||
/**
|
||||
* Minimal better-auth config used only by `@better-auth/cli` to generate /
|
||||
* inspect database schemas. Must mirror the plugin set in `auth.ts` so the CLI
|
||||
* sees every table each plugin expects.
|
||||
*
|
||||
* Do NOT import this file from the runtime — use `auth.ts` for that.
|
||||
*/
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
user: {
|
||||
modelName: "user",
|
||||
fields: {
|
||||
name: "firstName",
|
||||
},
|
||||
additionalFields: {
|
||||
role: { type: "string", input: false },
|
||||
ownerId: { type: "string", input: false },
|
||||
allowImpersonation: { type: "boolean", defaultValue: false },
|
||||
lastName: { type: "string", required: false, defaultValue: "" },
|
||||
enableEnterpriseFeatures: { type: "boolean", required: false },
|
||||
isValidEnterpriseLicense: { type: "boolean", required: false },
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
apiKey({ enableMetadata: true, references: "user" }),
|
||||
sso(),
|
||||
twoFactor(),
|
||||
organization({
|
||||
ac,
|
||||
roles: { owner: ownerRole, admin: adminRole, member: memberRole },
|
||||
dynamicAccessControl: {
|
||||
enabled: true,
|
||||
maximumRolesPerOrganization: 10,
|
||||
},
|
||||
}),
|
||||
scim(),
|
||||
admin(),
|
||||
],
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { scim } from "@better-auth/scim";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { betterAuth } from "better-auth";
|
||||
@@ -22,10 +21,7 @@ import {
|
||||
updateWebServerSettings,
|
||||
} from "../services/web-server-settings";
|
||||
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
||||
import {
|
||||
sendEmail,
|
||||
sendVerificationEmail,
|
||||
} from "../verification/send-verification-email";
|
||||
import { sendEmail } from "../verification/send-verification-email";
|
||||
import { getPublicIpWithFallback } from "../wss/utils";
|
||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||
|
||||
@@ -110,13 +106,14 @@ const { handler, api } = betterAuth({
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendOnSignIn: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
if (IS_CLOUD) {
|
||||
await sendVerificationEmail({
|
||||
userName: user.name || "User",
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
verificationUrl: url,
|
||||
subject: "Verify your email",
|
||||
text: `
|
||||
<p>Click the link to verify your email: <a href="${url}">Verify Email</a></p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -179,8 +176,7 @@ const { handler, api } = betterAuth({
|
||||
}
|
||||
} else {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
if (isSSORequest || isSCIMRequest) {
|
||||
if (isSSORequest) {
|
||||
return;
|
||||
}
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
@@ -196,12 +192,11 @@ const { handler, api } = betterAuth({
|
||||
},
|
||||
after: async (user, context) => {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
|
||||
if (!IS_CLOUD && !isAdminPresent) {
|
||||
if (!IS_CLOUD) {
|
||||
await updateWebServerSettings({
|
||||
serverIp: await getPublicIpWithFallback(),
|
||||
});
|
||||
@@ -232,10 +227,6 @@ const { handler, api } = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
if (isSCIMRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (IS_CLOUD || !isAdminPresent) {
|
||||
await db.transaction(async (tx) => {
|
||||
const organization = await tx
|
||||
@@ -403,24 +394,7 @@ const { handler, api } = betterAuth({
|
||||
enableMetadata: true,
|
||||
references: "user",
|
||||
}),
|
||||
sso({
|
||||
saml: {
|
||||
enableInResponseToValidation: false,
|
||||
},
|
||||
}),
|
||||
scim({
|
||||
beforeSCIMTokenGenerated: async ({ user }) => {
|
||||
const dbUser = await db.query.user.findFirst({
|
||||
where: eq(schema.user.id, user.id),
|
||||
columns: { enableEnterpriseFeatures: true },
|
||||
});
|
||||
if (!dbUser?.enableEnterpriseFeatures) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "SCIM provisioning requires an enterprise license",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
sso(),
|
||||
twoFactor(),
|
||||
organization({
|
||||
ac,
|
||||
@@ -466,9 +440,6 @@ const _auth = {
|
||||
createApiKey: api.createApiKey,
|
||||
registerSSOProvider: api.registerSSOProvider,
|
||||
updateSSOProvider: api.updateSSOProvider,
|
||||
generateSCIMToken: api.generateSCIMToken,
|
||||
listSCIMProviderConnections: api.listSCIMProviderConnections,
|
||||
deleteSCIMProviderConnection: api.deleteSCIMProviderConnection,
|
||||
};
|
||||
|
||||
export type AuthType = typeof _auth;
|
||||
|
||||
@@ -100,6 +100,7 @@ export const findApplicationById = async (applicationId: string) => {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
|
||||
domains: true,
|
||||
deployments: true,
|
||||
mounts: true,
|
||||
|
||||
@@ -417,58 +417,21 @@ export const getContainerLogs = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const containerRestart = async (
|
||||
containerId: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker container restart ${containerId}`;
|
||||
const { stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
export const containerRestart = async (containerId: string) => {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(
|
||||
`docker container restart ${containerId}`,
|
||||
);
|
||||
|
||||
if (stderr) {
|
||||
console.error(`Error: ${stderr}`);
|
||||
throw new Error(stderr);
|
||||
}
|
||||
};
|
||||
if (stderr) {
|
||||
console.error(`Error: ${stderr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
export const containerStart = async (
|
||||
containerId: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker container start ${containerId}`;
|
||||
const { stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
const config = JSON.parse(stdout);
|
||||
|
||||
if (stderr) {
|
||||
console.error(`Error: ${stderr}`);
|
||||
throw new Error(stderr);
|
||||
}
|
||||
};
|
||||
|
||||
export const containerStop = async (containerId: string, serverId?: string) => {
|
||||
const command = `docker container stop ${containerId}`;
|
||||
const { stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
if (stderr) {
|
||||
console.error(`Error: ${stderr}`);
|
||||
throw new Error(stderr);
|
||||
}
|
||||
};
|
||||
|
||||
export const containerKill = async (containerId: string, serverId?: string) => {
|
||||
const command = `docker container kill ${containerId}`;
|
||||
const { stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
if (stderr) {
|
||||
console.error(`Error: ${stderr}`);
|
||||
throw new Error(stderr);
|
||||
}
|
||||
return config;
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const containerRemove = async (
|
||||
|
||||
121
packages/server/src/services/network.ts
Normal file
121
packages/server/src/services/network.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateNetwork,
|
||||
type apiUpdateNetwork,
|
||||
network,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
import { getRemoteDocker } from "../utils/servers/remote-docker";
|
||||
|
||||
export const findNetworkById = async (networkId: string) => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(network)
|
||||
.where(eq(network.networkId, networkId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
export const createNetwork = async (
|
||||
input: typeof apiCreateNetwork._type,
|
||||
organizationId: string,
|
||||
) => {
|
||||
if (IS_CLOUD) {
|
||||
if (!input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is required",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(network)
|
||||
.values({
|
||||
...input,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create network",
|
||||
});
|
||||
}
|
||||
|
||||
const ipam = row.ipam ?? {};
|
||||
const ipamConfig = (ipam.config ?? [])
|
||||
.map((c) => {
|
||||
const entry: Record<string, string> = {};
|
||||
if (c.subnet) entry.Subnet = c.subnet;
|
||||
if (c.gateway) entry.Gateway = c.gateway;
|
||||
if (c.ipRange) entry.IPRange = c.ipRange;
|
||||
return entry;
|
||||
})
|
||||
.filter((e) => Object.keys(e).length > 0);
|
||||
|
||||
const docker = await getRemoteDocker(input.serverId ?? null);
|
||||
await docker.createNetwork({
|
||||
Name: row.name,
|
||||
Driver: row.driver,
|
||||
Internal: row.internal,
|
||||
Attachable: row.attachable,
|
||||
Ingress: row.ingress,
|
||||
EnableIPv6: row.enableIPv6,
|
||||
IPAM: {
|
||||
Driver: ipam.driver ?? "default",
|
||||
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
return created;
|
||||
};
|
||||
|
||||
export const updateNetwork = async (input: typeof apiUpdateNetwork._type) => {
|
||||
const { networkId, ...rest } = input;
|
||||
const [updated] = await db
|
||||
.update(network)
|
||||
.set(rest)
|
||||
.where(eq(network.networkId, networkId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const removeNetwork = async (networkId: string) => {
|
||||
const [deleted] = await db
|
||||
.delete(network)
|
||||
.where(eq(network.networkId, networkId))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return deleted;
|
||||
};
|
||||
@@ -1,7 +1,4 @@
|
||||
import { renderAsync } from "@react-email/components";
|
||||
import VerifyEmailTemplate from "../emails/emails/verify-email";
|
||||
import { sendEmailNotification } from "../utils/notifications/utils";
|
||||
|
||||
export const sendEmail = async ({
|
||||
email,
|
||||
subject,
|
||||
@@ -29,25 +26,3 @@ export const sendEmail = async ({
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const sendVerificationEmail = async ({
|
||||
userName,
|
||||
email,
|
||||
verificationUrl,
|
||||
}: {
|
||||
userName: string;
|
||||
email: string;
|
||||
verificationUrl: string;
|
||||
}) => {
|
||||
const html = await renderAsync(
|
||||
VerifyEmailTemplate({
|
||||
userName: userName || "User",
|
||||
verificationUrl,
|
||||
}),
|
||||
);
|
||||
await sendEmail({
|
||||
email,
|
||||
subject: "Verify your email",
|
||||
text: html,
|
||||
});
|
||||
};
|
||||
|
||||
797
pnpm-lock.yaml
generated
797
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user