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.
This commit is contained in:
Mauricio Siu
2026-04-17 21:36:37 -06:00
parent 4277a509b2
commit 6f0ed89ce7
31 changed files with 317 additions and 34 deletions
@@ -0,0 +1,223 @@
import { formatDistanceToNow } from "date-fns";
import { Plus, Rocket } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
type DeploymentStatus = "idle" | "running" | "done" | "error";
const statusDotClass: Record<string, string> = {
done: "bg-muted-foreground/60",
running: "bg-amber-500",
error: "bg-red-500",
idle: "bg-muted-foreground/30",
};
function getServiceInfo(d: any) {
const app = d.application;
const comp = d.compose;
if (app?.environment?.project && app.environment) {
return {
name: app.name as string,
environment: app.environment.name as string,
projectName: app.environment.project.name as string,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
name: comp.name as string,
environment: comp.environment.name as string,
projectName: comp.environment.project.name as string,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
}
function StatCard({
label,
value,
delta,
}: {
label: string;
value: string;
delta?: string;
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[120px] flex flex-col justify-between">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex flex-col gap-1">
<span className="text-3xl font-semibold tracking-tight">{value}</span>
{delta && (
<span className="text-xs text-muted-foreground">
{delta}
</span>
)}
</div>
</div>
);
}
export const ShowHome = () => {
const { data: auth } = api.user.get.useQuery();
const { data: projects } = api.project.all.useQuery();
const { data: servers } = api.server.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const canReadDeployments = !!permissions?.deployment.read;
const { data: deployments } = api.deployment.allCentralized.useQuery(
undefined,
{
enabled: canReadDeployments,
refetchInterval: 10000,
},
);
const firstName = auth?.user?.firstName?.trim();
const { totals, serverCount } = useMemo(() => {
let applications = 0;
let compose = 0;
let databases = 0;
const dbKeys = [
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"libsql",
] as const;
for (const p of projects ?? []) {
for (const env of p.environments ?? []) {
applications += env.applications?.length ?? 0;
compose += env.compose?.length ?? 0;
for (const key of dbKeys) {
databases += (env as any)[key]?.length ?? 0;
}
}
}
return {
totals: {
projects: projects?.length ?? 0,
applications,
compose,
databases,
services: applications + compose + databases,
},
serverCount: servers?.length ?? 0,
};
}, [projects, servers]);
const recentDeployments = useMemo(() => {
if (!deployments) return [];
return [...deployments]
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
.slice(0, 10);
}, [deployments]);
return (
<div className="w-full flex flex-col gap-6">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-3xl font-semibold tracking-tight">
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
</h1>
<p className="text-sm text-muted-foreground">
{totals.services} services across {serverCount}{" "}
{serverCount === 1 ? "server" : "servers"} · {totals.projects}{" "}
{totals.projects === 1 ? "project" : "projects"}
</p>
</div>
<Button asChild className="w-fit">
<Link href="/dashboard/projects">
<Plus className="size-4" />
New project
</Link>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Deploys / 24h"
value={String(recentDeployments.length)}
delta="placeholder"
/>
<StatCard label="Avg build" value="—" delta="placeholder" />
<StatCard label="CPU" value="—" delta="placeholder" />
<StatCard label="Memory" value="—" delta="placeholder" />
</div>
<div className="rounded-xl border bg-background">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex items-center gap-2">
<Rocket className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Recent deployments</h2>
</div>
{canReadDeployments && (
<Link
href="/dashboard/deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all
</Link>
)}
</div>
{!canReadDeployments ? (
<div className="p-10 text-center text-sm text-muted-foreground">
You do not have permission to view deployments.
</div>
) : recentDeployments.length === 0 ? (
<div className="p-10 text-center text-sm text-muted-foreground">
No deployments yet.
</div>
) : (
<ul className="divide-y">
{recentDeployments.map((d) => {
const info = getServiceInfo(d);
if (!info) return null;
const status = (d.status ?? "idle") as DeploymentStatus;
return (
<li key={d.deploymentId}>
<Link
href={info.href}
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
>
<span
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
aria-hidden
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm truncate">
{info.name}
</span>
<span className="text-xs text-muted-foreground truncate">
{info.projectName} · {info.environment}
</span>
</div>
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
{status}
</span>
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
{formatDistanceToNow(new Date(d.createdAt), {
addSuffix: true,
})}
</span>
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
logs
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
);
};
@@ -167,7 +167,7 @@ export const SearchCommand = () => {
<CommandGroup heading={"Application"} hidden={true}>
<CommandItem
onSelect={() => {
router.push("/dashboard/projects");
router.push("/dashboard/home");
setOpen(false);
}}
>
@@ -425,7 +425,7 @@ export const WelcomeSubscription = () => {
onClick={() => {
if (stepper.isLast) {
setIsOpen(false);
push("/dashboard/projects");
push("/dashboard/home");
} else {
stepper.next();
}
+7
View File
@@ -19,6 +19,7 @@ import {
Forward,
GalleryVerticalEnd,
GitBranch,
House,
Key,
KeyRound,
Loader2,
@@ -148,6 +149,12 @@ type Menu = {
// The `isEnabled` function is called to determine if the item should be displayed
const MENU: Menu = {
home: [
{
isSingle: true,
title: "Home",
url: "/dashboard/home",
icon: House,
},
{
isSingle: true,
title: "Projects",
+1 -1
View File
@@ -80,7 +80,7 @@ export const UserNav = () => {
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
router.push("/dashboard/projects");
router.push("/dashboard/home");
}}
>
Projects
@@ -44,7 +44,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
try {
const { data, error } = await authClient.signIn.sso({
email: values.email,
callbackURL: "/dashboard/projects",
callbackURL: "/dashboard/home",
});
if (error) {
toast.error(error.message ?? "Failed to sign in with SSO");
+1 -1
View File
@@ -53,7 +53,7 @@ export default function Custom404({ statusCode, error }: Props) {
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
<Link
href="/dashboard/projects"
href="/dashboard/home"
className={buttonVariants({
variant: "secondary",
className: "flex flex-row gap-2",
+1 -1
View File
@@ -102,7 +102,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
+1 -1
View File
@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
+53
View File
@@ -0,0 +1,53 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowHome } from "@/components/dashboard/home/show-home";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Home = () => {
return <ShowHome />;
};
export default Home;
Home.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
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,
});
await helpers.settings.isCloud.prefetch();
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}
+2 -2
View File
@@ -96,7 +96,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -122,7 +122,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -36,7 +36,7 @@ import { AddTemplate } from "@/components/dashboard/project/add-template";
import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
import { ProjectEnvironment } from "@/components/dashboard/projects/project-environment";
import { ProjectEnvironment } from "@/components/dashboard/home/project-environment";
import {
LibsqlIcon,
MariadbIcon,
@@ -1856,7 +1856,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -490,7 +490,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -492,7 +492,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -343,7 +343,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -372,7 +372,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -376,7 +376,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -353,7 +353,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -360,7 +360,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -363,7 +363,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
+1 -1
View File
@@ -18,7 +18,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
+1 -1
View File
@@ -35,7 +35,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -28,7 +28,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -45,7 +45,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
+1 -1
View File
@@ -46,7 +46,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
+1 -1
View File
@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
+5 -5
View File
@@ -106,7 +106,7 @@ export default function Home({ IS_CLOUD }: Props) {
}
toast.success("Logged in successfully");
router.push("/dashboard/projects");
router.push("/dashboard/home");
} catch {
toast.error("An error occurred while logging in");
} finally {
@@ -133,7 +133,7 @@ export default function Home({ IS_CLOUD }: Props) {
}
toast.success("Logged in successfully");
router.push("/dashboard/projects");
router.push("/dashboard/home");
} catch {
toast.error("An error occurred while verifying 2FA code");
} finally {
@@ -163,7 +163,7 @@ export default function Home({ IS_CLOUD }: Props) {
}
toast.success("Logged in successfully");
router.push("/dashboard/projects");
router.push("/dashboard/home");
} catch {
toast.error("An error occurred while verifying backup code");
} finally {
@@ -408,7 +408,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -437,7 +437,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
+1 -1
View File
@@ -139,7 +139,7 @@ const Invitation = ({
});
toast.success("Account created successfully");
router.push("/dashboard/projects");
router.push("/dashboard/home");
} catch {
toast.error("An error occurred while creating your account");
}
+1 -1
View File
@@ -303,7 +303,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}