mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 13:21:11 +00:00
feat: add Overview dashboard (Services, Backups & Domains tabs)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
+13
-5
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
@@ -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" };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user