Skip to content

Commit 6f0ed89

Browse files
committed
feat: add dashboard home page with overview and recent deployments
Adds a new /dashboard/home landing with welcome header, KPI cards (deploys/24h, build, CPU, memory) and a recent deployments list. Home is now the post-login landing and the destination for permission fallback redirects across the app. Projects remains accessible from the sidebar.
1 parent 4277a50 commit 6f0ed89

31 files changed

Lines changed: 317 additions & 34 deletions

File tree

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { formatDistanceToNow } from "date-fns";
2+
import { Plus, Rocket } from "lucide-react";
3+
import Link from "next/link";
4+
import { useMemo } from "react";
5+
import { Button } from "@/components/ui/button";
6+
import { api } from "@/utils/api";
7+
8+
type DeploymentStatus = "idle" | "running" | "done" | "error";
9+
10+
const statusDotClass: Record<string, string> = {
11+
done: "bg-muted-foreground/60",
12+
running: "bg-amber-500",
13+
error: "bg-red-500",
14+
idle: "bg-muted-foreground/30",
15+
};
16+
17+
function getServiceInfo(d: any) {
18+
const app = d.application;
19+
const comp = d.compose;
20+
if (app?.environment?.project && app.environment) {
21+
return {
22+
name: app.name as string,
23+
environment: app.environment.name as string,
24+
projectName: app.environment.project.name as string,
25+
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
26+
};
27+
}
28+
if (comp?.environment?.project && comp.environment) {
29+
return {
30+
name: comp.name as string,
31+
environment: comp.environment.name as string,
32+
projectName: comp.environment.project.name as string,
33+
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
34+
};
35+
}
36+
return null;
37+
}
38+
39+
function StatCard({
40+
label,
41+
value,
42+
delta,
43+
}: {
44+
label: string;
45+
value: string;
46+
delta?: string;
47+
}) {
48+
return (
49+
<div className="rounded-xl border bg-background p-5 min-h-[120px] flex flex-col justify-between">
50+
<span className="text-xs uppercase tracking-wider text-muted-foreground">
51+
{label}
52+
</span>
53+
<div className="flex flex-col gap-1">
54+
<span className="text-3xl font-semibold tracking-tight">{value}</span>
55+
{delta && (
56+
<span className="text-xs text-muted-foreground">
57+
{delta}
58+
</span>
59+
)}
60+
</div>
61+
</div>
62+
);
63+
}
64+
65+
export const ShowHome = () => {
66+
const { data: auth } = api.user.get.useQuery();
67+
const { data: projects } = api.project.all.useQuery();
68+
const { data: servers } = api.server.all.useQuery();
69+
const { data: permissions } = api.user.getPermissions.useQuery();
70+
const canReadDeployments = !!permissions?.deployment.read;
71+
const { data: deployments } = api.deployment.allCentralized.useQuery(
72+
undefined,
73+
{
74+
enabled: canReadDeployments,
75+
refetchInterval: 10000,
76+
},
77+
);
78+
79+
const firstName = auth?.user?.firstName?.trim();
80+
81+
const { totals, serverCount } = useMemo(() => {
82+
let applications = 0;
83+
let compose = 0;
84+
let databases = 0;
85+
const dbKeys = [
86+
"postgres",
87+
"mysql",
88+
"mariadb",
89+
"mongo",
90+
"redis",
91+
"libsql",
92+
] as const;
93+
for (const p of projects ?? []) {
94+
for (const env of p.environments ?? []) {
95+
applications += env.applications?.length ?? 0;
96+
compose += env.compose?.length ?? 0;
97+
for (const key of dbKeys) {
98+
databases += (env as any)[key]?.length ?? 0;
99+
}
100+
}
101+
}
102+
return {
103+
totals: {
104+
projects: projects?.length ?? 0,
105+
applications,
106+
compose,
107+
databases,
108+
services: applications + compose + databases,
109+
},
110+
serverCount: servers?.length ?? 0,
111+
};
112+
}, [projects, servers]);
113+
114+
const recentDeployments = useMemo(() => {
115+
if (!deployments) return [];
116+
return [...deployments]
117+
.sort(
118+
(a, b) =>
119+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
120+
)
121+
.slice(0, 10);
122+
}, [deployments]);
123+
124+
return (
125+
<div className="w-full flex flex-col gap-6">
126+
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
127+
<div className="flex flex-col gap-1">
128+
<h1 className="text-3xl font-semibold tracking-tight">
129+
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
130+
</h1>
131+
<p className="text-sm text-muted-foreground">
132+
{totals.services} services across {serverCount}{" "}
133+
{serverCount === 1 ? "server" : "servers"} · {totals.projects}{" "}
134+
{totals.projects === 1 ? "project" : "projects"}
135+
</p>
136+
</div>
137+
<Button asChild className="w-fit">
138+
<Link href="/dashboard/projects">
139+
<Plus className="size-4" />
140+
New project
141+
</Link>
142+
</Button>
143+
</div>
144+
145+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
146+
<StatCard
147+
label="Deploys / 24h"
148+
value={String(recentDeployments.length)}
149+
delta="placeholder"
150+
/>
151+
<StatCard label="Avg build" value="—" delta="placeholder" />
152+
<StatCard label="CPU" value="—" delta="placeholder" />
153+
<StatCard label="Memory" value="—" delta="placeholder" />
154+
</div>
155+
156+
<div className="rounded-xl border bg-background">
157+
<div className="flex items-center justify-between px-5 py-4 border-b">
158+
<div className="flex items-center gap-2">
159+
<Rocket className="size-4 text-muted-foreground" />
160+
<h2 className="text-sm font-semibold">Recent deployments</h2>
161+
</div>
162+
{canReadDeployments && (
163+
<Link
164+
href="/dashboard/deployments"
165+
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
166+
>
167+
view all →
168+
</Link>
169+
)}
170+
</div>
171+
{!canReadDeployments ? (
172+
<div className="p-10 text-center text-sm text-muted-foreground">
173+
You do not have permission to view deployments.
174+
</div>
175+
) : recentDeployments.length === 0 ? (
176+
<div className="p-10 text-center text-sm text-muted-foreground">
177+
No deployments yet.
178+
</div>
179+
) : (
180+
<ul className="divide-y">
181+
{recentDeployments.map((d) => {
182+
const info = getServiceInfo(d);
183+
if (!info) return null;
184+
const status = (d.status ?? "idle") as DeploymentStatus;
185+
return (
186+
<li key={d.deploymentId}>
187+
<Link
188+
href={info.href}
189+
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
190+
>
191+
<span
192+
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
193+
aria-hidden
194+
/>
195+
<div className="flex flex-col min-w-0 flex-1">
196+
<span className="text-sm truncate">
197+
{info.name}
198+
</span>
199+
<span className="text-xs text-muted-foreground truncate">
200+
{info.projectName} · {info.environment}
201+
</span>
202+
</div>
203+
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
204+
{status}
205+
</span>
206+
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
207+
{formatDistanceToNow(new Date(d.createdAt), {
208+
addSuffix: true,
209+
})}
210+
</span>
211+
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
212+
logs →
213+
</span>
214+
</Link>
215+
</li>
216+
);
217+
})}
218+
</ul>
219+
)}
220+
</div>
221+
</div>
222+
);
223+
};

