feat: refine home page and fix libsql in bulk actions

- Home: 4 KPI cards (projects, services, deploys/7d, status list),
  server column with icon in recent deployments, empty state with
  icon, dashboard card frame to match other pages.
- Include libsql in project services count sort.
- Fix bulk actions in environment page: libsql was missing from
  start, stop, move, delete and deploy handlers.
This commit is contained in:
Mauricio Siu
2026-04-17 22:11:04 -06:00
parent e7859395b1
commit 2ba1df1eaa
3 changed files with 237 additions and 98 deletions
@@ -1,27 +1,31 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { Plus, Rocket } from "lucide-react"; import { ArrowRight, Rocket, Server } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react"; import { useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
type DeploymentStatus = "idle" | "running" | "done" | "error"; type DeploymentStatus = "idle" | "running" | "done" | "error";
const statusDotClass: Record<string, string> = { const statusDotClass: Record<string, string> = {
done: "bg-muted-foreground/60", done: "bg-emerald-500",
running: "bg-amber-500", running: "bg-amber-500",
error: "bg-red-500", error: "bg-red-500",
idle: "bg-muted-foreground/30", idle: "bg-muted-foreground/40",
}; };
function getServiceInfo(d: any) { function getServiceInfo(d: any) {
const app = d.application; const app = d.application;
const comp = d.compose; const comp = d.compose;
const serverName: string =
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
if (app?.environment?.project && app.environment) { if (app?.environment?.project && app.environment) {
return { return {
name: app.name as string, name: app.name as string,
environment: app.environment.name as string, environment: app.environment.name as string,
projectName: app.environment.project.name as string, projectName: app.environment.project.name as string,
serverName,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`, href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
}; };
} }
@@ -30,6 +34,7 @@ function getServiceInfo(d: any) {
name: comp.name as string, name: comp.name as string,
environment: comp.environment.name as string, environment: comp.environment.name as string,
projectName: comp.environment.project.name as string, projectName: comp.environment.project.name as string,
serverName,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`, href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
}; };
} }
@@ -46,7 +51,7 @@ function StatCard({
delta?: string; delta?: string;
}) { }) {
return ( return (
<div className="rounded-xl border bg-background p-5 min-h-[120px] flex flex-col justify-between"> <div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
<span className="text-xs uppercase tracking-wider text-muted-foreground"> <span className="text-xs uppercase tracking-wider text-muted-foreground">
{label} {label}
</span> </span>
@@ -60,6 +65,34 @@ function StatCard({
); );
} }
function StatusListCard({
label,
items,
}: {
label: string;
items: { dotClass: string; label: string; count: number }[];
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<ul className="flex flex-col gap-1.5">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-2.5 text-sm">
<span
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
aria-hidden
/>
<span className="font-semibold tabular-nums w-8">{item.count}</span>
<span className="text-muted-foreground">{item.label}</span>
</li>
))}
</ul>
</div>
);
}
export const ShowHome = () => { export const ShowHome = () => {
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: projects } = api.project.all.useQuery(); const { data: projects } = api.project.all.useQuery();
@@ -76,10 +109,12 @@ export const ShowHome = () => {
const firstName = auth?.user?.firstName?.trim(); const firstName = auth?.user?.firstName?.trim();
const { totals, serverCount } = useMemo(() => { const { totals, statusBreakdown } = useMemo(() => {
let applications = 0; let applications = 0;
let compose = 0; let compose = 0;
let databases = 0; let databases = 0;
let environments = 0;
const breakdown = { running: 0, error: 0, idle: 0 };
const dbKeys = [ const dbKeys = [
"postgres", "postgres",
"mysql", "mysql",
@@ -88,24 +123,37 @@ export const ShowHome = () => {
"redis", "redis",
"libsql", "libsql",
] as const; ] as const;
const bump = (status?: string) => {
if (status === "done") breakdown.running++;
else if (status === "error") breakdown.error++;
else breakdown.idle++;
};
for (const p of projects ?? []) { for (const p of projects ?? []) {
for (const env of p.environments ?? []) { for (const env of p.environments ?? []) {
environments++;
applications += env.applications?.length ?? 0; applications += env.applications?.length ?? 0;
compose += env.compose?.length ?? 0; compose += env.compose?.length ?? 0;
for (const a of env.applications ?? []) bump(a.applicationStatus);
for (const c of env.compose ?? []) bump((c as any).composeStatus);
for (const key of dbKeys) { for (const key of dbKeys) {
databases += (env as any)[key]?.length ?? 0; const list = ((env as any)[key] ?? []) as Array<{
applicationStatus?: string;
}>;
databases += list.length;
for (const s of list) bump(s.applicationStatus);
} }
} }
} }
return { return {
totals: { totals: {
projects: projects?.length ?? 0, projects: projects?.length ?? 0,
environments,
applications, applications,
compose, compose,
databases, databases,
services: applications + compose + databases, services: applications + compose + databases,
}, },
serverCount: servers?.length ?? 0, statusBreakdown: breakdown,
}; };
}, [projects, servers]); }, [projects, servers]);
@@ -119,101 +167,160 @@ export const ShowHome = () => {
.slice(0, 10); .slice(0, 10);
}, [deployments]); }, [deployments]);
const deployStats = useMemo(() => {
const now = Date.now();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const lastStart = now - weekMs;
const prevStart = now - 2 * weekMs;
const last: NonNullable<typeof deployments> = [];
const prev: NonNullable<typeof deployments> = [];
for (const d of deployments ?? []) {
const t = new Date(d.createdAt).getTime();
if (t >= lastStart) last.push(d);
else if (t >= prevStart) prev.push(d);
}
const lastCount = last.length;
const prevCount = prev.length;
let delta: string | undefined;
if (prevCount > 0) {
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
} else if (lastCount > 0) {
delta = "no prior data";
} else {
delta = "no activity yet";
}
return { value: String(lastCount), delta };
}, [deployments]);
return ( return (
<div className="w-full flex flex-col gap-6"> <div className="w-full">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between"> <Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="flex flex-col gap-1"> <div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6">
<h1 className="text-3xl font-semibold tracking-tight"> <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
{firstName ? `Welcome back, ${firstName}` : "Welcome back"} <h1 className="text-3xl font-semibold tracking-tight">
</h1> {firstName ? `Welcome back, ${firstName}` : "Welcome back"}
<p className="text-sm text-muted-foreground"> </h1>
{totals.services} services across {serverCount}{" "} <Button asChild variant="secondary" className="w-fit">
{serverCount === 1 ? "server" : "servers"} · {totals.projects}{" "} <Link href="/dashboard/projects">
{totals.projects === 1 ? "project" : "projects"} Go to projects
</p> <ArrowRight className="size-4" />
</div> </Link>
<Button asChild className="w-fit"> </Button>
<Link href="/dashboard/projects"> </div>
<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"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard <StatCard
label="Deploys / 24h" label="Projects"
value={String(recentDeployments.length)} value={String(totals.projects)}
delta="placeholder" delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
/> />
<StatCard label="Avg build" value="—" delta="placeholder" /> <StatCard
<StatCard label="CPU" value="—" delta="placeholder" /> label="Services"
<StatCard label="Memory" value="—" delta="placeholder" /> value={String(totals.services)}
</div> delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
/>
<StatCard
label="Deploys / 7d"
value={deployStats.value}
delta={deployStats.delta}
/>
<StatusListCard
label="Status"
items={[
{
dotClass: "bg-emerald-500",
label: "running",
count: statusBreakdown.running,
},
{
dotClass: "bg-red-500",
label: "errored",
count: statusBreakdown.error,
},
{
dotClass: "bg-muted-foreground/40",
label: "idle",
count: statusBreakdown.idle,
},
]}
/>
</div>
<div className="rounded-xl border bg-background"> <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 justify-between px-5 py-4 border-b">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Rocket className="size-4 text-muted-foreground" /> <Rocket className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Recent deployments</h2> <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="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>You do not have permission to view deployments.</span>
</div>
) : recentDeployments.length === 0 ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>No deployments yet.</span>
</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-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
<Server className="size-3 shrink-0" />
<span className="truncate">{info.serverName}</span>
</span>
<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>
{canReadDeployments && (
<Link
href="/dashboard/deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all →
</Link>
)}
</div> </div>
{!canReadDeployments ? ( </Card>
<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> </div>
); );
}; };
@@ -166,6 +166,7 @@ export const ShowProjects = () => {
return ( return (
total + total +
(env.applications?.length || 0) + (env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) + (env.mariadb?.length || 0) +
(env.mongo?.length || 0) + (env.mongo?.length || 0) +
(env.mysql?.length || 0) + (env.mysql?.length || 0) +
@@ -178,6 +179,7 @@ export const ShowProjects = () => {
return ( return (
total + total +
(env.applications?.length || 0) + (env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) + (env.mariadb?.length || 0) +
(env.mongo?.length || 0) + (env.mongo?.length || 0) +
(env.mysql?.length || 0) + (env.mysql?.length || 0) +
@@ -36,7 +36,7 @@ import { AddTemplate } from "@/components/dashboard/project/add-template";
import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector"; import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
import { DuplicateProject } from "@/components/dashboard/project/duplicate-project"; import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables"; import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
import { ProjectEnvironment } from "@/components/dashboard/home/project-environment"; import { ProjectEnvironment } from "@/components/dashboard/projects/project-environment";
import { import {
LibsqlIcon, LibsqlIcon,
MariadbIcon, MariadbIcon,
@@ -509,6 +509,14 @@ const EnvironmentPage = (
deploy: api.mongo.deploy.useMutation(), deploy: api.mongo.deploy.useMutation(),
}; };
const libsqlActions = {
start: api.libsql.start.useMutation(),
stop: api.libsql.stop.useMutation(),
move: api.libsql.move.useMutation(),
delete: api.libsql.remove.useMutation(),
deploy: api.libsql.deploy.useMutation(),
};
const handleBulkStart = async () => { const handleBulkStart = async () => {
let success = 0; let success = 0;
setIsBulkActionLoading(true); setIsBulkActionLoading(true);
@@ -541,6 +549,9 @@ const EnvironmentPage = (
case "mongo": case "mongo":
await mongoActions.start.mutateAsync({ mongoId: serviceId }); await mongoActions.start.mutateAsync({ mongoId: serviceId });
break; break;
case "libsql":
await libsqlActions.start.mutateAsync({ libsqlId: serviceId });
break;
} }
success++; success++;
} catch { } catch {
@@ -588,6 +599,9 @@ const EnvironmentPage = (
case "mongo": case "mongo":
await mongoActions.stop.mutateAsync({ mongoId: serviceId }); await mongoActions.stop.mutateAsync({ mongoId: serviceId });
break; break;
case "libsql":
await libsqlActions.stop.mutateAsync({ libsqlId: serviceId });
break;
} }
success++; success++;
} catch { } catch {
@@ -664,6 +678,12 @@ const EnvironmentPage = (
targetEnvironmentId: selectedTargetEnvironment, targetEnvironmentId: selectedTargetEnvironment,
}); });
break; break;
case "libsql":
await libsqlActions.move.mutateAsync({
libsqlId: serviceId,
targetEnvironmentId: selectedTargetEnvironment,
});
break;
} }
await utils.environment.one.invalidate({ await utils.environment.one.invalidate({
environmentId, environmentId,
@@ -733,6 +753,11 @@ const EnvironmentPage = (
mongoId: serviceId, mongoId: serviceId,
}); });
break; break;
case "libsql":
await libsqlActions.delete.mutateAsync({
libsqlId: serviceId,
});
break;
} }
await utils.environment.one.invalidate({ await utils.environment.one.invalidate({
environmentId, environmentId,
@@ -799,6 +824,11 @@ const EnvironmentPage = (
mongoId: serviceId, mongoId: serviceId,
}); });
break; break;
case "libsql":
await libsqlActions.deploy.mutateAsync({
libsqlId: serviceId,
});
break;
} }
success++; success++;
} catch (error) { } catch (error) {