This commit is contained in:
2026-09-13 06:53:33 +01:00
parent 75670e4d5c
commit e7e63bc491
45 changed files with 2117 additions and 22 deletions
@@ -0,0 +1,57 @@
import { twMerge } from "tailwind-merge";
import type { ActivityEvent } from "../(data)/dashboard-mock-data";
import type { LucideIcon } from "lucide-react";
import { Cable, FileDown, LogIn, RefreshCw, UserPlus } from "lucide-react";
type Props = {
event: ActivityEvent;
};
const KIND_META: Record<
ActivityEvent["kind"],
{ Icon: LucideIcon; className: string }
> = {
peer: {
Icon: Cable,
className: "text-secondary dark:text-secondary",
},
client: { Icon: UserPlus, className: "text-primary" },
config: {
Icon: FileDown,
className: "text-link dark:text-link-dark",
},
auth: {
Icon: LogIn,
className: "text-foreground-light/50 dark:text-foreground-dark/50",
},
system: { Icon: RefreshCw, className: "text-warning dark:text-warning" },
};
export default function ActivityRow({ event }: Props) {
const meta = KIND_META[event.kind];
return (
<li className="flex items-center gap-3 px-5 py-2.5">
<span
className={twMerge(
"w-7 h-7 rounded-[5px] flex items-center justify-center shrink-0",
"bg-foreground-light/5 dark:bg-foreground-dark/5",
meta.className,
)}
>
<meta.Icon size={14} />
</span>
<div className="min-w-0 grow">
<p className="text-[13.5px] font-medium text-foreground-light dark:text-foreground-dark truncate">
{event.title}
</p>
<p className="text-[12px] text-foreground-light/45 dark:text-foreground-dark/45 truncate">
{event.detail}
</p>
</div>
<span className="shrink-0 tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40">
{event.time}
</span>
</li>
);
}
@@ -0,0 +1,38 @@
import { FileDown, Plus, RefreshCw } from "lucide-react";
import AdminButton from "@/src/components/general/admin-button";
import AdminCard from "@/src/components/general/admin-card";
import DistributionRow from "./distribution-row";
import { DISTRIBUTION, DISTRIBUTION_TOTAL } from "../(data)/dashboard-mock-data";
export default function AsidePanel() {
return (
<AdminCard tier="secondary" className="p-4 flex flex-col gap-5">
<div className="w-full">
<h2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60 mb-3">
Connection distribution
</h2>
<div className="flex flex-col gap-3">
{DISTRIBUTION.map((d) => (
<DistributionRow
key={d.label}
label={d.label}
value={d.value}
total={DISTRIBUTION_TOTAL}
tone={d.tone}
/>
))}
</div>
</div>
<div className="w-full pt-4 border-t border-slate-200 dark:border-white/10 flex flex-col gap-2">
<h2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60 mb-1">
Quick actions
</h2>
<AdminButton variant="primary" Icon={Plus}>
Add client
</AdminButton>
<AdminButton Icon={FileDown}>Generate config</AdminButton>
<AdminButton Icon={RefreshCw}>Restart service</AdminButton>
</div>
</AdminCard>
);
}
@@ -0,0 +1,56 @@
import { twMerge } from "tailwind-merge";
type Props = {
label: string;
value: number;
total: number;
tone: "success" | "warning" | "error";
};
const BAR_TONE: Record<Props["tone"], string> = {
success: "bg-success",
warning: "bg-warning",
error: "bg-error",
};
const TEXT_TONE: Record<Props["tone"], string> = {
success: "text-success dark:text-success",
warning: "text-warning dark:text-warning",
error: "text-error dark:text-error",
};
export default function DistributionRow({
label,
value,
total,
tone,
}: Props) {
const pct = Math.round((value / total) * 100);
return (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[12.5px] text-foreground-light/60 dark:text-foreground-dark/60">
{label}
</span>
<span
className={twMerge(
"tabular text-[12.5px] font-medium",
TEXT_TONE[tone],
)}
>
{value}
</span>
</div>
<div className="w-full h-[3px] rounded-full bg-slate-200 dark:bg-white/10">
<div
className={twMerge(
"h-full rounded-full",
BAR_TONE[tone],
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import StatusDot from "@/src/components/general/status-dot";
import type { DashboardPeer } from "../(data)/dashboard-mock-data";
type Props = {
peer: DashboardPeer;
};
export default function PeerRow({ peer }: Props) {
return (
<tr className="border-b border-slate-200/60 dark:border-white/5 last:border-b-0 hover:bg-foreground-light/[0.02] dark:hover:bg-foreground-dark/[0.02] transition-colors">
<td className="px-4 py-[9px]">
<div className="flex items-center gap-2.5">
<StatusDot status={peer.status} />
<span className="text-[13.5px] font-medium text-foreground-light dark:text-foreground-dark">
{peer.name}
</span>
</div>
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/60 dark:text-foreground-dark/60 whitespace-nowrap">
{peer.tunnelIp}
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/60 dark:text-foreground-dark/60 whitespace-nowrap">
{peer.endpoint}
</td>
<td className="px-4 py-[9px] text-right whitespace-nowrap">
<span className="tabular text-[13px] font-semibold text-foreground-light dark:text-foreground-dark">
↓ {peer.received}
</span>
<span className="tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40">
{" "}
↑ {peer.sent}
</span>
</td>
<td className="px-4 py-[9px] tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40 text-right whitespace-nowrap">
{peer.lastHandshake}
</td>
</tr>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { twMerge } from "tailwind-merge";
type Props = {
data: number[];
width?: number;
height?: number;
className?: string;
};
export default function Sparkline({
data,
width = 120,
height = 44,
className,
}: Props) {
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const stepX = width / (data.length - 1);
const pad = 3;
const points: [number, number][] = data.map((value, index) => [
index * stepX,
height - pad - ((value - min) / range) * (height - pad * 2),
]);
const linePath = points
.map(
([x, y], index) =>
`${index == 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`,
)
.join(" ");
const areaPath = `${linePath} L${width},${height} L0,${height} Z`;
const last = points[points.length - 1]!;
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={twMerge("overflow-visible", className)}
aria-hidden="true"
>
<path d={areaPath} fill="currentColor" opacity="0.08" />
<path
d={linePath}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx={last[0]}
cy={last[1]}
r="2.5"
fill="currentColor"
/>
</svg>
);
}
+78
View File
@@ -0,0 +1,78 @@
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import AdminCard from "@/src/components/general/admin-card";
import type { DeltaTone } from "../(data)/dashboard-mock-data";
type Props = {
label: string;
value: string;
delta?: string;
deltaTone?: DeltaTone;
subtext?: string;
trailing?: ReactNode;
tier?: "default" | "secondary";
valueClassName?: string;
};
const DELTA_TONE_CLASS: Record<DeltaTone, string> = {
positive: "text-success dark:text-success",
negative: "text-error dark:text-error",
neutral: "text-foreground-light/50 dark:text-foreground-dark/50",
};
export default function StatCard({
label,
value,
delta,
deltaTone = "neutral",
subtext,
trailing,
tier = "secondary",
valueClassName,
}: Props) {
return (
<AdminCard
tier={tier}
className="p-4 flex items-start justify-between gap-4"
>
<div className="flex flex-col items-start gap-1.5 min-w-0">
<span className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/50 dark:text-foreground-dark/50">
{label}
</span>
<span
className={twMerge(
"tabular text-[22px] font-semibold tracking-[-0.02em] leading-none",
"text-foreground-light dark:text-foreground-dark",
valueClassName,
)}
>
{value}
</span>
{delta || subtext ? (
<span
className={twMerge(
"text-[12.5px] font-medium tabular",
delta ? DELTA_TONE_CLASS[deltaTone] : undefined,
)}
>
{delta}
{delta && subtext ? (
<span className="text-foreground-light/40 dark:text-foreground-dark/40">
{" "}
· {subtext}
</span>
) : null}
{!delta && subtext ? (
<span className="text-foreground-light/40 dark:text-foreground-dark/40">
{subtext}
</span>
) : null}
</span>
) : null}
</div>
{trailing ? (
<div className="shrink-0 self-center">{trailing}</div>
) : null}
</AdminCard>
);
}
@@ -0,0 +1,130 @@
import { useMemo } from "react";
import type { TrafficPoint } from "../(data)/dashboard-mock-data";
type Props = {
data: TrafficPoint[];
};
const W = 800;
const H = 240;
const PAD_L = 46;
const PAD_R = 12;
const PAD_T = 12;
const PAD_B = 28;
function formatAxis(value: number) {
return value >= 1 ? `${value}G` : `${Math.round(value * 1000)}M`;
}
export default function TrafficChart({ data }: Props) {
const { max, downPath, downArea, upPath, yTicks, xTicks } = useMemo(() => {
const plotW = W - PAD_L - PAD_R;
const plotH = H - PAD_T - PAD_B;
const max = Math.ceil(
Math.max(...data.flatMap((p) => [p.up, p.down])),
);
const x = (i: number) => PAD_L + (i / (data.length - 1)) * plotW;
const y = (v: number) => PAD_T + (1 - v / max) * plotH;
const toPath = (key: "up" | "down") =>
data
.map(
(p, i) =>
`${i == 0 ? "M" : "L"}${x(i).toFixed(2)},${y(
p[key],
).toFixed(2)}`,
)
.join(" ");
const downPath = toPath("down");
const upPath = toPath("up");
const baseY = (H - PAD_B).toFixed(2);
const downArea = `${downPath} L${x(data.length - 1).toFixed(
2,
)},${baseY} L${PAD_L},${baseY} Z`;
const yTicks = Array.from({ length: 5 }, (_, i) => {
const v = (max / 4) * i;
return { v, label: formatAxis(v), y: y(v) };
});
const xTicks: { label: string; x: number }[] = [];
for (let i = 0; i < data.length; i += 4) {
xTicks.push({ label: data[i]!.label, x: x(i) });
}
return { max, downPath, downArea, upPath, yTicks, xTicks };
}, [data]);
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full h-auto text-secondary"
role="img"
aria-label="Network traffic over the last 24 hours"
>
<defs>
<linearGradient id="traffic-down" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.14" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
<linearGradient id="traffic-up" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#94a3b8" stopOpacity="0.1" />
<stop offset="100%" stopColor="#94a3b8" stopOpacity="0" />
</linearGradient>
</defs>
{yTicks.map((tick, i) => (
<g key={i}>
<line
x1={PAD_L}
y1={tick.y}
x2={W - PAD_R}
y2={tick.y}
stroke="#94a3b8"
strokeOpacity="0.18"
/>
<text
x={PAD_L - 8}
y={tick.y + 3}
textAnchor="end"
className="fill-current text-zinc-500 dark:text-zinc-600 text-[10.5px] tabular"
>
{tick.label}
</text>
</g>
))}
<path d={downArea} fill="url(#traffic-down)" />
<path
d={upPath}
fill="none"
stroke="#94a3b8"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d={downPath}
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
{xTicks.map((tick, i) => (
<text
key={i}
x={tick.x}
y={H - PAD_B + 16}
textAnchor="middle"
className="fill-current text-zinc-500 dark:text-zinc-600 text-[10.5px] tabular"
>
{tick.label}
</text>
))}
</svg>
);
}