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

This commit is contained in:
Narciso
2026-08-13 17:03:42 -04:00
parent 1183c7c4a9
commit 046a608f8c
17 changed files with 2770 additions and 9 deletions
@@ -50,6 +50,7 @@ interface Props {
id: string;
type: "application" | "compose";
serverId?: string;
trigger?: React.ReactNode;
}
const RestoreBackupSchema = z.object({
@@ -64,7 +65,12 @@ const RestoreBackupSchema = z.object({
}),
});
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
export const RestoreVolumeBackups = ({
id,
type,
serverId,
trigger,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
@@ -144,10 +150,12 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Volume Backup
</Button>
{trigger ?? (
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Volume Backup
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
@@ -74,6 +74,7 @@ interface Props {
databaseType?: DatabaseType;
serverId?: string | null;
backupType?: "database" | "compose";
trigger?: React.ReactNode;
}
const RestoreBackupSchema = z
@@ -200,6 +201,7 @@ export const RestoreBackup = ({
databaseType,
serverId,
backupType = "database",
trigger,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -311,10 +313,12 @@ export const RestoreBackup = ({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</Button>
{trigger ?? (
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
@@ -0,0 +1,317 @@
import type { OverviewBackup } from "@dokploy/server/services/overview-shared";
import { getBackupOverviewIcon } from "@dokploy/server/services/overview-shared";
import {
ArrowUpDown,
CircuitBoard,
GlobeIcon,
Loader2,
MoreHorizontal,
RefreshCw,
ServerIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { RestoreVolumeBackups } from "@/components/dashboard/application/volume-backups/restore-volume-backups";
import { RestoreBackup } from "@/components/dashboard/database/backups/restore-backup";
import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const STATUS_VARIANT: Record<string, "default" | "destructive" | "secondary"> =
{
done: "default",
running: "secondary",
error: "destructive",
cancelled: "destructive",
};
const renderRowIcon = (row: OverviewBackup) => {
const icon = getBackupOverviewIcon(row);
if (icon.kind === "db") {
const Icon = DB_ENGINE_ICONS[icon.engine as keyof typeof DB_ENGINE_ICONS];
return Icon ? <Icon className="h-5 w-5" /> : null;
}
if (icon.kind === "webServer") {
return <ServerIcon className="h-5 w-5 text-muted-foreground" />;
}
return icon.type === "compose" ? (
<CircuitBoard className="h-5 w-5" />
) : (
<GlobeIcon className="h-5 w-5" />
);
};
const detailHref = (row: OverviewBackup) => {
if (!row.projectId || !row.environmentId || !row.serviceOwnerId) return null;
return `/dashboard/project/${row.projectId}/environment/${row.environmentId}/services/${row.serviceOwnerType}/${row.serviceOwnerId}`;
};
export const ShowOverviewBackups = () => {
const {
data: backups,
isFetching,
isFetched,
isError,
refetch,
} = api.overview.backups.useQuery(undefined, {
staleTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
});
const [destinationId, setDestinationId] = useState("all");
const [serviceOwnerId, setServiceOwnerId] = useState("all");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const destinations = useMemo(() => {
if (!backups) return [];
const map = new Map<string, string>();
for (const row of backups) map.set(row.destinationId, row.destinationName);
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [backups]);
const services = useMemo(() => {
if (!backups) return [];
const map = new Map<string, string>();
for (const row of backups) {
if (row.serviceOwnerId) map.set(row.serviceOwnerId, row.serviceName);
}
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [backups]);
const rows = useMemo(() => {
if (!backups) return [];
const filtered = backups.filter(
(row) =>
(destinationId === "all" || row.destinationId === destinationId) &&
(serviceOwnerId === "all" || row.serviceOwnerId === serviceOwnerId),
);
const sorted = [...filtered].sort(
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
return sortDirection === "asc" ? sorted : sorted.reverse();
}, [backups, destinationId, serviceOwnerId, sortDirection]);
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Backups{" "}
{isFetched && (
<span className="text-sm font-normal text-muted-foreground">
({rows.length})
</span>
)}
</h3>
<div className="flex flex-wrap items-center gap-2">
{isFetched && (
<>
<Select value={destinationId} onValueChange={setDestinationId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Destination" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All destinations</SelectItem>
{destinations.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={serviceOwnerId}
onValueChange={setServiceOwnerId}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Service" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All services</SelectItem>
{services.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() =>
setSortDirection((d) => (d === "desc" ? "asc" : "desc"))
}
>
<ArrowUpDown className="size-4 mr-2" />
{sortDirection === "desc" ? "Newest first" : "Oldest first"}
</Button>
</>
)}
<Button onClick={() => refetch()} disabled={isFetching} size="sm">
{isFetching ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<RefreshCw className="size-4 mr-2" />
)}
Refresh
</Button>
</div>
</div>
{isFetching && !isFetched && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading backups...
</div>
)}
{isFetched && isError && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>Failed to load backups.</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
</Button>
</div>
)}
{isFetched && !isError && rows.length === 0 && (
<div className="flex items-center justify-center py-16 text-muted-foreground">
No backups match the current filters.
</div>
)}
{isFetched && !isError && rows.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Service</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Kind</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const href = detailHref(row);
const name = href ? (
<Link href={href} className="hover:underline">
{row.serviceName}
</Link>
) : (
row.serviceName
);
const canRestoreBackup =
row.kind === "backup" && !!row.serviceOwnerId;
const canRestoreVolumeBackup =
row.kind === "volumeBackup" &&
!!row.serviceOwnerId &&
(row.serviceOwnerType === "application" ||
row.serviceOwnerType === "compose");
return (
<TableRow key={row.deploymentId}>
<TableCell className="whitespace-nowrap text-sm">
{new Date(row.createdAt).toLocaleString()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{renderRowIcon(row)}
{name}
</div>
</TableCell>
<TableCell>{row.destinationName}</TableCell>
<TableCell>
<Badge variant="outline">
{row.kind === "backup" ? "Database" : "Volume"}
</Badge>
</TableCell>
<TableCell>
{row.status && (
<Badge
variant={STATUS_VARIANT[row.status] ?? "secondary"}
>
{row.status}
</Badge>
)}
</TableCell>
<TableCell className="text-right">
{(canRestoreBackup || canRestoreVolumeBackup) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canRestoreBackup && row.serviceOwnerId && (
<RestoreBackup
id={row.serviceOwnerId}
databaseType={row.databaseType as any}
serverId={row.serverId}
backupType={row.backupType ?? "database"}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
Restore
</DropdownMenuItem>
}
/>
)}
{canRestoreVolumeBackup &&
row.serviceOwnerId &&
(row.serviceOwnerType === "application" ||
row.serviceOwnerType === "compose") && (
<RestoreVolumeBackups
id={row.serviceOwnerId}
type={row.serviceOwnerType}
serverId={row.serverId ?? undefined}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
Restore
</DropdownMenuItem>
}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</Card>
);
};
@@ -0,0 +1,377 @@
import type { OverviewDomainSortBy } from "@dokploy/server/services/overview-shared";
import { sortOverviewDomains } from "@dokploy/server/services/overview-shared";
import { ExternalLink, Loader2 } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useSortPreference } from "@/hooks/use-sort-preference";
import { api } from "@/utils/api";
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
const SORT_OPTIONS: { value: OverviewDomainSortBy; label: string }[] = [
{ value: "createdAt-desc", label: "Newest first" },
{ value: "createdAt-asc", label: "Oldest first" },
{ value: "port-asc", label: "Port (low-high)" },
{ value: "port-desc", label: "Port (high-low)" },
];
const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value);
export const ShowOverviewDomains = () => {
const utils = api.useUtils();
const { data: permissions } = api.user.getPermissions.useQuery();
const canToggleDomain = permissions?.domain.create ?? false;
const { data: domains, isLoading } = api.overview.domains.useQuery();
const { data: allProjects } = api.project.all.useQuery();
const { mutateAsync: toggleEnable, isPending: isToggling } =
api.domain.toggleEnable.useMutation();
const [selectedProjectId, setSelectedProjectId] = useState("all");
const [selectedStatus, setSelectedStatus] = useState("all");
const [selectedSsl, setSelectedSsl] = useState("all");
const [selectedPort, setSelectedPort] = useState("all");
const [sortBy, setSort] = useSortPreference<OverviewDomainSortBy>(
"overviewDomainsSort",
"createdAt-desc",
SORT_VALUES,
);
const handleToggleEnable = async (
domain: NonNullable<typeof domains>[number],
) => {
try {
const result = await toggleEnable({ domainId: domain.domainId });
utils.overview.domains.invalidate();
toast.success(result.enabled ? "Domain enabled" : "Domain disabled");
} catch {
toast.error(`Error updating "${domain.host}"`);
}
};
const availablePorts = useMemo(() => {
if (!domains) return [];
const ports = new Set<number>();
for (const domain of domains) {
if (domain.port !== null) ports.add(domain.port);
}
return Array.from(ports).sort((a, b) => a - b);
}, [domains]);
const filteredDomains = useMemo(() => {
if (!domains) return [];
const filtered = domains.filter(
(domain) =>
(selectedProjectId === "all" ||
domain.projectId === selectedProjectId) &&
(selectedStatus === "all" ||
(selectedStatus === "enabled" ? domain.enabled : !domain.enabled)) &&
(selectedSsl === "all" ||
(selectedSsl === "https" ? domain.https : !domain.https)) &&
(selectedPort === "all" || domain.port === Number(selectedPort)),
);
return sortOverviewDomains(filtered, sortBy);
}, [
domains,
selectedProjectId,
selectedStatus,
selectedSsl,
selectedPort,
sortBy,
]);
const [pageSize, setPageSize] = useState(50);
const [pageIndex, setPageIndex] = useState(0);
const pageCount = Math.max(1, Math.ceil(filteredDomains.length / pageSize));
const currentPageIndex = Math.min(pageIndex, pageCount - 1);
const pagedDomains = filteredDomains.slice(
currentPageIndex * pageSize,
currentPageIndex * pageSize + pageSize,
);
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Domains{" "}
<span className="text-sm font-normal text-muted-foreground">
({filteredDomains.length})
</span>
</h3>
<div className="flex flex-wrap items-center gap-2">
<Select
value={selectedProjectId}
onValueChange={setSelectedProjectId}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{allProjects?.map((project) => (
<SelectItem key={project.projectId} value={project.projectId}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
<Select value={selectedSsl} onValueChange={setSelectedSsl}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="SSL" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">HTTP & HTTPS</SelectItem>
<SelectItem value="https">HTTPS only</SelectItem>
<SelectItem value="http">HTTP only</SelectItem>
</SelectContent>
</Select>
{availablePorts.length > 0 && (
<Select value={selectedPort} onValueChange={setSelectedPort}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Port" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All ports</SelectItem>
{availablePorts.map((port) => (
<SelectItem key={port} value={String(port)}>
{port}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select
value={sortBy}
onValueChange={(v) => setSort(v as OverviewDomainSortBy)}
>
<SelectTrigger className="w-[170px]">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading domains...
</div>
)}
{!isLoading && filteredDomains.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>No domains match the current filters.</span>
</div>
)}
{!isLoading && filteredDomains.length > 0 && (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Host</TableHead>
<TableHead>Path</TableHead>
<TableHead>Port</TableHead>
<TableHead>Entrypoint</TableHead>
<TableHead>Protocol</TableHead>
<TableHead>Certificate</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedDomains.map((domain) => {
const href = `/dashboard/project/${domain.projectId}/environment/${domain.environmentId}/services/${domain.serviceOwnerType}/${domain.serviceOwnerId}`;
return (
<TableRow key={domain.domainId}>
<TableCell>
<Link href={href} className="flex flex-col min-w-0">
<span className="font-medium truncate">
{domain.serviceName}
</span>
<span className="text-xs font-normal text-muted-foreground">
{domain.projectName} / {domain.environmentName}
</span>
</Link>
</TableCell>
<TableCell>
<Link
className="flex items-center gap-2 font-medium hover:underline"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
{domain.host}
<ExternalLink className="size-3" />
</Link>
</TableCell>
<TableCell>
<span className="font-mono text-sm">
{domain.path || "/"}
</span>
</TableCell>
<TableCell>
<Badge variant="secondary">{domain.port}</Badge>
</TableCell>
<TableCell>
{domain.customEntrypoint ? (
<span className="font-mono text-sm">
{domain.customEntrypoint}
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>
<Badge variant={domain.https ? "outline" : "secondary"}>
{domain.https ? "HTTPS" : "HTTP"}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{domain.certificateType}
</Badge>
</TableCell>
<TableCell>
<DateTooltip date={domain.createdAt} />
</TableCell>
<TableCell className="text-right">
{canToggleDomain ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-end">
<Switch
checked={domain.enabled}
onCheckedChange={() =>
handleToggleEnable(domain)
}
disabled={isToggling}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{domain.enabled
? "Domain is active. Toggle to disable routing without deleting it."
: "Domain is disabled and not routed. Toggle to enable it again."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge
variant={domain.enabled ? "outline" : "secondary"}
>
{domain.enabled ? "Enabled" : "Disabled"}
</Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredDomains.length}{" "}
{filteredDomains.length === 1 ? "domain" : "domains"} total
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value));
setPageIndex(0);
}}
>
<SelectTrigger className="w-[80px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="whitespace-nowrap">
Page {currentPageIndex + 1} of {pageCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex - 1)}
disabled={currentPageIndex === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex + 1)}
disabled={currentPageIndex + 1 >= pageCount}
>
Next
</Button>
</div>
</div>
</div>
</>
)}
</div>
</Card>
);
};
@@ -0,0 +1,547 @@
import type {
OverviewServiceType,
OverviewSortBy,
} from "@dokploy/server/services/overview-shared";
import { sortOverviewServices } from "@dokploy/server/services/overview-shared";
import {
Ban,
CircuitBoard,
GlobeIcon,
Loader2,
MoreHorizontal,
RefreshCw,
Search,
ServerIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useSortPreference } from "@/hooks/use-sort-preference";
import { api } from "@/utils/api";
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
const TYPE_LABELS = {
application: "Application",
postgres: "PostgreSQL",
mariadb: "MariaDB",
mongo: "MongoDB",
mysql: "MySQL",
redis: "Redis",
compose: "Compose",
libsql: "Libsql",
} satisfies Record<OverviewServiceType, string>;
const STATUS_OPTIONS = ["running", "idle", "done", "error"];
// "done" is the steady live state (green); "running" only holds mid-deploy (yellow) — relabeled to match what users expect.
const STATUS_LABELS: Record<string, string> = {
running: "Deploying",
idle: "Idle",
done: "Running",
error: "Error",
};
const SORT_OPTIONS: { value: OverviewSortBy; label: string }[] = [
{ value: "lastDeploy-desc", label: "Recently deployed" },
{ value: "lastDeploy-asc", label: "Oldest deployed" },
{ value: "createdAt-desc", label: "Newest first" },
{ value: "createdAt-asc", label: "Oldest first" },
{ value: "name-asc", label: "Name (A-Z)" },
{ value: "name-desc", label: "Name (Z-A)" },
{ value: "type-asc", label: "Type (A-Z)" },
{ value: "type-desc", label: "Type (Z-A)" },
];
const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value);
// libsql has no deploy/stop actions, same as environment/[environmentId].tsx.
const idKeyByType: Record<string, string> = {
application: "applicationId",
compose: "composeId",
postgres: "postgresId",
mysql: "mysqlId",
mariadb: "mariadbId",
redis: "redisId",
mongo: "mongoId",
};
export const ShowOverviewServices = () => {
const utils = api.useUtils();
const {
data: services,
isLoading,
refetch,
} = api.overview.services.useQuery();
const { data: allProjects } = api.project.all.useQuery();
const [searchQuery, setSearchQuery] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState("all");
const [selectedType, setSelectedType] = useState("all");
const [selectedStatus, setSelectedStatus] = useState("all");
const [selectedServerId, setSelectedServerId] = useState("all");
const [sortBy, setSort] = useSortPreference<OverviewSortBy>(
"overviewServicesSort",
"lastDeploy-desc",
SORT_VALUES,
);
const applicationActions = {
deploy: api.application.deploy.useMutation(),
stop: api.application.stop.useMutation(),
};
const composeActions = {
deploy: api.compose.deploy.useMutation(),
stop: api.compose.stop.useMutation(),
};
const postgresActions = {
deploy: api.postgres.deploy.useMutation(),
stop: api.postgres.stop.useMutation(),
};
const mysqlActions = {
deploy: api.mysql.deploy.useMutation(),
stop: api.mysql.stop.useMutation(),
};
const mariadbActions = {
deploy: api.mariadb.deploy.useMutation(),
stop: api.mariadb.stop.useMutation(),
};
const redisActions = {
deploy: api.redis.deploy.useMutation(),
stop: api.redis.stop.useMutation(),
};
const mongoActions = {
deploy: api.mongo.deploy.useMutation(),
stop: api.mongo.stop.useMutation(),
};
const actionsByType: Record<
string,
{
deploy: { mutateAsync: (input: any) => Promise<unknown> };
stop: { mutateAsync: (input: any) => Promise<unknown> };
}
> = {
application: applicationActions,
compose: composeActions,
postgres: postgresActions,
mysql: mysqlActions,
mariadb: mariadbActions,
redis: redisActions,
mongo: mongoActions,
};
const handleAction = async (
service: NonNullable<typeof services>[number],
action: "deploy" | "stop",
) => {
const actions = actionsByType[service.type];
const idKey = idKeyByType[service.type];
if (!actions || !idKey) return;
const labels =
action === "deploy"
? {
loading: "Deploying",
success: "queued for deployment",
error: "deploying",
}
: { loading: "Stopping", success: "stopped", error: "stopping" };
toast.promise(
(async () => {
await actions[action].mutateAsync({ [idKey]: service.id });
})(),
{
loading: `${labels.loading} ${service.name}...`,
success: () => {
utils.overview.services.invalidate();
return `${service.name} ${labels.success} successfully`;
},
error: (error) =>
`Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
},
);
};
const availableServers = useMemo(() => {
if (!services) return [];
const map = new Map<string, string>();
for (const service of services) {
if (service.serverId && service.serverName) {
map.set(service.serverId, service.serverName);
}
}
return Array.from(map.entries()).map(([serverId, serverName]) => ({
serverId,
serverName,
}));
}, [services]);
const hasServicesWithoutServer = useMemo(
() => !!services?.some((service) => !service.serverId),
[services],
);
const filteredServices = useMemo(() => {
if (!services) return [];
const filtered = services.filter(
(service) =>
service.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
(selectedProjectId === "all" ||
service.projectId === selectedProjectId) &&
(selectedType === "all" || service.type === selectedType) &&
(selectedStatus === "all" || service.status === selectedStatus) &&
(selectedServerId === "all" ||
(selectedServerId === "dokploy-server" && !service.serverId) ||
service.serverId === selectedServerId),
);
return sortOverviewServices(filtered, sortBy);
}, [
services,
searchQuery,
selectedProjectId,
selectedType,
selectedStatus,
selectedServerId,
sortBy,
]);
// pageIndex is clamped against the filtered count each render instead of reset via an effect.
const [pageSize, setPageSize] = useState(50);
const [pageIndex, setPageIndex] = useState(0);
const pageCount = Math.max(1, Math.ceil(filteredServices.length / pageSize));
const currentPageIndex = Math.min(pageIndex, pageCount - 1);
const pagedServices = filteredServices.slice(
currentPageIndex * pageSize,
currentPageIndex * pageSize + pageSize,
);
const renderIcon = (service: NonNullable<typeof services>[number]) => {
if (service.type in DB_ENGINE_ICONS) {
const Icon =
DB_ENGINE_ICONS[service.type as keyof typeof DB_ENGINE_ICONS];
return <Icon className="h-7 w-7" />;
}
if (service.icon) {
return (
<img
src={service.icon}
alt={service.name}
className="size-7 object-contain rounded-sm"
/>
);
}
return service.type === "compose" ? (
<CircuitBoard className="h-6 w-6" />
) : (
<GlobeIcon className="h-6 w-6" />
);
};
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 justify-between">
<h3 className="text-lg font-medium">
Services{" "}
<span className="text-sm font-normal text-muted-foreground">
({filteredServices.length})
</span>
</h3>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Input
placeholder="Filter services..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-9 w-[200px]"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<Select
value={selectedProjectId}
onValueChange={setSelectedProjectId}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{allProjects?.map((project) => (
<SelectItem key={project.projectId} value={project.projectId}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{Object.entries(TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{STATUS_OPTIONS.map((status) => (
<SelectItem key={status} value={status}>
{STATUS_LABELS[status] ?? status}
</SelectItem>
))}
</SelectContent>
</Select>
{(availableServers.length > 0 || hasServicesWithoutServer) && (
<Select
value={selectedServerId}
onValueChange={setSelectedServerId}
>
<SelectTrigger className="w-[170px]">
<SelectValue placeholder="Server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All servers</SelectItem>
{hasServicesWithoutServer && (
<SelectItem value="dokploy-server">
Dokploy server
</SelectItem>
)}
{availableServers.map((s) => (
<SelectItem key={s.serverId} value={s.serverId}>
{s.serverName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select
value={sortBy}
onValueChange={(v) => setSort(v as OverviewSortBy)}
>
<SelectTrigger className="w-[190px]">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading services...
</div>
)}
{!isLoading && filteredServices.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<span>No services match the current filters.</span>
</div>
)}
{!isLoading && filteredServices.length > 0 && (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Server</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last Deploy</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedServices.map((service) => {
const href = `/dashboard/project/${service.projectId}/environment/${service.environmentId}/services/${service.type}/${service.id}`;
const hasActions = service.type in actionsByType;
return (
<TableRow key={service.id}>
<TableCell>
<Link href={href} className="flex items-center gap-2">
{renderIcon(service)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">
{service.name}
</span>
<span className="text-xs font-normal text-muted-foreground">
{service.projectName} / {service.environmentName}
</span>
</div>
</Link>
</TableCell>
<TableCell>{TYPE_LABELS[service.type]}</TableCell>
<TableCell>
<StatusTooltip status={service.status as any} />
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 text-muted-foreground">
<ServerIcon className="size-3.5" />
<span className="truncate">
{service.serverName ?? "Dokploy server"}
</span>
</div>
</TableCell>
<TableCell>
<DateTooltip date={service.createdAt} />
</TableCell>
<TableCell>
{service.lastDeployAt ? (
<DateTooltip date={service.lastDeployAt} />
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-right">
{hasActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel className="truncate">
{service.name}
</DropdownMenuLabel>
<DialogAction
title="Deploy Service"
description={`Are you sure you want to deploy "${service.name}"?`}
type="default"
onClick={() => handleAction(service, "deploy")}
>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={(e) => e.preventDefault()}
>
<RefreshCw className="size-4" />
Deploy
</DropdownMenuItem>
</DialogAction>
<DialogAction
title="Stop Service"
description={`Are you sure you want to stop "${service.name}"?`}
onClick={() => handleAction(service, "stop")}
>
<DropdownMenuItem
className="flex items-center gap-2 text-orange-500 focus:text-orange-500"
onSelect={(e) => e.preventDefault()}
>
<Ban className="size-4" />
Stop
</DropdownMenuItem>
</DialogAction>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredServices.length}{" "}
{filteredServices.length === 1 ? "service" : "services"} total
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value));
setPageIndex(0);
}}
>
<SelectTrigger className="w-[80px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="whitespace-nowrap">
Page {currentPageIndex + 1} of {pageCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex - 1)}
disabled={currentPageIndex === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPageIndex + 1)}
disabled={currentPageIndex + 1 >= pageCount}
>
Next
</Button>
</div>
</div>
</div>
</>
)}
</div>
</Card>
);
};
@@ -211,6 +211,15 @@ export const LibsqlIcon = ({ className }: Props) => {
);
};
export const DB_ENGINE_ICONS = {
postgres: PostgresqlIcon,
mariadb: MariadbIcon,
mysql: MysqlIcon,
mongo: MongodbIcon,
redis: RedisIcon,
libsql: LibsqlIcon,
} as const;
export const GitlabIcon = ({ className }: Props) => {
return (
<svg
+9
View File
@@ -23,6 +23,7 @@ import {
House,
Key,
KeyRound,
LayoutGrid,
Loader2,
LogIn,
type LucideIcon,
@@ -215,6 +216,14 @@ const MENU: Menu = {
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
},
{
isSingle: true,
title: "Overview",
url: "/dashboard/overview",
icon: LayoutGrid,
// Only enabled for users with access to services
isEnabled: ({ permissions }) => !!permissions?.service.read,
},
{
isSingle: true,
title: "Requests",