feat: add Overview dashboard (Services, Backups & Domains tabs)

This commit is contained in:
Narciso
2026-08-12 01:01:55 -04:00
parent 1183c7c4a9
commit 046a608f8c
17 changed files with 2770 additions and 9 deletions
@@ -0,0 +1,113 @@
import {
getBackupOverviewIcon,
getServiceOverviewIcon,
} from "@dokploy/server/services/overview";
import { describe, expect, test } from "vitest";
describe("getServiceOverviewIcon", () => {
test("returns a db icon for every known DB engine type", () => {
for (const type of [
"postgres",
"mariadb",
"mysql",
"mongo",
"redis",
"libsql",
] as const) {
expect(getServiceOverviewIcon({ type, icon: null })).toEqual({
kind: "db",
engine: type,
});
}
});
test("returns a custom icon for application/compose with a service icon set", () => {
expect(
getServiceOverviewIcon({
type: "application",
icon: "data:image/png;base64,x",
}),
).toEqual({ kind: "custom", url: "data:image/png;base64,x" });
expect(
getServiceOverviewIcon({
type: "compose",
icon: "data:image/png;base64,y",
}),
).toEqual({ kind: "custom", url: "data:image/png;base64,y" });
});
test("falls back to a generic icon for application/compose without a service icon", () => {
expect(getServiceOverviewIcon({ type: "application", icon: null })).toEqual(
{
kind: "generic",
type: "application",
},
);
expect(getServiceOverviewIcon({ type: "compose", icon: null })).toEqual({
kind: "generic",
type: "compose",
});
});
});
describe("getBackupOverviewIcon", () => {
test("returns a db icon for a database backup with a known databaseType", () => {
expect(
getBackupOverviewIcon({
databaseType: "postgres",
serviceType: null,
serviceOwnerType: "postgres",
}),
).toEqual({ kind: "db", engine: "postgres" });
});
test("returns a webServer icon for a web-server database backup", () => {
expect(
getBackupOverviewIcon({
databaseType: "web-server",
serviceType: null,
serviceOwnerType: "web-server",
}),
).toEqual({ kind: "webServer" });
});
test("returns a db icon for a compose-type backup dumping a known DB engine", () => {
expect(
getBackupOverviewIcon({
databaseType: "mysql",
serviceType: null,
serviceOwnerType: "compose",
}),
).toEqual({ kind: "db", engine: "mysql" });
});
test("returns a db icon for a volume backup of a known DB engine service", () => {
expect(
getBackupOverviewIcon({
databaseType: null,
serviceType: "mongo",
serviceOwnerType: "mongo",
}),
).toEqual({ kind: "db", engine: "mongo" });
});
test("returns a generic application icon for a volume backup of an application", () => {
expect(
getBackupOverviewIcon({
databaseType: null,
serviceType: "application",
serviceOwnerType: "application",
}),
).toEqual({ kind: "generic", type: "application" });
});
test("returns a generic compose icon for a volume backup of a compose service", () => {
expect(
getBackupOverviewIcon({
databaseType: null,
serviceType: "compose",
serviceOwnerType: "compose",
}),
).toEqual({ kind: "generic", type: "compose" });
});
});
@@ -0,0 +1,79 @@
import type { OverviewDomain } from "@dokploy/server/services/overview";
import { sortOverviewDomains } from "@dokploy/server/services/overview";
import { describe, expect, test } from "vitest";
const makeDomain = (overrides: Partial<OverviewDomain>): OverviewDomain => ({
domainId: "id",
host: "example.com",
path: "/",
port: 3000,
customEntrypoint: null,
https: true,
certificateType: "letsencrypt",
createdAt: "2024-01-01T00:00:00.000Z",
enabled: true,
domainType: "application",
serviceOwnerId: "app",
serviceOwnerType: "application",
serviceName: "App",
projectId: "project",
projectName: "Project",
environmentId: "environment",
environmentName: "Environment",
...overrides,
});
describe("sortOverviewDomains", () => {
test("sorts by createdAt asc/desc", () => {
const domains = [
makeDomain({ domainId: "old", createdAt: "2023-01-01T00:00:00.000Z" }),
makeDomain({ domainId: "new", createdAt: "2024-06-01T00:00:00.000Z" }),
makeDomain({ domainId: "mid", createdAt: "2023-12-01T00:00:00.000Z" }),
];
expect(
sortOverviewDomains(domains, "createdAt-asc").map((d) => d.domainId),
).toEqual(["old", "mid", "new"]);
expect(
sortOverviewDomains(domains, "createdAt-desc").map((d) => d.domainId),
).toEqual(["new", "mid", "old"]);
});
test("sorts by port asc/desc, with portless domains always last", () => {
const domains = [
makeDomain({ domainId: "none", port: null }),
makeDomain({ domainId: "low", port: 80 }),
makeDomain({ domainId: "high", port: 8080 }),
];
expect(
sortOverviewDomains(domains, "port-asc").map((d) => d.domainId),
).toEqual(["low", "high", "none"]);
expect(
sortOverviewDomains(domains, "port-desc").map((d) => d.domainId),
).toEqual(["high", "low", "none"]);
});
test("port sort with all domains portless is a no-op", () => {
const domains = [
makeDomain({ domainId: "a", port: null }),
makeDomain({ domainId: "b", port: null }),
];
expect(
sortOverviewDomains(domains, "port-desc").map((d) => d.domainId),
).toEqual(["a", "b"]);
});
test("does not mutate the input array", () => {
const domains = [
makeDomain({ domainId: "b", port: 8080 }),
makeDomain({ domainId: "a", port: 80 }),
];
const original = [...domains];
sortOverviewDomains(domains, "port-asc");
expect(domains).toEqual(original);
});
});
@@ -0,0 +1,106 @@
import type { OverviewService } from "@dokploy/server/services/overview";
import { sortOverviewServices } from "@dokploy/server/services/overview";
import { describe, expect, test } from "vitest";
const makeService = (overrides: Partial<OverviewService>): OverviewService => ({
id: "id",
type: "application",
name: "name",
appName: "app-name",
status: "running",
createdAt: "2024-01-01T00:00:00.000Z",
serverId: null,
serverName: null,
icon: null,
projectId: "project",
projectName: "Project",
environmentId: "environment",
environmentName: "Environment",
lastDeployAt: null,
...overrides,
});
describe("sortOverviewServices", () => {
test("sorts by name asc/desc", () => {
const services = [
makeService({ id: "b", name: "Bravo" }),
makeService({ id: "a", name: "Alpha" }),
makeService({ id: "c", name: "Charlie" }),
];
expect(sortOverviewServices(services, "name-asc").map((s) => s.id)).toEqual(
["a", "b", "c"],
);
expect(
sortOverviewServices(services, "name-desc").map((s) => s.id),
).toEqual(["c", "b", "a"]);
});
test("sorts by type asc/desc", () => {
const services = [
makeService({ id: "app", type: "application" }),
makeService({ id: "pg", type: "postgres" }),
makeService({ id: "comp", type: "compose" }),
];
expect(sortOverviewServices(services, "type-asc").map((s) => s.id)).toEqual(
["app", "comp", "pg"],
);
expect(
sortOverviewServices(services, "type-desc").map((s) => s.id),
).toEqual(["pg", "comp", "app"]);
});
test("sorts by createdAt asc/desc", () => {
const services = [
makeService({ id: "old", createdAt: "2023-01-01T00:00:00.000Z" }),
makeService({ id: "new", createdAt: "2024-06-01T00:00:00.000Z" }),
makeService({ id: "mid", createdAt: "2023-12-01T00:00:00.000Z" }),
];
expect(
sortOverviewServices(services, "createdAt-asc").map((s) => s.id),
).toEqual(["old", "mid", "new"]);
expect(
sortOverviewServices(services, "createdAt-desc").map((s) => s.id),
).toEqual(["new", "mid", "old"]);
});
test("sorts by lastDeploy asc/desc, with never-deployed services always last", () => {
const services = [
makeService({ id: "never", lastDeployAt: null }),
makeService({ id: "old", lastDeployAt: "2023-01-01T00:00:00.000Z" }),
makeService({ id: "new", lastDeployAt: "2024-06-01T00:00:00.000Z" }),
];
expect(
sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id),
).toEqual(["new", "old", "never"]);
expect(
sortOverviewServices(services, "lastDeploy-asc").map((s) => s.id),
).toEqual(["old", "new", "never"]);
});
test("lastDeploy sort with all services never deployed is a no-op", () => {
const services = [
makeService({ id: "a", lastDeployAt: null }),
makeService({ id: "b", lastDeployAt: null }),
];
expect(
sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id),
).toEqual(["a", "b"]);
});
test("does not mutate the input array", () => {
const services = [
makeService({ id: "b", name: "Bravo" }),
makeService({ id: "a", name: "Alpha" }),
];
const original = [...services];
sortOverviewServices(services, "name-asc");
expect(services).toEqual(original);
});
});
@@ -50,6 +50,7 @@ interface Props {
id: string;
type: "application" | "compose";
serverId?: string;
trigger?: React.ReactNode;
}
const RestoreBackupSchema = z.object({
@@ -64,7 +65,12 @@ const RestoreBackupSchema = z.object({
}),
});
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
export const RestoreVolumeBackups = ({
id,
type,
serverId,
trigger,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
@@ -144,10 +150,12 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Volume Backup
</Button>
{trigger ?? (
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Volume Backup
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
@@ -74,6 +74,7 @@ interface Props {
databaseType?: DatabaseType;
serverId?: string | null;
backupType?: "database" | "compose";
trigger?: React.ReactNode;
}
const RestoreBackupSchema = z
@@ -200,6 +201,7 @@ export const RestoreBackup = ({
databaseType,
serverId,
backupType = "database",
trigger,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -311,10 +313,12 @@ export const RestoreBackup = ({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</Button>
{trigger ?? (
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
@@ -0,0 +1,317 @@
import type { OverviewBackup } from "@dokploy/server/services/overview-shared";
import { getBackupOverviewIcon } from "@dokploy/server/services/overview-shared";
import {
ArrowUpDown,
CircuitBoard,
GlobeIcon,
Loader2,
MoreHorizontal,
RefreshCw,
ServerIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { RestoreVolumeBackups } from "@/components/dashboard/application/volume-backups/restore-volume-backups";
import { RestoreBackup } from "@/components/dashboard/database/backups/restore-backup";
import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const STATUS_VARIANT: Record<string, "default" | "destructive" | "secondary"> =
{
done: "default",
running: "secondary",
error: "destructive",
cancelled: "destructive",
};
const renderRowIcon = (row: OverviewBackup) => {
const icon = getBackupOverviewIcon(row);
if (icon.kind === "db") {
const Icon = DB_ENGINE_ICONS[icon.engine as keyof typeof DB_ENGINE_ICONS];
return Icon ? <Icon className="h-5 w-5" /> : null;
}
if (icon.kind === "webServer") {
return <ServerIcon className="h-5 w-5 text-muted-foreground" />;
}
return icon.type === "compose" ? (
<CircuitBoard className="h-5 w-5" />
) : (
<GlobeIcon className="h-5 w-5" />
);
};
const detailHref = (row: OverviewBackup) => {
if (!row.projectId || !row.environmentId || !row.serviceOwnerId) return null;
return `/dashboard/project/${row.projectId}/environment/${row.environmentId}/services/${row.serviceOwnerType}/${row.serviceOwnerId}`;
};
export const ShowOverviewBackups = () => {
const {
data: backups,
isFetching,
isFetched,
isError,
refetch,
} = api.overview.backups.useQuery(undefined, {
staleTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
});
const [destinationId, setDestinationId] = useState("all");
const [serviceOwnerId, setServiceOwnerId] = useState("all");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const destinations = useMemo(() => {
if (!backups) return [];
const map = new Map<string, string>();
for (const row of backups) map.set(row.destinationId, row.destinationName);
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [backups]);
const services = useMemo(() => {
if (!backups) return [];
const map = new Map<string, string>();
for (const row of backups) {
if (row.serviceOwnerId) map.set(row.serviceOwnerId, row.serviceName);
}
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [backups]);
const rows = useMemo(() => {
if (!backups) return [];
const filtered = backups.filter(
(row) =>
(destinationId === "all" || row.destinationId === destinationId) &&
(serviceOwnerId === "all" || row.serviceOwnerId === serviceOwnerId),
);
const sorted = [...filtered].sort(
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
return sortDirection === "asc" ? sorted : sorted.reverse();
}, [backups, destinationId, serviceOwnerId, sortDirection]);
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Backups{" "}
{isFetched && (
<span className="text-sm font-normal text-muted-foreground">
({rows.length})
</span>
)}
</h3>
<div className="flex flex-wrap items-center gap-2">
{isFetched && (
<>
<Select value={destinationId} onValueChange={setDestinationId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Destination" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All destinations</SelectItem>
{destinations.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={serviceOwnerId}
onValueChange={setServiceOwnerId}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Service" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All services</SelectItem>
{services.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() =>
setSortDirection((d) => (d === "desc" ? "asc" : "desc"))
}
>
<ArrowUpDown className="size-4 mr-2" />
{sortDirection === "desc" ? "Newest first" : "Oldest first"}
</Button>
</>
)}
<Button onClick={() => refetch()} disabled={isFetching} size="sm">
{isFetching ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<RefreshCw className="size-4 mr-2" />
)}
Refresh
</Button>
</div>
</div>
{isFetching && !isFetched && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading backups...
</div>
)}
{isFetched && isError && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>Failed to load backups.</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
</Button>
</div>
)}
{isFetched && !isError && rows.length === 0 && (
<div className="flex items-center justify-center py-16 text-muted-foreground">
No backups match the current filters.
</div>
)}
{isFetched && !isError && rows.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Service</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Kind</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const href = detailHref(row);
const name = href ? (
<Link href={href} className="hover:underline">
{row.serviceName}
</Link>
) : (
row.serviceName
);
const canRestoreBackup =
row.kind === "backup" && !!row.serviceOwnerId;
const canRestoreVolumeBackup =
row.kind === "volumeBackup" &&
!!row.serviceOwnerId &&
(row.serviceOwnerType === "application" ||
row.serviceOwnerType === "compose");
return (
<TableRow key={row.deploymentId}>
<TableCell className="whitespace-nowrap text-sm">
{new Date(row.createdAt).toLocaleString()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{renderRowIcon(row)}
{name}
</div>
</TableCell>
<TableCell>{row.destinationName}</TableCell>
<TableCell>
<Badge variant="outline">
{row.kind === "backup" ? "Database" : "Volume"}
</Badge>
</TableCell>
<TableCell>
{row.status && (
<Badge
variant={STATUS_VARIANT[row.status] ?? "secondary"}
>
{row.status}
</Badge>
)}
</TableCell>
<TableCell className="text-right">
{(canRestoreBackup || canRestoreVolumeBackup) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canRestoreBackup && row.serviceOwnerId && (
<RestoreBackup
id={row.serviceOwnerId}
databaseType={row.databaseType as any}
serverId={row.serverId}
backupType={row.backupType ?? "database"}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
Restore
</DropdownMenuItem>
}
/>
)}
{canRestoreVolumeBackup &&
row.serviceOwnerId &&
(row.serviceOwnerType === "application" ||
row.serviceOwnerType === "compose") && (
<RestoreVolumeBackups
id={row.serviceOwnerId}
type={row.serviceOwnerType}
serverId={row.serverId ?? undefined}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
Restore
</DropdownMenuItem>
}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</Card>
);
};
@@ -0,0 +1,377 @@
import type { OverviewDomainSortBy } from "@dokploy/server/services/overview-shared";
import { sortOverviewDomains } from "@dokploy/server/services/overview-shared";
import { ExternalLink, Loader2 } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useSortPreference } from "@/hooks/use-sort-preference";
import { api } from "@/utils/api";
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
const SORT_OPTIONS: { value: OverviewDomainSortBy; label: string }[] = [
{ value: "createdAt-desc", label: "Newest first" },
{ value: "createdAt-asc", label: "Oldest first" },
{ value: "port-asc", label: "Port (low-high)" },
{ value: "port-desc", label: "Port (high-low)" },
];
const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value);
export const ShowOverviewDomains = () => {
const utils = api.useUtils();
const { data: permissions } = api.user.getPermissions.useQuery();
const canToggleDomain = permissions?.domain.create ?? false;
const { data: domains, isLoading } = api.overview.domains.useQuery();
const { data: allProjects } = api.project.all.useQuery();
const { mutateAsync: toggleEnable, isPending: isToggling } =
api.domain.toggleEnable.useMutation();
const [selectedProjectId, setSelectedProjectId] = useState("all");
const [selectedStatus, setSelectedStatus] = useState("all");
const [selectedSsl, setSelectedSsl] = useState("all");
const [selectedPort, setSelectedPort] = useState("all");
const [sortBy, setSort] = useSortPreference<OverviewDomainSortBy>(
"overviewDomainsSort",
"createdAt-desc",
SORT_VALUES,
);
const handleToggleEnable = async (
domain: NonNullable<typeof domains>[number],
) => {
try {
const result = await toggleEnable({ domainId: domain.domainId });
utils.overview.domains.invalidate();
toast.success(result.enabled ? "Domain enabled" : "Domain disabled");
} catch {
toast.error(`Error updating "${domain.host}"`);
}
};
const availablePorts = useMemo(() => {
if (!domains) return [];
const ports = new Set<number>();
for (const domain of domains) {
if (domain.port !== null) ports.add(domain.port);
}
return Array.from(ports).sort((a, b) => a - b);
}, [domains]);
const filteredDomains = useMemo(() => {
if (!domains) return [];
const filtered = domains.filter(
(domain) =>
(selectedProjectId === "all" ||
domain.projectId === selectedProjectId) &&
(selectedStatus === "all" ||
(selectedStatus === "enabled" ? domain.enabled : !domain.enabled)) &&
(selectedSsl === "all" ||
(selectedSsl === "https" ? domain.https : !domain.https)) &&
(selectedPort === "all" || domain.port === Number(selectedPort)),
);
return sortOverviewDomains(filtered, sortBy);
}, [
domains,
selectedProjectId,
selectedStatus,
selectedSsl,
selectedPort,
sortBy,
]);
const [pageSize, setPageSize] = useState(50);
const [pageIndex, setPageIndex] = useState(0);
const pageCount = Math.max(1, Math.ceil(filteredDomains.length / pageSize));
const currentPageIndex = Math.min(pageIndex, pageCount - 1);
const pagedDomains = filteredDomains.slice(
currentPageIndex * pageSize,
currentPageIndex * pageSize + pageSize,
);
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Domains{" "}
<span className="text-sm font-normal text-muted-foreground">
({filteredDomains.length})
</span>
</h3>
<div className="flex flex-wrap items-center gap-2">
<Select
value={selectedProjectId}
onValueChange={setSelectedProjectId}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{allProjects?.map((project) => (
<SelectItem key={project.projectId} value={project.projectId}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
<Select value={selectedSsl} onValueChange={setSelectedSsl}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="SSL" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">HTTP & HTTPS</SelectItem>
<SelectItem value="https">HTTPS only</SelectItem>
<SelectItem value="http">HTTP only</SelectItem>
</SelectContent>
</Select>
{availablePorts.length > 0 && (
<Select value={selectedPort} onValueChange={setSelectedPort}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Port" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All ports</SelectItem>
{availablePorts.map((port) => (
<SelectItem key={port} value={String(port)}>
{port}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select
value={sortBy}
onValueChange={(v) => setSort(v as OverviewDomainSortBy)}
>
<SelectTrigger className="w-[170px]">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading domains...
</div>
)}
{!isLoading && filteredDomains.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>No domains match the current filters.</span>
</div>
)}
{!isLoading && filteredDomains.length > 0 && (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Host</TableHead>
<TableHead>Path</TableHead>
<TableHead>Port</TableHead>
<TableHead>Entrypoint</TableHead>
<TableHead>Protocol</TableHead>
<TableHead>Certificate</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedDomains.map((domain) => {
const href = `/dashboard/project/${domain.projectId}/environment/${domain.environmentId}/services/${domain.serviceOwnerType}/${domain.serviceOwnerId}`;
return (
<TableRow key={domain.domainId}>
<TableCell>
<Link href={href} className="flex flex-col min-w-0">
<span className="font-medium truncate">
{domain.serviceName}
</span>
<span className="text-xs font-normal text-muted-foreground">
{domain.projectName} / {domain.environmentName}
</span>
</Link>
</TableCell>
<TableCell>
<Link
className="flex items-center gap-2 font-medium hover:underline"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
{domain.host}
<ExternalLink className="size-3" />
</Link>
</TableCell>
<TableCell>
<span className="font-mono text-sm">
{domain.path || "/"}
</span>
</TableCell>
<TableCell>
<Badge variant="secondary">{domain.port}</Badge>
</TableCell>
<TableCell>
{domain.customEntrypoint ? (
<span className="font-mono text-sm">
{domain.customEntrypoint}
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>
<Badge variant={domain.https ? "outline" : "secondary"}>
{domain.https ? "HTTPS" : "HTTP"}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{domain.certificateType}
</Badge>
</TableCell>
<TableCell>
<DateTooltip date={domain.createdAt} />
</TableCell>
<TableCell className="text-right">
{canToggleDomain ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-end">
<Switch
checked={domain.enabled}
onCheckedChange={() =>
handleToggleEnable(domain)
}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{domain.enabled
? "Domain is active. Toggle to disable routing without deleting it."
: "Domain is disabled and not routed. Toggle to enable it again."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge
variant={domain.enabled ? "outline" : "secondary"}
>
{domain.enabled ? "Enabled" : "Disabled"}
</Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredDomains.length}{" "}
{filteredDomains.length === 1 ? "domain" : "domains"} total
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value));
setPageIndex(0);
}}
>
<SelectTrigger className="w-[80px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="whitespace-nowrap">
Page {currentPageIndex + 1} of {pageCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex - 1)}
disabled={currentPageIndex === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex + 1)}
disabled={currentPageIndex + 1 >= pageCount}
>
Next
</Button>
</div>
</div>
</div>
</>
)}
</div>
</Card>
);
};
@@ -0,0 +1,547 @@
import type {
OverviewServiceType,
OverviewSortBy,
} from "@dokploy/server/services/overview-shared";
import { sortOverviewServices } from "@dokploy/server/services/overview-shared";
import {
Ban,
CircuitBoard,
GlobeIcon,
Loader2,
MoreHorizontal,
RefreshCw,
Search,
ServerIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useSortPreference } from "@/hooks/use-sort-preference";
import { api } from "@/utils/api";
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
const TYPE_LABELS = {
application: "Application",
postgres: "PostgreSQL",
mariadb: "MariaDB",
mongo: "MongoDB",
mysql: "MySQL",
redis: "Redis",
compose: "Compose",
libsql: "Libsql",
} satisfies Record<OverviewServiceType, string>;
const STATUS_OPTIONS = ["running", "idle", "done", "error"];
// "done" is the steady live state (green); "running" only holds mid-deploy (yellow) — relabeled to match what users expect.
const STATUS_LABELS: Record<string, string> = {
running: "Deploying",
idle: "Idle",
done: "Running",
error: "Error",
};
const SORT_OPTIONS: { value: OverviewSortBy; label: string }[] = [
{ value: "lastDeploy-desc", label: "Recently deployed" },
{ value: "lastDeploy-asc", label: "Oldest deployed" },
{ value: "createdAt-desc", label: "Newest first" },
{ value: "createdAt-asc", label: "Oldest first" },
{ value: "name-asc", label: "Name (A-Z)" },
{ value: "name-desc", label: "Name (Z-A)" },
{ value: "type-asc", label: "Type (A-Z)" },
{ value: "type-desc", label: "Type (Z-A)" },
];
const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value);
// libsql has no deploy/stop actions, same as environment/[environmentId].tsx.
const idKeyByType: Record<string, string> = {
application: "applicationId",
compose: "composeId",
postgres: "postgresId",
mysql: "mysqlId",
mariadb: "mariadbId",
redis: "redisId",
mongo: "mongoId",
};
export const ShowOverviewServices = () => {
const utils = api.useUtils();
const {
data: services,
isLoading,
refetch,
} = api.overview.services.useQuery();
const { data: allProjects } = api.project.all.useQuery();
const [searchQuery, setSearchQuery] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState("all");
const [selectedType, setSelectedType] = useState("all");
const [selectedStatus, setSelectedStatus] = useState("all");
const [selectedServerId, setSelectedServerId] = useState("all");
const [sortBy, setSort] = useSortPreference<OverviewSortBy>(
"overviewServicesSort",
"lastDeploy-desc",
SORT_VALUES,
);
const applicationActions = {
deploy: api.application.deploy.useMutation(),
stop: api.application.stop.useMutation(),
};
const composeActions = {
deploy: api.compose.deploy.useMutation(),
stop: api.compose.stop.useMutation(),
};
const postgresActions = {
deploy: api.postgres.deploy.useMutation(),
stop: api.postgres.stop.useMutation(),
};
const mysqlActions = {
deploy: api.mysql.deploy.useMutation(),
stop: api.mysql.stop.useMutation(),
};
const mariadbActions = {
deploy: api.mariadb.deploy.useMutation(),
stop: api.mariadb.stop.useMutation(),
};
const redisActions = {
deploy: api.redis.deploy.useMutation(),
stop: api.redis.stop.useMutation(),
};
const mongoActions = {
deploy: api.mongo.deploy.useMutation(),
stop: api.mongo.stop.useMutation(),
};
const actionsByType: Record<
string,
{
deploy: { mutateAsync: (input: any) => Promise<unknown> };
stop: { mutateAsync: (input: any) => Promise<unknown> };
}
> = {
application: applicationActions,
compose: composeActions,
postgres: postgresActions,
mysql: mysqlActions,
mariadb: mariadbActions,
redis: redisActions,
mongo: mongoActions,
};
const handleAction = async (
service: NonNullable<typeof services>[number],
action: "deploy" | "stop",
) => {
const actions = actionsByType[service.type];
const idKey = idKeyByType[service.type];
if (!actions || !idKey) return;
const labels =
action === "deploy"
? {
loading: "Deploying",
success: "queued for deployment",
error: "deploying",
}
: { loading: "Stopping", success: "stopped", error: "stopping" };
toast.promise(
(async () => {
await actions[action].mutateAsync({ [idKey]: service.id });
})(),
{
loading: `${labels.loading} ${service.name}...`,
success: () => {
utils.overview.services.invalidate();
return `${service.name} ${labels.success} successfully`;
},
error: (error) =>
`Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
},
);
};
const availableServers = useMemo(() => {
if (!services) return [];
const map = new Map<string, string>();
for (const service of services) {
if (service.serverId && service.serverName) {
map.set(service.serverId, service.serverName);
}
}
return Array.from(map.entries()).map(([serverId, serverName]) => ({
serverId,
serverName,
}));
}, [services]);
const hasServicesWithoutServer = useMemo(
() => !!services?.some((service) => !service.serverId),
[services],
);
const filteredServices = useMemo(() => {
if (!services) return [];
const filtered = services.filter(
(service) =>
service.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
(selectedProjectId === "all" ||
service.projectId === selectedProjectId) &&
(selectedType === "all" || service.type === selectedType) &&
(selectedStatus === "all" || service.status === selectedStatus) &&
(selectedServerId === "all" ||
(selectedServerId === "dokploy-server" && !service.serverId) ||
service.serverId === selectedServerId),
);
return sortOverviewServices(filtered, sortBy);
}, [
services,
searchQuery,
selectedProjectId,
selectedType,
selectedStatus,
selectedServerId,
sortBy,
]);
// pageIndex is clamped against the filtered count each render instead of reset via an effect.
const [pageSize, setPageSize] = useState(50);
const [pageIndex, setPageIndex] = useState(0);
const pageCount = Math.max(1, Math.ceil(filteredServices.length / pageSize));
const currentPageIndex = Math.min(pageIndex, pageCount - 1);
const pagedServices = filteredServices.slice(
currentPageIndex * pageSize,
currentPageIndex * pageSize + pageSize,
);
const renderIcon = (service: NonNullable<typeof services>[number]) => {
if (service.type in DB_ENGINE_ICONS) {
const Icon =
DB_ENGINE_ICONS[service.type as keyof typeof DB_ENGINE_ICONS];
return <Icon className="h-7 w-7" />;
}
if (service.icon) {
return (
<img
src={service.icon}
alt={service.name}
className="size-7 object-contain rounded-sm"
/>
);
}
return service.type === "compose" ? (
<CircuitBoard className="h-6 w-6" />
) : (
<GlobeIcon className="h-6 w-6" />
);
};
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Services{" "}
<span className="text-sm font-normal text-muted-foreground">
({filteredServices.length})
</span>
</h3>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Input
placeholder="Filter services..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-9 w-[200px]"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<Select
value={selectedProjectId}
onValueChange={setSelectedProjectId}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{allProjects?.map((project) => (
<SelectItem key={project.projectId} value={project.projectId}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{Object.entries(TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{STATUS_OPTIONS.map((status) => (
<SelectItem key={status} value={status}>
{STATUS_LABELS[status] ?? status}
</SelectItem>
))}
</SelectContent>
</Select>
{(availableServers.length > 0 || hasServicesWithoutServer) && (
<Select
value={selectedServerId}
onValueChange={setSelectedServerId}
>
<SelectTrigger className="w-[170px]">
<SelectValue placeholder="Server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All servers</SelectItem>
{hasServicesWithoutServer && (
<SelectItem value="dokploy-server">
Dokploy server
</SelectItem>
)}
{availableServers.map((s) => (
<SelectItem key={s.serverId} value={s.serverId}>
{s.serverName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select
value={sortBy}
onValueChange={(v) => setSort(v as OverviewSortBy)}
>
<SelectTrigger className="w-[190px]">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading services...
</div>
)}
{!isLoading && filteredServices.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>No services match the current filters.</span>
</div>
)}
{!isLoading && filteredServices.length > 0 && (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Server</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last Deploy</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedServices.map((service) => {
const href = `/dashboard/project/${service.projectId}/environment/${service.environmentId}/services/${service.type}/${service.id}`;
const hasActions = service.type in actionsByType;
return (
<TableRow key={service.id}>
<TableCell>
<Link href={href} className="flex items-center gap-2">
{renderIcon(service)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">
{service.name}
</span>
<span className="text-xs font-normal text-muted-foreground">
{service.projectName} / {service.environmentName}
</span>
</div>
</Link>
</TableCell>
<TableCell>{TYPE_LABELS[service.type]}</TableCell>
<TableCell>
<StatusTooltip status={service.status as any} />
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 text-muted-foreground">
<ServerIcon className="size-3.5" />
<span className="truncate">
{service.serverName ?? "Dokploy server"}
</span>
</div>
</TableCell>
<TableCell>
<DateTooltip date={service.createdAt} />
</TableCell>
<TableCell>
{service.lastDeployAt ? (
<DateTooltip date={service.lastDeployAt} />
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-right">
{hasActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel className="truncate">
{service.name}
</DropdownMenuLabel>
<DialogAction
title="Deploy Service"
description={`Are you sure you want to deploy "${service.name}"?`}
type="default"
onClick={() => handleAction(service, "deploy")}
>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={(e) => e.preventDefault()}
>
<RefreshCw className="size-4" />
Deploy
</DropdownMenuItem>
</DialogAction>
<DialogAction
title="Stop Service"
description={`Are you sure you want to stop "${service.name}"?`}
onClick={() => handleAction(service, "stop")}
>
<DropdownMenuItem
className="flex items-center gap-2 text-orange-500 focus:text-orange-500"
onSelect={(e) => e.preventDefault()}
>
<Ban className="size-4" />
Stop
</DropdownMenuItem>
</DialogAction>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredServices.length}{" "}
{filteredServices.length === 1 ? "service" : "services"} total
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value));
setPageIndex(0);
}}
>
<SelectTrigger className="w-[80px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="whitespace-nowrap">
Page {currentPageIndex + 1} of {pageCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex - 1)}
disabled={currentPageIndex === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex + 1)}
disabled={currentPageIndex + 1 >= pageCount}
>
Next
</Button>
</div>
</div>
</div>
</>
)}
</div>
</Card>
);
};
@@ -211,6 +211,15 @@ export const LibsqlIcon = ({ className }: Props) => {
);
};
export const DB_ENGINE_ICONS = {
postgres: PostgresqlIcon,
mariadb: MariadbIcon,
mysql: MysqlIcon,
mongo: MongodbIcon,
redis: RedisIcon,
libsql: LibsqlIcon,
} as const;
export const GitlabIcon = ({ className }: Props) => {
return (
<svg
+9
View File
@@ -23,6 +23,7 @@ import {
House,
Key,
KeyRound,
LayoutGrid,
Loader2,
LogIn,
type LucideIcon,
@@ -215,6 +216,14 @@ const MENU: Menu = {
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
},
{
isSingle: true,
title: "Overview",
url: "/dashboard/overview",
icon: LayoutGrid,
// Only enabled for users with access to services
isEnabled: ({ permissions }) => !!permissions?.service.read,
},
{
isSingle: true,
title: "Requests",
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
// Initializes from defaultValue (not localStorage) to avoid an SSR/client hydration mismatch, then syncs from localStorage after mount.
export const useSortPreference = <T extends string>(
key: string,
defaultValue: T,
allowedValues: readonly T[],
) => {
const [value, setValue] = useState<T>(defaultValue);
useEffect(() => {
const stored = localStorage.getItem(key);
if (stored && (allowedValues as readonly string[]).includes(stored)) {
setValue(stored as T);
}
}, [key]);
const setPreference = (next: T) => {
setValue(next);
localStorage.setItem(key, next);
};
return [value, setPreference] as const;
};
+118
View File
@@ -0,0 +1,118 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowOverviewBackups } from "@/components/dashboard/overview/show-overview-backups";
import { ShowOverviewDomains } from "@/components/dashboard/overview/show-overview-domains";
import { ShowOverviewServices } from "@/components/dashboard/overview/show-overview-services";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const DEFAULT_TAB = "services";
const Overview = () => {
const router = useRouter();
const { data: permissions } = api.user.getPermissions.useQuery();
const canSeeBackups =
!!permissions?.backup.read && !!permissions?.volumeBackup.read;
const canSeeDomains = !!permissions?.domain.read;
const queryTab =
typeof router.query.tab === "string" ? router.query.tab : DEFAULT_TAB;
const activeTab =
(queryTab === "backups" && !canSeeBackups) ||
(queryTab === "domains" && !canSeeDomains)
? DEFAULT_TAB
: queryTab;
const setTab = (value: string) => {
const { tab: _current, ...query } = router.query;
router.replace(
{
pathname: router.pathname,
query: value === DEFAULT_TAB ? query : { ...query, tab: value },
},
undefined,
{ shallow: true },
);
};
return (
<Tabs value={activeTab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="services">Services</TabsTrigger>
{canSeeBackups && <TabsTrigger value="backups">Backups</TabsTrigger>}
{canSeeDomains && <TabsTrigger value="domains">Domains</TabsTrigger>}
</TabsList>
<TabsContent value="services">
<ShowOverviewServices />
</TabsContent>
{canSeeBackups && (
<TabsContent value="backups">
<ShowOverviewBackups />
</TabsContent>
)}
{canSeeDomains && (
<TabsContent value="domains">
<ShowOverviewDomains />
</TabsContent>
)}
</Tabs>
);
};
export default Overview;
Overview.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const { req, res } = ctx;
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,
});
try {
const userPermissions = await helpers.user.getPermissions.fetch();
if (!userPermissions?.service.read) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
return {
props: {
trpcState: helpers.dehydrate(),
},
};
} catch {
return {
props: {},
};
}
}
+2
View File
@@ -28,6 +28,7 @@ import { mysqlRouter } from "./routers/mysql";
import { networkRouter } from "./routers/network";
import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { overviewRouter } from "./routers/overview";
import { patchRouter } from "./routers/patch";
import { portRouter } from "./routers/port";
import { postgresRouter } from "./routers/postgres";
@@ -118,6 +119,7 @@ export const appRouter = createTRPCRouter({
environment: environmentRouter,
tag: tagRouter,
patch: patchRouter,
overview: overviewRouter,
});
// export type definition of API
@@ -0,0 +1,54 @@
import {
getAllBackupsForOrganization,
getAllDomainsForOrganization,
getAllServicesForOrganization,
} from "@dokploy/server";
import {
findMemberByUserId,
hasPermission,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const overviewRouter = createTRPCRouter({
services: withPermission("service", "read").query(async ({ ctx }) => {
const orgId = ctx.session.activeOrganizationId;
const accessedServices =
ctx.user.role !== "owner" && ctx.user.role !== "admin"
? (await findMemberByUserId(ctx.user.id, orgId)).accessedServices
: null;
return getAllServicesForOrganization(orgId, accessedServices);
}),
// Reads backup and/or volumeBackup run history depending on which the user can see.
backups: protectedProcedure.query(async ({ ctx }) => {
const [canReadBackups, canReadVolumeBackups] = await Promise.all([
hasPermission(ctx, { backup: ["read"] }),
hasPermission(ctx, { volumeBackup: ["read"] }),
]);
if (!canReadBackups && !canReadVolumeBackups) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to backups or volume backups",
});
}
const orgId = ctx.session.activeOrganizationId;
const accessedServices =
ctx.user.role !== "owner" && ctx.user.role !== "admin"
? (await findMemberByUserId(ctx.user.id, orgId)).accessedServices
: null;
return getAllBackupsForOrganization(orgId, accessedServices, {
backup: canReadBackups,
volumeBackup: canReadVolumeBackups,
});
}),
domains: withPermission("domain", "read").query(async ({ ctx }) => {
const orgId = ctx.session.activeOrganizationId;
const accessedServices =
ctx.user.role !== "owner" && ctx.user.role !== "admin"
? (await findMemberByUserId(ctx.user.id, orgId)).accessedServices
: null;
return getAllDomainsForOrganization(orgId, accessedServices);
}),
});
+1
View File
@@ -33,6 +33,7 @@ export * from "./services/mount";
export * from "./services/mysql";
export * from "./services/network";
export * from "./services/notification";
export * from "./services/overview";
export * from "./services/patch";
export * from "./services/patch-repo";
export * from "./services/port";
@@ -0,0 +1,183 @@
// No db/schema imports here — client components import this file directly, and dockerode/ssh2 (child_process) would break the browser bundle.
export type OverviewServiceType =
| "application"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis"
| "compose"
| "libsql";
export interface OverviewService {
id: string;
type: OverviewServiceType;
name: string;
appName: string;
status: string | null;
createdAt: string;
serverId: string | null;
serverName: string | null;
icon: string | null;
projectId: string;
projectName: string;
environmentId: string;
environmentName: string;
lastDeployAt: string | null;
}
export type OverviewSortBy =
| "name-asc"
| "name-desc"
| "type-asc"
| "type-desc"
| "createdAt-asc"
| "createdAt-desc"
| "lastDeploy-asc"
| "lastDeploy-desc";
export const sortOverviewServices = (
services: OverviewService[],
sortBy: OverviewSortBy,
): OverviewService[] => {
const [field, direction] = sortBy.split("-") as [string, "asc" | "desc"];
return [...services].sort((a, b) => {
if (field === "name") {
const cmp = a.name.localeCompare(b.name);
return direction === "asc" ? cmp : -cmp;
}
if (field === "type") {
const cmp = a.type.localeCompare(b.type);
return direction === "asc" ? cmp : -cmp;
}
if (field === "createdAt") {
const cmp =
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
return direction === "asc" ? cmp : -cmp;
}
// lastDeploy: services without a deploy always sort to the end.
const aDate = a.lastDeployAt ? new Date(a.lastDeployAt).getTime() : null;
const bDate = b.lastDeployAt ? new Date(b.lastDeployAt).getTime() : null;
if (aDate === null && bDate === null) return 0;
if (aDate === null) return 1;
if (bDate === null) return -1;
return direction === "desc" ? bDate - aDate : aDate - bDate;
});
};
export const DB_ENGINE_ICON_TYPES = new Set([
"postgres",
"mariadb",
"mysql",
"mongo",
"redis",
"libsql",
]);
export const getServiceOverviewIcon = (service: {
type: OverviewServiceType;
icon: string | null;
}):
| { kind: "db"; engine: string }
| { kind: "custom"; url: string }
| { kind: "generic"; type: "application" | "compose" } => {
if (DB_ENGINE_ICON_TYPES.has(service.type)) {
return { kind: "db", engine: service.type };
}
if (service.icon) {
return { kind: "custom", url: service.icon };
}
return { kind: "generic", type: service.type as "application" | "compose" };
};
export type OverviewBackupKind = "backup" | "volumeBackup";
export interface OverviewBackup {
deploymentId: string;
kind: OverviewBackupKind;
status: string | null;
createdAt: string;
destinationId: string;
destinationName: string;
databaseType: string | null;
serviceType: string | null;
backupType: "database" | "compose" | null;
serviceName: string;
serviceOwnerId: string | null;
serviceOwnerType: OverviewServiceType | "web-server";
serverId: string | null;
projectId: string | null;
environmentId: string | null;
}
export type OverviewDomainOwnerType = "application" | "compose";
export interface OverviewDomain {
domainId: string;
host: string;
path: string | null;
port: number | null;
customEntrypoint: string | null;
https: boolean;
certificateType: "letsencrypt" | "none" | "custom";
createdAt: string;
enabled: boolean;
domainType: "application" | "compose" | "preview" | null;
serviceOwnerId: string;
serviceOwnerType: OverviewDomainOwnerType;
serviceName: string;
projectId: string;
projectName: string;
environmentId: string;
environmentName: string;
}
export type OverviewDomainSortBy =
| "createdAt-asc"
| "createdAt-desc"
| "port-asc"
| "port-desc";
export const sortOverviewDomains = (
domains: OverviewDomain[],
sortBy: OverviewDomainSortBy,
): OverviewDomain[] => {
const [field, direction] = sortBy.split("-") as [string, "asc" | "desc"];
return [...domains].sort((a, b) => {
if (field === "port") {
// Domains without a port sort to the end regardless of direction.
const aPort = a.port;
const bPort = b.port;
if (aPort === null && bPort === null) return 0;
if (aPort === null) return 1;
if (bPort === null) return -1;
return direction === "asc" ? aPort - bPort : bPort - aPort;
}
const cmp =
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
return direction === "asc" ? cmp : -cmp;
});
};
export const getBackupOverviewIcon = (
row: Pick<
OverviewBackup,
"databaseType" | "serviceType" | "serviceOwnerType"
>,
):
| { kind: "db"; engine: string }
| { kind: "generic"; type: "application" | "compose" }
| { kind: "webServer" } => {
const engine = row.databaseType ?? row.serviceType;
if (engine === "web-server") {
return { kind: "webServer" };
}
if (engine && DB_ENGINE_ICON_TYPES.has(engine)) {
return { kind: "db", engine };
}
if (row.serviceOwnerType === "compose") {
return { kind: "generic", type: "compose" };
}
return { kind: "generic", type: "application" };
};
+810
View File
@@ -0,0 +1,810 @@
import { db } from "@dokploy/server/db";
import {
applications,
backups,
compose,
deployments,
destinations,
domains,
environments,
libsql,
mariadb,
mongo,
mysql,
postgres,
previewDeployments,
projects,
redis,
server as serverTable,
volumeBackups,
} from "@dokploy/server/db/schema";
import { and, desc, eq, inArray, isNull, max, or } from "drizzle-orm";
import type { AnyPgColumn } from "drizzle-orm/pg-core";
import type {
OverviewBackup,
OverviewDomain,
OverviewDomainOwnerType,
OverviewService,
OverviewServiceType,
} from "./overview-shared";
// Re-exported for existing "./overview" consumers; client components should import overview-shared directly.
export * from "./overview-shared";
type TypeQueryConfig = {
type: OverviewServiceType;
table:
| typeof applications
| typeof postgres
| typeof mysql
| typeof mariadb
| typeof mongo
| typeof redis
| typeof compose
| typeof libsql;
idColumn: { name: string };
statusColumn: { name: string };
hasIcon: boolean;
};
async function getServicesOfType(
config: TypeQueryConfig,
orgId: string,
accessedServices: string[] | null,
): Promise<OverviewService[]> {
const table = config.table as typeof applications;
const idCol = (table as unknown as Record<string, unknown>)[
config.idColumn.name
] as typeof applications.applicationId;
const statusCol = (table as unknown as Record<string, unknown>)[
config.statusColumn.name
] as typeof applications.applicationStatus;
const iconCol = config.hasIcon
? ((table as unknown as Record<string, unknown>)
.icon as typeof applications.icon)
: null;
const conditions = [
eq(projects.organizationId, orgId),
...(accessedServices !== null ? [inArray(idCol, accessedServices)] : []),
];
const baseSelect = {
id: idCol,
name: table.name,
appName: table.appName,
status: statusCol,
createdAt: table.createdAt,
serverId: table.serverId,
serverName: serverTable.name,
projectId: projects.projectId,
projectName: projects.name,
environmentId: environments.environmentId,
environmentName: environments.name,
};
// Only select icon when the table has that column — other service tables don't.
const rows = await db
.select(iconCol ? { ...baseSelect, icon: iconCol } : baseSelect)
.from(table)
.innerJoin(
environments,
eq(table.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.leftJoin(serverTable, eq(table.serverId, serverTable.serverId))
.where(and(...conditions));
return rows.map((row) => ({
...row,
icon: "icon" in row ? (row.icon as string | null) : null,
type: config.type,
lastDeployAt: null,
}));
}
const SERVICE_TYPE_CONFIGS: TypeQueryConfig[] = [
{
type: "application",
table: applications,
idColumn: { name: "applicationId" },
statusColumn: { name: "applicationStatus" },
hasIcon: true,
},
{
type: "postgres",
table: postgres,
idColumn: { name: "postgresId" },
statusColumn: { name: "applicationStatus" },
hasIcon: false,
},
{
type: "mysql",
table: mysql,
idColumn: { name: "mysqlId" },
statusColumn: { name: "applicationStatus" },
hasIcon: false,
},
{
type: "mariadb",
table: mariadb,
idColumn: { name: "mariadbId" },
statusColumn: { name: "applicationStatus" },
hasIcon: false,
},
{
type: "mongo",
table: mongo,
idColumn: { name: "mongoId" },
statusColumn: { name: "applicationStatus" },
hasIcon: false,
},
{
type: "redis",
table: redis,
idColumn: { name: "redisId" },
statusColumn: { name: "applicationStatus" },
hasIcon: false,
},
{
type: "compose",
table: compose,
idColumn: { name: "composeId" },
statusColumn: { name: "composeStatus" },
hasIcon: true,
},
{
type: "libsql",
table: libsql,
idColumn: { name: "libsqlId" },
statusColumn: { name: "applicationStatus" },
hasIcon: false,
},
];
// Only application/compose have a deployments FK, so lastDeployAt only applies to those.
async function attachLastDeployAt(
services: OverviewService[],
): Promise<OverviewService[]> {
const applicationIds = services
.filter((s) => s.type === "application")
.map((s) => s.id);
const composeIds = services
.filter((s) => s.type === "compose")
.map((s) => s.id);
const [appDeploys, composeDeploys] = await Promise.all([
applicationIds.length > 0
? db
.select({
applicationId: deployments.applicationId,
lastDeployAt: max(deployments.createdAt),
})
.from(deployments)
.where(inArray(deployments.applicationId, applicationIds))
.groupBy(deployments.applicationId)
: Promise.resolve([]),
composeIds.length > 0
? db
.select({
composeId: deployments.composeId,
lastDeployAt: max(deployments.createdAt),
})
.from(deployments)
.where(inArray(deployments.composeId, composeIds))
.groupBy(deployments.composeId)
: Promise.resolve([]),
]);
const lastAppDeploy = new Map<string, string>();
for (const row of appDeploys) {
if (row.applicationId && row.lastDeployAt) {
lastAppDeploy.set(row.applicationId, row.lastDeployAt);
}
}
const lastComposeDeploy = new Map<string, string>();
for (const row of composeDeploys) {
if (row.composeId && row.lastDeployAt) {
lastComposeDeploy.set(row.composeId, row.lastDeployAt);
}
}
return services.map((service) => {
if (service.type === "application") {
return {
...service,
lastDeployAt: lastAppDeploy.get(service.id) ?? null,
};
}
if (service.type === "compose") {
return {
...service,
lastDeployAt: lastComposeDeploy.get(service.id) ?? null,
};
}
return service;
});
}
export const getAllServicesForOrganization = async (
orgId: string,
accessedServices: string[] | null,
): Promise<OverviewService[]> => {
if (accessedServices !== null && accessedServices.length === 0) {
return [];
}
const results = await Promise.all(
SERVICE_TYPE_CONFIGS.map((config) =>
getServicesOfType(config, orgId, accessedServices),
),
);
return attachLastDeployAt(results.flat());
};
type Owner = {
id: string;
name: string;
serverId: string | null;
type: OverviewServiceType;
projectId: string | null;
environmentId: string | null;
};
type OwnerRow = {
name: string;
serverId: string | null;
environment?: {
environmentId: string;
project?: { projectId: string } | null;
} | null;
};
function ownerFrom(
row: OwnerRow,
id: string,
type: OverviewServiceType,
): Owner {
return {
id,
name: row.name,
serverId: row.serverId,
type,
projectId: row.environment?.project?.projectId ?? null,
environmentId: row.environment?.environmentId ?? null,
};
}
function pickBackupOwner(backup: {
postgres?: (OwnerRow & { postgresId: string }) | null;
mariadb?: (OwnerRow & { mariadbId: string }) | null;
mysql?: (OwnerRow & { mysqlId: string }) | null;
mongo?: (OwnerRow & { mongoId: string }) | null;
libsql?: (OwnerRow & { libsqlId: string }) | null;
compose?: (OwnerRow & { composeId: string }) | null;
}): Owner | null {
if (backup.postgres)
return ownerFrom(backup.postgres, backup.postgres.postgresId, "postgres");
if (backup.mariadb)
return ownerFrom(backup.mariadb, backup.mariadb.mariadbId, "mariadb");
if (backup.mysql)
return ownerFrom(backup.mysql, backup.mysql.mysqlId, "mysql");
if (backup.mongo)
return ownerFrom(backup.mongo, backup.mongo.mongoId, "mongo");
if (backup.libsql)
return ownerFrom(backup.libsql, backup.libsql.libsqlId, "libsql");
if (backup.compose)
return ownerFrom(backup.compose, backup.compose.composeId, "compose");
return null;
}
function pickVolumeBackupOwner(volumeBackup: {
application?: (OwnerRow & { applicationId: string }) | null;
postgres?: (OwnerRow & { postgresId: string }) | null;
mariadb?: (OwnerRow & { mariadbId: string }) | null;
mysql?: (OwnerRow & { mysqlId: string }) | null;
mongo?: (OwnerRow & { mongoId: string }) | null;
redis?: (OwnerRow & { redisId: string }) | null;
libsql?: (OwnerRow & { libsqlId: string }) | null;
compose?: (OwnerRow & { composeId: string }) | null;
}): Owner | null {
if (volumeBackup.application)
return ownerFrom(
volumeBackup.application,
volumeBackup.application.applicationId,
"application",
);
if (volumeBackup.postgres)
return ownerFrom(
volumeBackup.postgres,
volumeBackup.postgres.postgresId,
"postgres",
);
if (volumeBackup.mariadb)
return ownerFrom(
volumeBackup.mariadb,
volumeBackup.mariadb.mariadbId,
"mariadb",
);
if (volumeBackup.mysql)
return ownerFrom(volumeBackup.mysql, volumeBackup.mysql.mysqlId, "mysql");
if (volumeBackup.mongo)
return ownerFrom(volumeBackup.mongo, volumeBackup.mongo.mongoId, "mongo");
if (volumeBackup.redis)
return ownerFrom(volumeBackup.redis, volumeBackup.redis.redisId, "redis");
if (volumeBackup.libsql)
return ownerFrom(
volumeBackup.libsql,
volumeBackup.libsql.libsqlId,
"libsql",
);
if (volumeBackup.compose)
return ownerFrom(
volumeBackup.compose,
volumeBackup.compose.composeId,
"compose",
);
return null;
}
// Ownerless backups (e.g. the Dokploy host's own database) are visible to everyone.
function ownerAccessCondition(
ownerColumns: AnyPgColumn[],
accessedServices: string[] | null,
) {
if (accessedServices === null) return undefined;
return or(
and(...ownerColumns.map((col) => isNull(col))),
...ownerColumns.map((col) => inArray(col, accessedServices)),
);
}
async function getBackupIdsInOrg(
orgId: string,
accessedServices: string[] | null,
): Promise<string[]> {
const accessCondition = ownerAccessCondition(
[
backups.postgresId,
backups.mariadbId,
backups.mysqlId,
backups.mongoId,
backups.libsqlId,
backups.composeId,
],
accessedServices,
);
const rows = await db
.select({ backupId: backups.backupId })
.from(backups)
.innerJoin(
destinations,
eq(backups.destinationId, destinations.destinationId),
)
.where(
and(
eq(destinations.organizationId, orgId),
...(accessCondition ? [accessCondition] : []),
),
);
return rows.map((r) => r.backupId);
}
async function getVolumeBackupIdsInOrg(
orgId: string,
accessedServices: string[] | null,
): Promise<string[]> {
const accessCondition = ownerAccessCondition(
[
volumeBackups.applicationId,
volumeBackups.postgresId,
volumeBackups.mariadbId,
volumeBackups.mysqlId,
volumeBackups.mongoId,
volumeBackups.redisId,
volumeBackups.libsqlId,
volumeBackups.composeId,
],
accessedServices,
);
const rows = await db
.select({ volumeBackupId: volumeBackups.volumeBackupId })
.from(volumeBackups)
.innerJoin(
destinations,
eq(volumeBackups.destinationId, destinations.destinationId),
)
.where(
and(
eq(destinations.organizationId, orgId),
...(accessCondition ? [accessCondition] : []),
),
);
return rows.map((r) => r.volumeBackupId);
}
const ownerServiceColumns = {
columns: { name: true, serverId: true },
with: {
environment: {
columns: { environmentId: true },
with: { project: { columns: { projectId: true } } },
},
},
} as const;
// "Backups" here = run history (deployments with backupId/volumeBackupId), not the schedule configs.
export const getAllBackupsForOrganization = async (
orgId: string,
accessedServices: string[] | null,
permissions: { backup: boolean; volumeBackup: boolean },
): Promise<OverviewBackup[]> => {
if (accessedServices !== null && accessedServices.length === 0) {
return [];
}
const [backupIds, volumeBackupIds] = await Promise.all([
permissions.backup
? getBackupIdsInOrg(orgId, accessedServices)
: Promise.resolve([]),
permissions.volumeBackup
? getVolumeBackupIdsInOrg(orgId, accessedServices)
: Promise.resolve([]),
]);
if (backupIds.length === 0 && volumeBackupIds.length === 0) {
return [];
}
const conditions = [
...(backupIds.length > 0 ? [inArray(deployments.backupId, backupIds)] : []),
...(volumeBackupIds.length > 0
? [inArray(deployments.volumeBackupId, volumeBackupIds)]
: []),
];
const whereClause =
conditions.length === 1 ? conditions[0] : or(...conditions);
const rows = await db.query.deployments.findMany({
where: whereClause,
orderBy: desc(deployments.createdAt),
limit: 500,
with: {
backup: {
with: {
destination: { columns: { destinationId: true, name: true } },
postgres: {
columns: { postgresId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
mariadb: {
columns: { mariadbId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
mysql: {
columns: { mysqlId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
mongo: {
columns: { mongoId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
libsql: {
columns: { libsqlId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
compose: {
columns: { composeId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
},
},
volumeBackup: {
with: {
destination: { columns: { destinationId: true, name: true } },
application: {
columns: { applicationId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
postgres: {
columns: { postgresId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
mariadb: {
columns: { mariadbId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
mysql: {
columns: { mysqlId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
mongo: {
columns: { mongoId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
redis: {
columns: { redisId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
libsql: {
columns: { libsqlId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
compose: {
columns: { composeId: true, ...ownerServiceColumns.columns },
with: ownerServiceColumns.with,
},
},
},
},
});
const result: OverviewBackup[] = [];
for (const deployment of rows) {
if (deployment.backup) {
const owner = pickBackupOwner(deployment.backup);
result.push({
deploymentId: deployment.deploymentId,
kind: "backup",
status: deployment.status,
createdAt: deployment.createdAt,
destinationId: deployment.backup.destination?.destinationId ?? "",
destinationName: deployment.backup.destination?.name ?? "Unknown",
databaseType: deployment.backup.databaseType,
serviceType: null,
backupType: deployment.backup.backupType,
serviceName: owner?.name ?? "Dokploy Server",
serviceOwnerId: owner?.id ?? null,
serviceOwnerType: owner?.type ?? "web-server",
serverId: owner?.serverId ?? null,
projectId: owner?.projectId ?? null,
environmentId: owner?.environmentId ?? null,
});
} else if (deployment.volumeBackup) {
const owner = pickVolumeBackupOwner(deployment.volumeBackup);
result.push({
deploymentId: deployment.deploymentId,
kind: "volumeBackup",
status: deployment.status,
createdAt: deployment.createdAt,
destinationId: deployment.volumeBackup.destination?.destinationId ?? "",
destinationName: deployment.volumeBackup.destination?.name ?? "Unknown",
databaseType: null,
serviceType: deployment.volumeBackup.serviceType,
backupType: null,
serviceName:
owner?.name ?? deployment.volumeBackup.serviceName ?? "Unknown",
serviceOwnerId: owner?.id ?? null,
serviceOwnerType:
owner?.type ??
(deployment.volumeBackup.serviceType as OverviewServiceType),
serverId: owner?.serverId ?? null,
projectId: owner?.projectId ?? null,
environmentId: owner?.environmentId ?? null,
});
}
}
return result;
};
type DomainOwnerRow = {
name: string;
environment?: {
environmentId: string;
name: string;
project?: {
projectId: string;
name: string;
organizationId: string;
} | null;
} | null;
};
type DomainOwner = {
id: string;
name: string;
type: OverviewDomainOwnerType;
organizationId: string | null;
projectId: string | null;
projectName: string | null;
environmentId: string | null;
environmentName: string | null;
};
const domainOwnerColumns = {
columns: { name: true },
with: {
environment: {
columns: { environmentId: true, name: true },
with: {
project: {
columns: { projectId: true, name: true, organizationId: true },
},
},
},
},
} as const;
function domainOwnerFrom(
row: DomainOwnerRow,
id: string,
type: OverviewDomainOwnerType,
): DomainOwner {
return {
id,
name: row.name,
type,
organizationId: row.environment?.project?.organizationId ?? null,
projectId: row.environment?.project?.projectId ?? null,
projectName: row.environment?.project?.name ?? null,
environmentId: row.environment?.environmentId ?? null,
environmentName: row.environment?.name ?? null,
};
}
// Preview domains inherit access from their parent application, so they're scoped by the same accessedServices check as applications.
async function getDomainScopeIdsInOrg(
orgId: string,
accessedServices: string[] | null,
): Promise<{ appIds: string[]; composeIds: string[]; previewIds: string[] }> {
const [appRows, composeRows, previewRows] = await Promise.all([
db
.select({ id: applications.applicationId })
.from(applications)
.innerJoin(
environments,
eq(applications.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(
accessedServices !== null
? and(
eq(projects.organizationId, orgId),
inArray(applications.applicationId, accessedServices),
)
: eq(projects.organizationId, orgId),
),
db
.select({ id: compose.composeId })
.from(compose)
.innerJoin(
environments,
eq(compose.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(
accessedServices !== null
? and(
eq(projects.organizationId, orgId),
inArray(compose.composeId, accessedServices),
)
: eq(projects.organizationId, orgId),
),
db
.select({ id: previewDeployments.previewDeploymentId })
.from(previewDeployments)
.innerJoin(
applications,
eq(previewDeployments.applicationId, applications.applicationId),
)
.innerJoin(
environments,
eq(applications.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(
accessedServices !== null
? and(
eq(projects.organizationId, orgId),
inArray(applications.applicationId, accessedServices),
)
: eq(projects.organizationId, orgId),
),
]);
return {
appIds: appRows.map((r) => r.id),
composeIds: composeRows.map((r) => r.id),
previewIds: previewRows.map((r) => r.id),
};
}
export const getAllDomainsForOrganization = async (
orgId: string,
accessedServices: string[] | null,
): Promise<OverviewDomain[]> => {
if (accessedServices !== null && accessedServices.length === 0) {
return [];
}
const { appIds, composeIds, previewIds } = await getDomainScopeIdsInOrg(
orgId,
accessedServices,
);
if (
appIds.length === 0 &&
composeIds.length === 0 &&
previewIds.length === 0
) {
return [];
}
const scopeConditions = [
...(appIds.length > 0 ? [inArray(domains.applicationId, appIds)] : []),
...(composeIds.length > 0 ? [inArray(domains.composeId, composeIds)] : []),
...(previewIds.length > 0
? [inArray(domains.previewDeploymentId, previewIds)]
: []),
];
const rows = await db.query.domains.findMany({
where: or(...scopeConditions),
with: {
application: {
columns: { applicationId: true, ...domainOwnerColumns.columns },
with: domainOwnerColumns.with,
},
compose: {
columns: { composeId: true, ...domainOwnerColumns.columns },
with: domainOwnerColumns.with,
},
previewDeployment: {
columns: {},
with: {
application: {
columns: { applicationId: true, ...domainOwnerColumns.columns },
with: domainOwnerColumns.with,
},
},
},
},
});
const result: OverviewDomain[] = [];
for (const domain of rows) {
const owner = domain.application
? domainOwnerFrom(
domain.application,
domain.application.applicationId,
"application",
)
: domain.compose
? domainOwnerFrom(domain.compose, domain.compose.composeId, "compose")
: domain.previewDeployment?.application
? domainOwnerFrom(
domain.previewDeployment.application,
domain.previewDeployment.application.applicationId,
"application",
)
: null;
if (!owner || owner.organizationId !== orgId) continue;
if (accessedServices !== null && !accessedServices.includes(owner.id)) {
continue;
}
result.push({
domainId: domain.domainId,
host: domain.host,
path: domain.path,
port: domain.port,
customEntrypoint: domain.customEntrypoint,
https: domain.https,
certificateType: domain.certificateType,
createdAt: domain.createdAt,
enabled: domain.enabled,
domainType: domain.domainType,
serviceOwnerId: owner.id,
serviceOwnerType: owner.type,
serviceName: owner.name,
projectId: owner.projectId ?? "",
projectName: owner.projectName ?? "",
environmentId: owner.environmentId ?? "",
environmentName: owner.environmentName ?? "",
});
}
return result;
};