mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
feat(ui): add project-level monitoring dashboard
Add a dedicated monitoring page for an environment that lets users select services and inspect free or paid container metrics together, with a Monitoring entry point from the environment view. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
70bbc5c944
commit
37811fbef6
@ -0,0 +1,410 @@
|
||||
import {
|
||||
Activity,
|
||||
ChevronsUpDown,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CompactContainerMonitoring } from "@/components/dashboard/monitoring/free/container/compact-container-monitoring";
|
||||
import { CompactPaidContainerMonitoring } from "@/components/dashboard/monitoring/paid/container/compact-paid-container-monitoring";
|
||||
import {
|
||||
LibsqlIcon,
|
||||
MariadbIcon,
|
||||
MongodbIcon,
|
||||
MysqlIcon,
|
||||
PostgresqlIcon,
|
||||
RedisIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export type MonitoringService = {
|
||||
serverId?: string | null;
|
||||
serverName?: string | null;
|
||||
serverIp?: string | null;
|
||||
metricsConfig?: any;
|
||||
name: string;
|
||||
appName?: string | null;
|
||||
replicas?: number;
|
||||
composeType?: "docker-compose" | "stack";
|
||||
type:
|
||||
| "mariadb"
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose"
|
||||
| "libsql";
|
||||
id: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
services: MonitoringService[];
|
||||
}
|
||||
|
||||
const serviceTypeIcon = (type: MonitoringService["type"]) => {
|
||||
switch (type) {
|
||||
case "postgres":
|
||||
return <PostgresqlIcon className="size-4" />;
|
||||
case "mysql":
|
||||
return <MysqlIcon className="size-4" />;
|
||||
case "mariadb":
|
||||
return <MariadbIcon className="size-4" />;
|
||||
case "mongo":
|
||||
return <MongodbIcon className="size-4" />;
|
||||
case "redis":
|
||||
return <RedisIcon className="size-4" />;
|
||||
case "libsql":
|
||||
return <LibsqlIcon className="size-4" />;
|
||||
default:
|
||||
return <Activity className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const serviceHref = (
|
||||
projectId: string,
|
||||
environmentId: string,
|
||||
service: MonitoringService,
|
||||
) =>
|
||||
`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}?tab=monitoring`;
|
||||
|
||||
const isMonitorable = (service: MonitoringService, isCloud?: boolean) =>
|
||||
(!!service.serverId && !!isCloud) || !service.serverId;
|
||||
|
||||
const getStorageKey = (environmentId: string) =>
|
||||
`project-monitoring-selection:${environmentId}`;
|
||||
|
||||
export const ProjectMonitoring = ({
|
||||
projectId,
|
||||
environmentId,
|
||||
services,
|
||||
}: Props) => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const monitorableServices = useMemo(
|
||||
() => services.filter((service) => isMonitorable(service, isCloud)),
|
||||
[services, isCloud],
|
||||
);
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [showReplicas, setShowReplicas] = useState(false);
|
||||
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(getStorageKey(environmentId));
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
selectedIds?: string[];
|
||||
showReplicas?: boolean;
|
||||
};
|
||||
const validIds = (parsed.selectedIds || []).filter((id) =>
|
||||
monitorableServices.some((service) => service.id === id),
|
||||
);
|
||||
setSelectedIds(
|
||||
validIds.length > 0
|
||||
? validIds
|
||||
: monitorableServices.slice(0, 3).map((service) => service.id),
|
||||
);
|
||||
setShowReplicas(!!parsed.showReplicas);
|
||||
} else {
|
||||
setSelectedIds(
|
||||
monitorableServices.slice(0, 3).map((service) => service.id),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setSelectedIds(
|
||||
monitorableServices.slice(0, 3).map((service) => service.id),
|
||||
);
|
||||
}
|
||||
setHydrated(true);
|
||||
}, [environmentId, monitorableServices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
localStorage.setItem(
|
||||
getStorageKey(environmentId),
|
||||
JSON.stringify({ selectedIds, showReplicas }),
|
||||
);
|
||||
}, [selectedIds, showReplicas, environmentId, hydrated]);
|
||||
|
||||
const selectedServices = useMemo(
|
||||
() =>
|
||||
monitorableServices.filter((service) => selectedIds.includes(service.id)),
|
||||
[monitorableServices, selectedIds],
|
||||
);
|
||||
|
||||
const toggleService = (id: string) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedIds(monitorableServices.map((service) => service.id));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading monitoring...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Activity className="size-5 text-muted-foreground" />
|
||||
Project Monitoring
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
Watch metrics for services in this environment. Select only what you
|
||||
need — charts stay collapsed until you expand them to keep the view
|
||||
light.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-2 rounded-lg border px-3 py-2">
|
||||
<Switch
|
||||
id="show-replicas"
|
||||
checked={showReplicas}
|
||||
onCheckedChange={setShowReplicas}
|
||||
/>
|
||||
<Label htmlFor="show-replicas" className="cursor-pointer">
|
||||
Show replicas
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Popover open={selectorOpen} onOpenChange={setSelectorOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-between min-w-[220px]"
|
||||
>
|
||||
<span>
|
||||
Services ({selectedIds.length}/{monitorableServices.length})
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search services..." />
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<Button variant="ghost" size="sm" onClick={selectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={clearAll}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<CommandEmpty>No services found.</CommandEmpty>
|
||||
<CommandGroup className="max-h-72 overflow-auto">
|
||||
{monitorableServices.map((service) => {
|
||||
const checked = selectedIds.includes(service.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={service.id}
|
||||
value={`${service.name} ${service.type}`}
|
||||
onSelect={() => toggleService(service.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Checkbox checked={checked} />
|
||||
{serviceTypeIcon(service.type)}
|
||||
<span className="truncate flex-1">{service.name}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{service.type}
|
||||
</Badge>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{monitorableServices.length === 0 ? (
|
||||
<AlertBlock type="info">
|
||||
No monitorable services in this environment. Monitoring is available
|
||||
for services running on the Dokploy server
|
||||
{isCloud ? " or on remote servers (cloud)." : "."}
|
||||
</AlertBlock>
|
||||
) : selectedServices.length === 0 ? (
|
||||
<AlertBlock type="info">
|
||||
Select one or more services to start monitoring.
|
||||
</AlertBlock>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{selectedServices.map((service) => (
|
||||
<ServiceMonitoringPanel
|
||||
key={service.id}
|
||||
projectId={projectId}
|
||||
environmentId={environmentId}
|
||||
service={service}
|
||||
showReplicas={showReplicas}
|
||||
isCloud={!!isCloud}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ServiceMonitoringPanel = ({
|
||||
projectId,
|
||||
environmentId,
|
||||
service,
|
||||
showReplicas,
|
||||
isCloud,
|
||||
}: {
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
service: MonitoringService;
|
||||
showReplicas: boolean;
|
||||
isCloud: boolean;
|
||||
}) => {
|
||||
const usePaid = !!(service.serverId && isCloud);
|
||||
const appName = service.appName || "";
|
||||
const appType =
|
||||
service.type === "compose"
|
||||
? service.composeType || "docker-compose"
|
||||
: "application";
|
||||
|
||||
const { data: containers, isPending } =
|
||||
api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
appName,
|
||||
appType: service.type === "compose" ? appType : undefined,
|
||||
serverId: service.serverId || undefined,
|
||||
},
|
||||
{
|
||||
enabled: showReplicas && !!appName && !usePaid,
|
||||
},
|
||||
);
|
||||
|
||||
const paidBaseUrl = service.serverIp
|
||||
? `http://${service.serverIp}:${service.metricsConfig?.server?.port || 4500}`
|
||||
: "";
|
||||
const paidToken = service.metricsConfig?.server?.token || "";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{serviceTypeIcon(service.type)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{service.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{appName}
|
||||
{service.serverName ? ` · ${service.serverName}` : ""}
|
||||
{service.type !== "compose" && (service.replicas ?? 1) > 1
|
||||
? ` · ${service.replicas} replicas`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{service.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={serviceHref(projectId, environmentId, service)}>
|
||||
Open
|
||||
<ExternalLink className="ml-1 size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{!appName ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This service has no app name configured.
|
||||
</p>
|
||||
) : usePaid ? (
|
||||
paidBaseUrl && paidToken ? (
|
||||
<CompactPaidContainerMonitoring
|
||||
appName={appName}
|
||||
baseUrl={paidBaseUrl}
|
||||
token={paidToken}
|
||||
label={service.name}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Monitoring is not configured on this remote server.
|
||||
</p>
|
||||
)
|
||||
) : showReplicas ? (
|
||||
isPending ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading containers...
|
||||
</div>
|
||||
) : containers && containers.length > 0 ? (
|
||||
containers.map((container) => (
|
||||
<CompactContainerMonitoring
|
||||
key={container.containerId}
|
||||
appName={container.name}
|
||||
appType={
|
||||
service.type === "compose" ? appType : "application"
|
||||
}
|
||||
label={`${container.name} (${container.state})`}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No running replica containers found. Showing service-level
|
||||
metrics.
|
||||
</p>
|
||||
<CompactContainerMonitoring
|
||||
appName={appName}
|
||||
appType={
|
||||
service.type === "compose" ? appType : "application"
|
||||
}
|
||||
label={service.name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<CompactContainerMonitoring
|
||||
appName={appName}
|
||||
appType={service.type === "compose" ? appType : "application"}
|
||||
label={service.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -882,6 +882,16 @@ const EnvironmentPage = (
|
||||
</CardHeader>
|
||||
<div className="flex flex-row gap-4 flex-wrap justify-between items-center">
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
{permissions?.monitoring.read && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/monitoring`}
|
||||
>
|
||||
<CircuitBoard className="h-4 w-4" />
|
||||
Monitoring
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<ProjectEnvironment projectId={projectId}>
|
||||
<Button variant="outline">Project Environment</Button>
|
||||
</ProjectEnvironment>
|
||||
|
||||
@ -0,0 +1,173 @@
|
||||
import type { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import type { ReactElement } from "react";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { hasPermission } from "@dokploy/server/services/permission";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import { ArrowLeft, FolderInput } from "lucide-react";
|
||||
import superjson from "superjson";
|
||||
import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
|
||||
import { extractServicesFromEnvironment } from "@/components/dashboard/project/extract-services";
|
||||
import { ProjectMonitoring } from "@/components/dashboard/project/monitoring/project-monitoring";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const ProjectMonitoringPage = (
|
||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||
) => {
|
||||
const { projectId, environmentId } = props;
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
|
||||
const { data: currentEnvironment } = api.environment.one.useQuery({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
const services = extractServicesFromEnvironment(currentEnvironment);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Head>
|
||||
<title>
|
||||
Monitoring | {currentEnvironment?.name} |{" "}
|
||||
{currentEnvironment?.project?.name} | {appName}
|
||||
</title>
|
||||
</Head>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<div className="flex justify-between gap-4 w-full items-center flex-wrap p-6 border-b">
|
||||
<CardHeader className="p-0">
|
||||
<CardTitle className="text-xl flex flex-row gap-2 items-center flex-wrap">
|
||||
<FolderInput className="size-6 text-muted-foreground self-center" />
|
||||
<p className="text-base font-medium max-w-[250px] truncate">
|
||||
{currentEnvironment?.project?.name}
|
||||
</p>
|
||||
<AdvancedEnvironmentSelector
|
||||
projectId={projectId}
|
||||
currentEnvironmentId={environmentId}
|
||||
/>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Aggregated container metrics for this environment
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}`}
|
||||
>
|
||||
<ArrowLeft className="size-4 mr-1" />
|
||||
Back to services
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<ProjectMonitoring
|
||||
projectId={projectId}
|
||||
environmentId={environmentId}
|
||||
services={services}
|
||||
/>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectMonitoringPage;
|
||||
|
||||
ProjectMonitoringPage.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ projectId: string; environmentId: string }>,
|
||||
) {
|
||||
const { params, req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const canView = await hasPermission(
|
||||
{
|
||||
user: { id: user.id },
|
||||
session: { activeOrganizationId: session?.activeOrganizationId || "" },
|
||||
},
|
||||
{ monitoring: ["read"] },
|
||||
);
|
||||
|
||||
if (!canView) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
if (
|
||||
typeof params?.projectId === "string" &&
|
||||
typeof params?.environmentId === "string"
|
||||
) {
|
||||
try {
|
||||
await helpers.project.one.fetch({
|
||||
projectId: params.projectId,
|
||||
});
|
||||
await helpers.environment.one.fetch({
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
projectId: params.projectId,
|
||||
environmentId: params.environmentId,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user