Files
doktainer-upstream/src/app/page.tsx
T
2026-06-02 00:17:50 +07:00

1043 lines
34 KiB
TypeScript

"use client";
import DashboardLayout from "@/components/DashboardLayout";
import TablePagination from "@/components/TablePagination";
import { useTablePagination } from "@/lib/use-table-pagination";
import { useState, useEffect, useCallback, useRef } from "react";
import {
Server,
Container,
Globe,
Shield,
TrendingUp,
TrendingDown,
Activity,
AlertTriangle,
CheckCircle,
Clock,
Zap,
} from "lucide-react";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
} from "recharts";
import {
metrics as metricsApi,
servers as serversApi,
containers as containersApi,
DashboardOverview,
DashboardLiveMetrics,
AuditLog,
} from "@/lib/api";
import type { Server as ServerInfo } from "@/lib/api";
import { useRequireAuth } from "@/lib/auth-state";
const CustomTooltip = ({
active,
payload,
label,
valueSuffix = "",
}: {
active?: boolean;
payload?: { name: string; value: number; color: string }[];
label?: string;
valueSuffix?: string;
}) => {
if (active && payload && payload.length) {
return (
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "10px 14px",
fontSize: 12,
color: "var(--text-secondary)",
}}
>
<p style={{ color: "var(--text-muted)", marginBottom: 4 }}>{label}</p>
{payload.map((p) => (
<p key={p.name} style={{ color: p.color }}>
{p.name}:{" "}
<strong style={{ color: "var(--text-primary)" }}>
{p.value}
{valueSuffix}
</strong>
</p>
))}
</div>
);
}
return null;
};
function formatUpdatedAt(value?: string) {
if (!value) return "Waiting for live data";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Waiting for live data";
const diffSeconds = Math.max(
0,
Math.floor((Date.now() - date.getTime()) / 1000),
);
if (diffSeconds < 10) return "Updated just now";
if (diffSeconds < 60) return `Updated ${diffSeconds}s ago`;
return `Updated ${Math.floor(diffSeconds / 60)}m ago`;
}
function DashboardContent() {
const [overview, setOverview] = useState<DashboardOverview | null>(null);
const [serverData, setServerData] = useState<ServerInfo[]>([]);
const [liveMetrics, setLiveMetrics] = useState<DashboardLiveMetrics | null>(
null,
);
const refreshInFlightRef = useRef(false);
const loadDashboardSnapshot = useCallback(async () => {
const [overviewRes, serversRes, liveRes] = await Promise.all([
metricsApi.overview(),
serversApi.list(),
metricsApi.live(12),
]);
return {
overview: overviewRes.data,
servers: serversRes.data ?? [],
liveMetrics: liveRes.data,
};
}, []);
const refreshDashboardSources = useCallback(
async (servers: ServerInfo[], shouldSyncContainers: boolean) => {
const onlineServers = servers.filter(
(server) => server.status === "ONLINE",
);
await Promise.allSettled(
onlineServers.map((server) => serversApi.refreshMetrics(server.id)),
);
if (onlineServers.length > 0 && shouldSyncContainers) {
await Promise.allSettled(
onlineServers.map((server) =>
containersApi.sync({ serverId: server.id }),
),
);
}
},
[],
);
useEffect(() => {
let cancelled = false;
let pollCount = 0;
const run = async (shouldSyncContainers: boolean) => {
try {
const data = await loadDashboardSnapshot();
if (cancelled) return;
setOverview(data.overview);
setServerData(data.servers);
setLiveMetrics(data.liveMetrics);
if (refreshInFlightRef.current) return;
refreshInFlightRef.current = true;
void (async () => {
try {
await refreshDashboardSources(data.servers, shouldSyncContainers);
if (cancelled) return;
const refreshedData = await loadDashboardSnapshot();
if (cancelled) return;
setOverview(refreshedData.overview);
setServerData(refreshedData.servers);
setLiveMetrics(refreshedData.liveMetrics);
} catch {
// Keep the last successful snapshot while background refresh recovers.
} finally {
refreshInFlightRef.current = false;
}
})();
} catch {
if (!cancelled) {
// Keep the previous successful snapshot when a poll fails.
}
}
};
void run(true);
const interval = setInterval(() => {
pollCount += 1;
void run(pollCount % 2 === 0);
}, 30000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [loadDashboardSnapshot, refreshDashboardSources]);
const stats = [
{
label: "Active Servers",
value: overview
? `${overview.servers.online}/${overview.servers.total}`
: "—",
icon: Server,
color: "#3b82f6",
trend: overview
? `${overview.servers.total - overview.servers.online} offline`
: "",
trendUp: true,
sub: "Online servers",
},
{
label: "Containers",
value: overview ? String(overview.containers.running) : "—",
icon: Container,
color: "#10b981",
trend: overview ? `${overview.containers.total} total` : "",
trendUp: true,
sub: "Running",
},
{
label: "Domains & SSL",
value: overview ? String(overview.domains.total) : "—",
icon: Globe,
color: "#8b5cf6",
trend: overview ? `${overview.ssl.valid} valid certs` : "",
trendUp: true,
sub: "Active domains",
},
{
label: "SSL Certificates",
value: overview ? String(overview.ssl.valid) : "—",
icon: Shield,
color: "#f59e0b",
trend: "Valid",
trendUp: true,
sub: "Active certs",
},
];
const serverPagination = useTablePagination({
items: serverData,
resetKey: serverData.length,
});
return (
<DashboardLayout
title="Dashboard"
subtitle="Infrastructure overview — Live monitoring"
>
<div
className="animate-slide-in"
style={{ display: "flex", flexDirection: "column", gap: 20 }}
>
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.label}
className="card"
style={{
padding: 20,
position: "relative",
overflow: "hidden",
}}
>
{/* Glow orb */}
<div
style={{
position: "absolute",
top: -20,
right: -20,
width: 80,
height: 80,
borderRadius: "50%",
background: stat.color,
opacity: 0.06,
filter: "blur(20px)",
}}
/>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div>
<p
style={{
fontSize: 12,
color: "var(--text-muted)",
marginBottom: 8,
}}
>
{stat.label}
</p>
<p
style={{
fontSize: 28,
fontWeight: 700,
color: "var(--text-primary)",
lineHeight: 1,
}}
>
{stat.value}
</p>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 4,
}}
>
{stat.sub}
</p>
</div>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: `${stat.color}18`,
border: `1px solid ${stat.color}30`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Icon size={18} style={{ color: stat.color }} />
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
marginTop: 12,
}}
>
{stat.trendUp ? (
<TrendingUp size={11} style={{ color: "#10b981" }} />
) : (
<TrendingDown size={11} style={{ color: "#f59e0b" }} />
)}
<span
style={{
fontSize: 11,
color: stat.trendUp ? "#10b981" : "#f59e0b",
}}
>
{stat.trend}
</span>
</div>
</div>
);
})}
</div>
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* CPU/RAM/Net Chart */}
<div className="card lg:col-span-2" style={{ padding: 20 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 16,
}}
>
<div>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
Resource Usage
</h2>
<p style={{ fontSize: 12, color: "var(--text-muted)" }}>
avg. across monitored servers •{" "}
{formatUpdatedAt(liveMetrics?.updatedAt)}
</p>
</div>
<div style={{ display: "flex", gap: 16 }}>
{[
{ label: "CPU", color: "#3b82f6" },
{ label: "RAM", color: "#8b5cf6" },
{ label: "Disk", color: "#10b981" },
].map((l) => (
<div
key={l.label}
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<div
style={{
width: 8,
height: 2,
borderRadius: 1,
background: l.color,
}}
/>
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
{l.label}
</span>
</div>
))}
</div>
</div>
<ResponsiveContainer width="100%" height={180}>
<AreaChart
data={liveMetrics?.resourceUsage ?? []}
margin={{ top: 5, right: 5, bottom: 0, left: -20 }}
>
<defs>
<linearGradient id="cpuGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id="ramGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.2} />
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
</linearGradient>
<linearGradient id="diskGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="rgba(30,42,61,0.8)"
/>
<XAxis
dataKey="time"
tick={{ fill: "#4a5a7a", fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: "#4a5a7a", fontSize: 10 }}
axisLine={false}
tickLine={false}
domain={[0, 100]}
/>
<Tooltip content={<CustomTooltip valueSuffix="%" />} />
<Area
type="monotone"
dataKey="cpu"
name="CPU"
stroke="#3b82f6"
fill="url(#cpuGrad)"
strokeWidth={2}
dot={false}
/>
<Area
type="monotone"
dataKey="ram"
name="RAM"
stroke="#8b5cf6"
fill="url(#ramGrad)"
strokeWidth={2}
dot={false}
/>
<Area
type="monotone"
dataKey="disk"
name="Disk"
stroke="#10b981"
fill="url(#diskGrad)"
strokeWidth={2}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* Container stats bar chart */}
<div className="card" style={{ padding: 20 }}>
<div style={{ marginBottom: 16 }}>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
Containers
</h2>
<p style={{ fontSize: 12, color: "var(--text-muted)" }}>
live status by server
</p>
</div>
<ResponsiveContainer width="100%" height={180}>
<BarChart
data={liveMetrics?.containerStatusByServer ?? []}
margin={{ top: 5, right: 5, bottom: 0, left: -20 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="rgba(30,42,61,0.8)"
/>
<XAxis
dataKey="name"
tick={{ fill: "#4a5a7a", fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: "#4a5a7a", fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<Bar
dataKey="running"
name="Running"
fill="#10b981"
radius={[3, 3, 0, 0]}
/>
<Bar
dataKey="stopped"
name="Stopped"
fill="#ef4444"
radius={[3, 3, 0, 0]}
/>
<Bar
dataKey="other"
name="Other"
fill="#f59e0b"
radius={[3, 3, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Servers table + Activity */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
{/* Servers table */}
<div className="card lg:col-span-3" style={{ overflow: "hidden" }}>
<div
style={{
padding: "16px 20px",
borderBottom: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
Servers
</h2>
<p style={{ fontSize: 12, color: "var(--text-muted)" }}>
{serverData.length} servers registered
</p>
</div>
</div>
<div style={{ overflowX: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>Server</th>
<th>Status</th>
<th>CPU</th>
<th>RAM</th>
<th>Containers</th>
<th>Uptime</th>
</tr>
</thead>
<tbody>
{serverPagination.paginatedItems.map((s) => {
const cpu = s.metrics?.cpuPct ?? 0;
const ram = s.metrics?.ramPct ?? 0;
const statusLow = s.status.toLowerCase();
return (
<tr key={s.id} style={{ cursor: "pointer" }}>
<td>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<div
style={{
width: 28,
height: 28,
borderRadius: 6,
background: "var(--bg-input)",
border: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Server size={12} style={{ color: "#3b82f6" }} />
</div>
<div>
<span
style={{
color: "var(--text-primary)",
fontWeight: 500,
}}
>
{s.name}
</span>
<p
style={{
fontSize: 10,
color: "var(--text-muted)",
margin: 0,
}}
>
{s.ip}
</p>
</div>
</div>
</td>
<td>
<span
className={
statusLow === "online"
? "badge-online"
: statusLow === "warning"
? "badge-warning"
: "badge-offline"
}
style={{
padding: "2px 8px",
borderRadius: 6,
fontSize: 11,
fontWeight: 500,
}}
>
{statusLow === "online" && (
<span
className="animate-pulse-dot"
style={{
display: "inline-block",
width: 5,
height: 5,
borderRadius: "50%",
background: "currentColor",
marginRight: 5,
verticalAlign: "middle",
}}
/>
)}
{s.status}
</span>
</td>
<td>
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 3,
}}
>
<span
style={{
fontSize: 11,
color:
cpu > 70
? "#ef4444"
: "var(--text-secondary)",
}}
>
{cpu.toFixed(0)}%
</span>
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${cpu}%`,
background:
cpu > 70
? "#ef4444"
: cpu > 50
? "#f59e0b"
: "#3b82f6",
}}
/>
</div>
</div>
</td>
<td>
<div>
<div
style={{
fontSize: 11,
color:
ram > 80
? "#ef4444"
: "var(--text-secondary)",
marginBottom: 3,
}}
>
{ram.toFixed(0)}%
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${ram}%`,
background:
ram > 80
? "#ef4444"
: ram > 60
? "#f59e0b"
: "#8b5cf6",
}}
/>
</div>
</div>
</td>
<td>
<span
style={{
color: "var(--text-primary)",
fontWeight: 500,
}}
>
{s.containers ?? 0}
</span>
</td>
<td>
<span
style={{
fontSize: 11,
fontFamily: "JetBrains Mono, monospace",
color: "var(--text-muted)",
}}
>
{s.metrics?.uptimeSec
? (() => {
const sec = Number(s.metrics!.uptimeSec);
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
return d > 0 ? `${d}d ${h}h` : `${h}h`;
})()
: "—"}
</span>
</td>
</tr>
);
})}
{serverData.length === 0 && (
<tr>
<td
colSpan={6}
style={{
textAlign: "center",
padding: "24px",
color: "var(--text-muted)",
fontSize: 13,
}}
>
No servers registered
</td>
</tr>
)}
</tbody>
</table>
</div>
<TablePagination
currentPage={serverPagination.currentPage}
totalPages={serverPagination.totalPages}
totalItems={serverPagination.totalItems}
startItem={serverPagination.startItem}
endItem={serverPagination.endItem}
itemLabel="servers"
onPageChange={serverPagination.setCurrentPage}
/>
{/* <div
style={{
padding: "12px 20px",
borderTop: "1px solid var(--border)",
display: "flex",
justifyContent: "flex-end",
}}
>
<a
href="/servers"
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 12,
color: "#3b82f6",
textDecoration: "none",
}}
>
View all servers <ArrowRight size={12} />
</a>
</div> */}
</div>
{/* Activity log */}
<div className="card lg:col-span-2" style={{ overflow: "hidden" }}>
<div
style={{
padding: "16px 20px",
borderBottom: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
Activity
</h2>
<p style={{ fontSize: 12, color: "var(--text-muted)" }}>
Recent system events
</p>
</div>
<div
className="animate-pulse-dot"
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#10b981",
}}
/>
</div>
<div
style={{ padding: "8px 0", maxHeight: 320, overflowY: "auto" }}
>
{(overview?.recentLogs ?? []).map((a: AuditLog) => {
const isSuccess = a.level === "SUCCESS";
const isError = a.level === "ERROR";
const isWarning = a.level === "WARNING";
return (
<div
key={a.id}
style={{
padding: "12px 20px",
display: "flex",
gap: 12,
borderBottom: "1px solid rgba(30,42,61,0.4)",
}}
>
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: isSuccess
? "rgba(16,185,129,0.1)"
: isError
? "rgba(239,68,68,0.1)"
: isWarning
? "rgba(245,158,11,0.1)"
: "rgba(59,130,246,0.1)",
}}
>
{isSuccess && (
<CheckCircle size={13} style={{ color: "#10b981" }} />
)}
{isError && (
<AlertTriangle size={13} style={{ color: "#ef4444" }} />
)}
{isWarning && (
<AlertTriangle size={13} style={{ color: "#f59e0b" }} />
)}
{!isSuccess && !isError && !isWarning && (
<Activity size={13} style={{ color: "#3b82f6" }} />
)}
</div>
<div style={{ flex: 1 }}>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
lineHeight: 1.4,
}}
>
{a.message}
</p>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 3,
}}
>
<Clock
size={9}
style={{
display: "inline",
marginRight: 4,
verticalAlign: "middle",
}}
/>
{new Date(a.createdAt).toLocaleTimeString()}
{a.server && <> &middot; {a.server.name}</>}
</p>
</div>
</div>
);
})}
{(!overview || overview.recentLogs.length === 0) && (
<div
style={{
padding: "24px 20px",
textAlign: "center",
color: "var(--text-muted)",
fontSize: 13,
}}
>
No recent activity
</div>
)}
</div>
</div>
</div>
{/* Quick actions */}
<div className="card" style={{ padding: 20 }}>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: 14,
}}
>
Quick Actions
</h2>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
{[
{
label: "Deploy Container",
icon: Container,
color: "#10b981",
href: "/containers",
},
{
label: "Add Server",
icon: Server,
color: "#3b82f6",
href: "/servers",
},
{
label: "Setup SSL",
icon: Shield,
color: "#8b5cf6",
href: "/ssl",
},
{
label: "Open Terminal",
icon: Activity,
color: "#06b6d4",
href: "/terminal",
},
{
label: "Add Domain",
icon: Globe,
color: "#f59e0b",
href: "/domains",
},
{
label: "View Logs",
icon: Zap,
color: "#ef4444",
href: "/logs",
},
].map((action) => {
const Icon = action.icon;
return (
<a
key={action.label}
href={action.href}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 14px",
borderRadius: 8,
background: `${action.color}10`,
border: `1px solid ${action.color}25`,
textDecoration: "none",
color: action.color,
fontSize: 13,
fontWeight: 500,
transition: "all 0.2s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background =
`${action.color}20`;
(e.currentTarget as HTMLElement).style.transform =
"translateY(-1px)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background =
`${action.color}10`;
(e.currentTarget as HTMLElement).style.transform =
"translateY(0)";
}}
>
<Icon size={14} />
{action.label}
</a>
);
})}
</div>
</div>
</div>
</DashboardLayout>
);
}
export default function DashboardPage() {
const isAuthenticated = useRequireAuth();
if (!isAuthenticated) {
return null;
}
return <DashboardContent />;
}