Files
caddy-proxy-manager/app/(dashboard)/OverviewClient.tsx
fuomag9 9c60d11c2c feat: improve UI contrast, dark mode, dialog sizing, color coherence, and add table sorting
- Fix dialog scrollability (flex layout + max-h-[90dvh]) and increase L4 dialog to lg width
- Add styled enable card to L4 dialog matching proxy host pattern
- Unify section colors across proxy host and L4 dialogs (cyan=LB, emerald=DNS, violet=upstream DNS, rose=geo, amber=mTLS)
- Improve light mode contrast: muted-foreground oklch 0.552→0.502, remove opacity modifiers on secondary text
- Improve dark mode: boost muted-foreground to 0.85, increase border opacity 10%→16%, input 15%→20%
- Add bg-card to DataTable wrapper and bg-muted/40 to table headers for surface hierarchy
- Add semantic badge variants (success, warning, info, muted) and StatusChip dark mode fix
- Add server-side sortable columns to Proxy Hosts and L4 Proxy Hosts (name, upstream, status, protocol, listen)
- Add sortKey to DataTable Column type with clickable sort headers (ArrowUp/Down indicators, URL param driven)
- Fix E2E test selectors for shadcn UI (label associations, combobox roles, dropdown menus, mobile drawer)
- Add htmlFor/id to proxy host form fields and aria-labels to select triggers for accessibility
- Add sorting E2E tests for both proxy host pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:17:56 +01:00

173 lines
7.0 KiB
TypeScript

"use client";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { BarChart2, Activity } from "lucide-react";
import { ReactNode } from "react";
type StatCard = {
label: string;
icon: ReactNode;
count: number;
href: string;
};
type RecentEvent = {
summary: string;
created_at: string;
};
type TrafficSummary = {
totalRequests: number;
blockedPercent: number;
} | null;
// Per-position accent colors for stat cards (proxy hosts, certs, access lists, traffic)
const CARD_ACCENTS = [
{ border: "border-l-violet-500", icon: "border-violet-500/30 bg-violet-500/10 text-violet-500", count: "text-violet-600 dark:text-violet-400" },
{ border: "border-l-emerald-500", icon: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500", count: "text-emerald-600 dark:text-emerald-400" },
{ border: "border-l-amber-500", icon: "border-amber-500/30 bg-amber-500/10 text-amber-500", count: "text-amber-600 dark:text-amber-400" },
];
const TRAFFIC_ACCENT = {
border: "border-l-cyan-500",
icon: "border-cyan-500/30 bg-cyan-500/10 text-cyan-500",
count: "text-cyan-600 dark:text-cyan-400",
};
function getEventDotColor(summary: string): string {
const lower = summary.toLowerCase();
if (lower.startsWith("delete") || lower.startsWith("remove")) return "bg-rose-500 shadow-[0_0_6px_rgba(244,63,94,0.5)]";
if (lower.startsWith("create") || lower.startsWith("add")) return "bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.5)]";
return "bg-primary shadow-[0_0_6px_var(--primary)]";
}
function formatRelativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return new Date(iso).toLocaleDateString();
}
export default function OverviewClient({
userName,
stats,
trafficSummary,
recentEvents
}: {
userName: string;
stats: StatCard[];
trafficSummary: TrafficSummary;
recentEvents: RecentEvent[];
}) {
return (
<div className="flex flex-col gap-8">
{/* Welcome header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">
Welcome back, <span className="text-primary">{userName}</span>
</h1>
<p className="text-sm text-muted-foreground mt-1">
Everything you need to orchestrate Caddy proxies, certificates, and secure edge services.
</p>
</div>
{/* Stat grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{stats.map((stat, i) => {
const accent = CARD_ACCENTS[i % CARD_ACCENTS.length];
return (
<Link key={stat.label} href={stat.href} className="block group">
<Card className={`border-l-2 ${accent.border} hover:bg-muted/40 transition-colors`}>
<CardContent className="p-5">
<div className="flex items-start justify-between gap-2">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border ${accent.icon} transition-transform group-hover:scale-110`}>
{stat.icon}
</div>
<span className={`text-3xl font-bold tabular-nums ${accent.count}`}>
{stat.count}
</span>
</div>
<p className="text-sm font-medium text-muted-foreground mt-3">{stat.label}</p>
</CardContent>
</Card>
</Link>
);
})}
{/* Traffic (24h) card */}
<Link href="/analytics" className="block group">
<Card className={`border-l-2 ${TRAFFIC_ACCENT.border} hover:bg-muted/40 transition-colors`}>
<CardContent className="p-5">
<div className="flex items-start justify-between gap-2">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border ${TRAFFIC_ACCENT.icon} transition-transform group-hover:scale-110`}>
<BarChart2 className="h-4 w-4" />
</div>
<span className={`text-3xl font-bold tabular-nums ${TRAFFIC_ACCENT.count}`}>
{trafficSummary ? trafficSummary.totalRequests.toLocaleString() : "—"}
</span>
</div>
<p className="text-sm font-medium text-muted-foreground mt-3">Traffic (24h)</p>
{trafficSummary && trafficSummary.totalRequests > 0 && (
<div className="mt-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-muted-foreground">Blocked</span>
<span className={`text-xs font-semibold tabular-nums ${trafficSummary.blockedPercent > 0 ? "text-rose-500" : "text-muted-foreground"}`}>
{trafficSummary.blockedPercent}%
</span>
</div>
<div className="h-1 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-rose-500 transition-all"
style={{ width: `${Math.min(trafficSummary.blockedPercent, 100)}%` }}
/>
</div>
</div>
)}
</CardContent>
</Card>
</Link>
</div>
{/* Recent Activity */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg border border-primary/30 bg-primary/10 text-primary">
<Activity className="h-3.5 w-3.5" />
</div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Recent Activity</h2>
</div>
<Card>
<CardContent className="p-0">
{recentEvents.length === 0 ? (
<p className="px-5 py-6 text-sm text-muted-foreground">No activity recorded yet.</p>
) : (
<div className="relative">
{/* Vertical timeline line */}
<div className="absolute left-[28px] top-4 bottom-4 w-px bg-border" />
{recentEvents.map((event, index) => (
<div
key={`${event.created_at}-${index}`}
className="relative flex items-start gap-4 px-5 py-3 hover:bg-muted/30 transition-colors"
>
{/* Dot */}
<div className={`relative z-10 mt-1 h-3 w-3 shrink-0 rounded-full ${getEventDotColor(event.summary)}`} />
<span className="flex-1 text-sm leading-snug">{event.summary}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(event.created_at)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}