mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-31 01:02:16 +00:00
feat: add Overview dashboard (Services, Backups & Domains tabs)
This commit is contained in:
+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",
|
||||
|
||||
Reference in New Issue
Block a user