Updates
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user