apps/dokploy/components/dashboard/search-command.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export const SearchCommand = () => {
167167
<CommandGroup heading={"Application"} hidden={true}>
168168
<CommandItem
169169
onSelect={() => {
170-
router.push("/dashboard/projects");
170+
router.push("/dashboard/home");
171171
setOpen(false);
172172
}}
173173
>

apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-subscription.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ export const WelcomeSubscription = () => {
425425
onClick={() => {
426426
if (stepper.isLast) {
427427
setIsOpen(false);
428-
push("/dashboard/projects");
428+
push("/dashboard/home");
429429
} else {
430430
stepper.next();
431431
}

apps/dokploy/components/layouts/side.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
Forward,
2020
GalleryVerticalEnd,
2121
GitBranch,
22+
House,
2223
Key,
2324
KeyRound,
2425
Loader2,
@@ -148,6 +149,12 @@ type Menu = {
148149
// The `isEnabled` function is called to determine if the item should be displayed
149150
const MENU: Menu = {
150151
home: [
152+
{
153+
isSingle: true,
154+
title: "Home",
155+
url: "/dashboard/home",
156+
icon: House,
157+
},
151158
{
152159
isSingle: true,
153160
title: "Projects",

apps/dokploy/components/layouts/user-nav.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const UserNav = () => {
8080
<DropdownMenuItem
8181
className="cursor-pointer"
8282
onClick={() => {
83-
router.push("/dashboard/projects");
83+
router.push("/dashboard/home");
8484
}}
8585
>
8686
Projects

apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
4444
try {
4545
const { data, error } = await authClient.signIn.sso({
4646
email: values.email,
47-
callbackURL: "/dashboard/projects",
47+
callbackURL: "/dashboard/home",
4848
});
4949
if (error) {
5050
toast.error(error.message ?? "Failed to sign in with SSO");

apps/dokploy/pages/_error.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export default function Custom404({ statusCode, error }: Props) {
5353

5454
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
5555
<Link
56-
href="/dashboard/projects"
56+
href="/dashboard/home"
5757
className={buttonVariants({
5858
variant: "secondary",
5959
className: "flex flex-row gap-2",

apps/dokploy/pages/dashboard/deployments.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
102102
return {
103103
redirect: {
104104
permanent: false,
105-
destination: "/dashboard/projects",
105+
destination: "/dashboard/home",
106106
},
107107
};
108108
}

apps/dokploy/pages/dashboard/docker.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export async function getServerSideProps(
2424
return {
2525
redirect: {
2626
permanent: true,
27-
destination: "/dashboard/projects",
27+
destination: "/dashboard/home",
2828
},
2929
};
3030
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { validateRequest } from "@dokploy/server/lib/auth";
2+
import { createServerSideHelpers } from "@trpc/react-query/server";
3+
import type { GetServerSidePropsContext } from "next";
4+
import type { ReactElement } from "react";
5+
import superjson from "superjson";
6+
import { ShowHome } from "@/components/dashboard/home/show-home";
7+
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
8+
import { appRouter } from "@/server/api/root";
9+
10+
const Home = () => {
11+
return <ShowHome />;
12+
};
13+
14+
export default Home;
15+
16+
Home.getLayout = (page: ReactElement) => {
17+
return <DashboardLayout>{page}</DashboardLayout>;
18+
};
19+
20+
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
21+
const { req, res } = ctx;
22+
const { user, session } = await validateRequest(req);
23+
24+
if (!user) {
25+
return {
26+
redirect: {
27+
permanent: true,
28+
destination: "/",
29+
},
30+
};
31+
}
32+
33+
const helpers = createServerSideHelpers({
34+
router: appRouter,
35+
ctx: {
36+
req: req as any,
37+
res: res as any,
38+
db: null as any,
39+
session: session as any,
40+
user: user as any,
41+
},
42+
transformer: superjson,
43+
});
44+
45+
await helpers.settings.isCloud.prefetch();
46+
await helpers.user.get.prefetch();
47+
48+
return {
49+
props: {
50+
trpcState: helpers.dehydrate(),
51+
},
52+
};
53+
}

0 commit comments

Comments
 (0)