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>
This commit is contained in:
fuomag9
2026-03-22 22:17:56 +01:00
parent 65753f6a8d
commit 9c60d11c2c
45 changed files with 1616 additions and 1052 deletions

View File

@@ -1,10 +1,9 @@
"use client";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BarChart2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { BarChart2, Activity } from "lucide-react";
import { ReactNode } from "react";
import { Separator } from "@/components/ui/separator";
type StatCard = {
label: string;
@@ -23,6 +22,36 @@ type TrafficSummary = {
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,
@@ -36,89 +65,108 @@ export default function OverviewClient({
}) {
return (
<div className="flex flex-col gap-8">
{/* Welcome header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">
Welcome back, {userName}
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) => (
<Link key={stat.label} href={stat.href} className="block">
<Card className="hover:bg-muted/50 transition-colors">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{stat.label}
</CardTitle>
<div className="text-muted-foreground">
{stat.icon}
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stat.count}</div>
</CardContent>
</Card>
</Link>
))}
{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">
<Card className="hover:bg-muted/50 transition-colors">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Traffic (24h)
</CardTitle>
<div className="text-muted-foreground">
<BarChart2 className="h-4 w-4" />
<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>
</CardHeader>
<CardContent>
{trafficSummary ? (
<>
<div className="text-3xl font-bold">
{trafficSummary.totalRequests.toLocaleString()}
<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>
{trafficSummary.totalRequests > 0 && (
<p className={`text-xs mt-1 ${trafficSummary.blockedPercent > 0 ? "text-destructive" : "text-muted-foreground"}`}>
{trafficSummary.blockedPercent}% blocked
</p>
)}
</>
) : (
<div className="text-3xl font-bold"></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>
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Recent Activity</CardTitle>
</CardHeader>
<CardContent className="p-0">
{recentEvents.length === 0 ? (
<p className="px-6 pb-6 text-sm text-muted-foreground">No activity recorded yet.</p>
) : (
<div>
{recentEvents.map((event, index) => (
<div key={`${event.created_at}-${index}`}>
{index > 0 && <Separator />}
<div className="flex justify-between items-center gap-4 px-6 py-3">
<span className="text-sm">{event.summary}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(event.created_at).toLocaleString()}
{/* 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>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { Trash2 } from "lucide-react";
import { Shield, Trash2, UserPlus, Plus } from "lucide-react";
import { PageHeader } from "@/components/ui/PageHeader";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -22,6 +22,44 @@ type Props = {
pagination: { total: number; page: number; perPage: number };
};
// Cycling accent colors per card
const ACCENT_COLORS = [
{
border: "border-l-violet-500",
icon: "border-violet-500/30 bg-violet-500/10 text-violet-500",
countBadge: "border-violet-500/30 bg-violet-500/10 text-violet-600 dark:text-violet-400",
avatar: "bg-violet-500/15 text-violet-600 dark:text-violet-400",
},
{
border: "border-l-cyan-500",
icon: "border-cyan-500/30 bg-cyan-500/10 text-cyan-500",
countBadge: "border-cyan-500/30 bg-cyan-500/10 text-cyan-600 dark:text-cyan-400",
avatar: "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400",
},
{
border: "border-l-emerald-500",
icon: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500",
countBadge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
avatar: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
},
{
border: "border-l-amber-500",
icon: "border-amber-500/30 bg-amber-500/10 text-amber-500",
countBadge: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400",
avatar: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
},
{
border: "border-l-rose-500",
icon: "border-rose-500/30 bg-rose-500/10 text-rose-500",
countBadge: "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400",
avatar: "bg-rose-500/15 text-rose-600 dark:text-rose-400",
},
];
function getInitials(username: string): string {
return username.slice(0, 2).toUpperCase();
}
export default function AccessListsClient({ lists, pagination }: Props) {
const router = useRouter();
const pathname = usePathname();
@@ -42,89 +80,124 @@ export default function AccessListsClient({ lists, pagination }: Props) {
/>
<div className="flex flex-col gap-4">
{lists.map((list) => (
<Card key={list.id}>
<CardContent className="flex flex-col gap-4 pt-6">
<form action={(formData) => updateAccessListAction(list.id, formData)} className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Access List</h2>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`name-${list.id}`}>Name</Label>
<Input id={`name-${list.id}`} name="name" defaultValue={list.name} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`desc-${list.id}`}>Description</Label>
<textarea
id={`desc-${list.id}`}
name="description"
defaultValue={list.description ?? ""}
rows={2}
className="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="submit" variant="default">
Save
</Button>
<Button
type="submit"
formAction={deleteAccessListAction.bind(null, list.id)}
variant="outline"
className="text-destructive hover:text-destructive"
>
Delete list
</Button>
</div>
</form>
<Separator />
<div className="flex flex-col gap-2">
<p className="font-semibold">Accounts</p>
{list.entries.length === 0 ? (
<p className="text-sm text-muted-foreground">No credentials configured.</p>
) : (
<div className="flex flex-col gap-2">
{list.entries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2"
>
<div>
<p className="text-sm font-medium">{entry.username}</p>
<p className="text-xs text-muted-foreground">
Created {new Date(entry.created_at).toLocaleDateString()}
</p>
</div>
<form action={deleteAccessEntryAction.bind(null, list.id, entry.id)}>
<Button type="submit" variant="ghost" size="icon" className="h-8 w-8 text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</form>
</div>
))}
{lists.map((list, idx) => {
const accent = ACCENT_COLORS[idx % ACCENT_COLORS.length];
return (
<Card key={list.id} className={`border-l-2 ${accent.border}`}>
<CardContent className="flex flex-col gap-5 pt-5 pb-5 px-5">
{/* Header row */}
<div className="flex items-center gap-3">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border ${accent.icon}`}>
<Shield className="h-4 w-4" />
</div>
)}
</div>
<Separator />
<form
action={(formData) => addAccessEntryAction(list.id, formData)}
className="flex flex-col sm:flex-row gap-2 items-end"
>
<div className="flex flex-col gap-1.5 w-full">
<Label htmlFor={`username-${list.id}`}>Username</Label>
<Input id={`username-${list.id}`} name="username" required />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{list.name}</p>
<p className="text-xs text-muted-foreground">
{list.entries.length} {list.entries.length === 1 ? "account" : "accounts"}
{list.description && ` · ${list.description}`}
</p>
</div>
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-bold tabular-nums ${accent.countBadge}`}>
{list.entries.length}
</span>
</div>
<div className="flex flex-col gap-1.5 w-full">
<Label htmlFor={`password-${list.id}`}>Password</Label>
<Input id={`password-${list.id}`} name="password" type="password" required />
{/* Edit form */}
<form action={(formData) => updateAccessListAction(list.id, formData)} className="flex flex-col gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor={`name-${list.id}`} className="text-xs">Name</Label>
<Input id={`name-${list.id}`} name="name" defaultValue={list.name} className="h-8 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`desc-${list.id}`} className="text-xs">Description</Label>
<Input
id={`desc-${list.id}`}
name="description"
defaultValue={list.description ?? ""}
placeholder="Optional"
className="h-8 text-sm"
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="submit" variant="outline" size="sm" className="h-7 text-xs">
Save
</Button>
<Button
type="submit"
formAction={deleteAccessListAction.bind(null, list.id)}
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-destructive"
>
Delete list
</Button>
</div>
</form>
<Separator />
{/* Accounts list */}
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Accounts</p>
{list.entries.length === 0 ? (
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-3 text-sm text-muted-foreground">
<UserPlus className="h-4 w-4 shrink-0" />
No accounts yet add one below.
</div>
) : (
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
{list.entries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between px-3 py-2 bg-muted/20 hover:bg-muted/40 transition-colors"
>
<div className="flex items-center gap-2.5">
<span className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${accent.avatar}`}>
{getInitials(entry.username)}
</span>
<div>
<p className="text-sm font-medium font-mono leading-tight">{entry.username}</p>
<p className="text-xs text-muted-foreground">
Added {new Date(entry.created_at).toLocaleDateString()}
</p>
</div>
</div>
<form action={deleteAccessEntryAction.bind(null, list.id, entry.id)}>
<Button type="submit" variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</form>
</div>
))}
</div>
)}
</div>
<Button type="submit" className="shrink-0">Add</Button>
</form>
</CardContent>
</Card>
))}
{/* Add account */}
<form
action={(formData) => addAccessEntryAction(list.id, formData)}
className="flex flex-col sm:flex-row gap-2 items-end"
>
<div className="flex flex-col gap-1.5 w-full">
<Label htmlFor={`username-${list.id}`} className="text-xs">Username</Label>
<Input id={`username-${list.id}`} name="username" required placeholder="username" className="h-8 text-sm font-mono" />
</div>
<div className="flex flex-col gap-1.5 w-full">
<Label htmlFor={`password-${list.id}`} className="text-xs">Password</Label>
<Input id={`password-${list.id}`} name="password" type="password" required placeholder="••••••••" className="h-8 text-sm" />
</div>
<Button type="submit" size="sm" className="shrink-0 h-8 gap-1">
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</form>
</CardContent>
</Card>
);
})}
</div>
{pageCount > 1 && (
@@ -142,38 +215,42 @@ export default function AccessListsClient({ lists, pagination }: Props) {
</div>
)}
{/* Create new */}
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Create access list</h2>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg border border-dashed border-muted-foreground/50 text-muted-foreground">
<Plus className="h-3.5 w-3.5" />
</div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">New access list</h2>
</div>
<Card className="border-dashed">
<CardContent className="pt-5 pb-5">
<form action={createAccessListAction} className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="create-name">Name</Label>
<Input id="create-name" name="name" placeholder="Internal users" required />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="create-name" className="text-xs">Name <span className="text-destructive">*</span></Label>
<Input id="create-name" name="name" placeholder="Internal users" required className="h-8 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="create-description" className="text-xs">Description</Label>
<Input id="create-description" name="description" placeholder="Optional description" className="h-8 text-sm" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="create-description">Description</Label>
<textarea
id="create-description"
name="description"
placeholder="Optional description"
rows={2}
className="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="create-users">Seed members</Label>
<Label htmlFor="create-users" className="text-xs">Seed members</Label>
<textarea
id="create-users"
name="users"
rows={3}
placeholder="One per line, username:password"
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none"
placeholder="One per line: username:password"
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
/>
<p className="text-xs text-muted-foreground">One per line, username:password</p>
</div>
<div className="flex justify-end">
<Button type="submit">Create Access List</Button>
<Button type="submit" size="sm">
<Shield className="h-3.5 w-3.5 mr-1.5" />
Create Access List
</Button>
</div>
</form>
</CardContent>

View File

@@ -443,7 +443,7 @@ export default function AnalyticsClient() {
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.25em] text-muted-foreground/60">Traffic Intelligence</p>
<p className="text-xs uppercase tracking-[0.25em] text-muted-foreground">Traffic Intelligence</p>
<h1 className="text-xl font-bold tracking-tight">Analytics</h1>
</div>
<div className="flex flex-row items-center gap-3 flex-wrap">
@@ -731,7 +731,7 @@ export default function AnalyticsClient() {
<TooltipContent>{rule.message}</TooltipContent>
</Tooltip>
) : (
<span className="text-sm text-muted-foreground/40"></span>
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-sm font-semibold">{rule.count.toLocaleString()}</TableCell>

View File

@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageHeader } from "@/components/ui/PageHeader";
import { SearchField } from "@/components/ui/SearchField";
import type { AcmeHost, CaCertificateView, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page";
import { StatusSummaryBar } from "./components/StatusSummaryBar";
import { AcmeTab } from "./components/AcmeTab";
@@ -21,9 +21,7 @@ type Props = {
};
function countExpiry(statuses: (CertExpiryStatus | null)[]) {
let expired = 0;
let expiringSoon = 0;
let healthy = 0;
let expired = 0, expiringSoon = 0, healthy = 0;
for (const s of statuses) {
if (s === "expired") expired++;
else if (s === "expiring_soon") expiringSoon++;
@@ -45,7 +43,6 @@ export default function CertificatesClient({
const [searchCa, setSearchCa] = useState("");
const [statusFilter, setStatusFilter] = useState<string | null>(null);
// Aggregate expiry counts across all cert types
const allStatuses: (CertExpiryStatus | null)[] = [
...acmeHosts.map((h) => h.certExpiryStatus),
...importedCerts.map((c) => c.expiryStatus),
@@ -53,8 +50,7 @@ export default function CertificatesClient({
const { expired, expiringSoon, healthy } = countExpiry(allStatuses);
const search = activeTab === "acme" ? searchAcme : activeTab === "imported" ? searchImported : searchCa;
const setSearch =
activeTab === "acme" ? setSearchAcme : activeTab === "imported" ? setSearchImported : setSearchCa;
const setSearch = activeTab === "acme" ? setSearchAcme : activeTab === "imported" ? setSearchImported : setSearchCa;
function handleTabChange(value: string) {
setActiveTab(value as TabId);
@@ -68,7 +64,7 @@ export default function CertificatesClient({
description="Caddy automatically handles HTTPS certificates via Let's Encrypt. Import custom certificates only when needed."
/>
{/* Status summary bar */}
{/* Status summary filter chips */}
<StatusSummaryBar
expired={expired}
expiringSoon={expiringSoon}
@@ -77,28 +73,44 @@ export default function CertificatesClient({
onFilter={setStatusFilter}
/>
{/* Per-tab search */}
<Input
placeholder={
activeTab === "acme"
? "Search by host name or domain…"
: activeTab === "imported"
? "Search by name or domain…"
: "Search by name…"
}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
aria-label="search"
/>
{/* Tabs */}
{/* Tabs + search row */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList>
<TabsTrigger value="acme">ACME ({acmePagination.total})</TabsTrigger>
<TabsTrigger value="imported">Imported ({importedCerts.length})</TabsTrigger>
<TabsTrigger value="ca">CA / mTLS ({caCertificates.length})</TabsTrigger>
</TabsList>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<TabsList className="w-fit">
<TabsTrigger value="acme" className="gap-1.5">
ACME
<span className="rounded-full bg-muted px-1.5 py-0 text-xs font-bold tabular-nums">
{acmePagination.total}
</span>
</TabsTrigger>
<TabsTrigger value="imported" className="gap-1.5">
Imported
<span className="rounded-full bg-muted px-1.5 py-0 text-xs font-bold tabular-nums">
{importedCerts.length}
</span>
</TabsTrigger>
<TabsTrigger value="ca" className="gap-1.5">
CA / mTLS
<span className="rounded-full bg-muted px-1.5 py-0 text-xs font-bold tabular-nums">
{caCertificates.length}
</span>
</TabsTrigger>
</TabsList>
<SearchField
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={
activeTab === "acme"
? "Search by host or domain…"
: activeTab === "imported"
? "Search by name or domain…"
: "Search by name…"
}
className="sm:max-w-xs"
aria-label="search"
/>
</div>
<TabsContent value="acme" className="mt-4">
<AcmeTab

View File

@@ -1,8 +1,9 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Lock } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { DataTable } from "@/components/ui/DataTable";
import { StatusChip } from "@/components/ui/StatusChip";
import type { AcmeHost } from "../page";
import { RelativeTime } from "./RelativeTime";
@@ -17,20 +18,33 @@ const columns = [
{
id: "name",
label: "Proxy Host",
render: (r: AcmeHost) => <span className="font-semibold">{r.name}</span>,
},
{
id: "domains",
label: "Domains",
render: (r: AcmeHost) => (
<p className="text-sm text-muted-foreground">{r.domains.join(", ")}</p>
<div className="flex items-start gap-3">
<div className={[
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border",
r.enabled
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-500"
: "border-zinc-500/20 bg-zinc-500/10 text-zinc-400",
].join(" ")}>
<Lock className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-sm font-semibold leading-tight">{r.name}</p>
<p className="text-xs text-muted-foreground font-mono mt-0.5">
{r.domains[0]}
{r.domains.length > 1 && (
<span className="ml-1 text-muted-foreground">+{r.domains.length - 1}</span>
)}
</p>
</div>
</div>
),
},
{
id: "issuer",
label: "Issuer",
render: (r: AcmeHost) => (
<p className="text-sm text-muted-foreground">{r.certIssuer ?? "—"}</p>
<span className="text-xs text-muted-foreground font-mono">{r.certIssuer ?? "—"}</span>
),
},
{
@@ -41,25 +55,24 @@ const columns = [
{
id: "status",
label: "Status",
width: 110,
render: (r: AcmeHost) => (
<Badge variant={r.enabled ? "default" : "secondary"}>
{r.enabled ? "Active" : "Disabled"}
</Badge>
<StatusChip status={r.enabled ? "active" : "inactive"} />
),
},
];
function acmeMobileCard(r: AcmeHost) {
return (
<Card className="border">
<CardContent className="p-3 flex flex-col gap-1">
<span className="text-sm font-bold">{r.name}</span>
<p className="text-xs text-muted-foreground">{r.domains.join(", ")}</p>
<div className="flex items-center gap-2 flex-wrap">
<Card className={["border-l-2", r.enabled ? "border-l-emerald-500" : "border-l-zinc-500/30"].join(" ")}>
<CardContent className="p-4 flex flex-col gap-1.5">
<p className="text-sm font-semibold">{r.name}</p>
<p className="text-xs text-muted-foreground font-mono">
{r.domains[0]}{r.domains.length > 1 ? ` +${r.domains.length - 1}` : ""}
</p>
<div className="flex items-center gap-2 flex-wrap mt-1">
<RelativeTime validTo={r.certValidTo} status={r.certExpiryStatus} />
<Badge variant={r.enabled ? "default" : "secondary"}>
{r.enabled ? "Active" : "Disabled"}
</Badge>
<StatusChip status={r.enabled ? "active" : "inactive"} />
</div>
</CardContent>
</Card>
@@ -79,7 +92,6 @@ export function AcmeTab({ acmeHosts, acmePagination, search, statusFilter }: Pro
return true;
});
// When filtering client-side, pass a fake pagination that disables server pagination display
const pagination =
search || statusFilter
? { total: filtered.length, page: 1, perPage: filtered.length || 1 }
@@ -93,6 +105,7 @@ export function AcmeTab({ acmeHosts, acmePagination, search, statusFilter }: Pro
emptyMessage="No ACME certificates match"
pagination={pagination}
mobileCard={acmeMobileCard}
rowClassName={(r) => r.enabled ? "" : "opacity-75"}
/>
);
}

View File

@@ -17,7 +17,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ChevronDown, ChevronUp, MoreVertical, Plus } from "lucide-react";
import { ChevronDown, ChevronUp, KeyRound, MoreVertical, Plus, ShieldCheck } from "lucide-react";
import React, { useState } from "react";
import {
DeleteCaCertDialog,
@@ -38,11 +38,11 @@ function formatRelativeDate(iso: string): string {
const days = Math.floor(diff / 86400000);
if (days < 1) return "today";
if (days === 1) return "yesterday";
if (days < 30) return `${days} days ago`;
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} month${months !== 1 ? "s" : ""} ago`;
if (months < 12) return `${months}mo ago`;
const years = Math.floor(months / 12);
return `${years} year${years !== 1 ? "s" : ""} ago`;
return `${years}y ago`;
}
function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
@@ -52,20 +52,23 @@ function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
const active = ca.issuedCerts.filter((c) => !c.revoked_at);
return (
<div className="p-4 bg-muted/40">
<div className="px-5 py-4 bg-muted/30 border-t">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">
Issued Client Certificates ({active.length} active)
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Issued Client Certificates
<span className="ml-2 inline-flex items-center rounded-full border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0 text-xs font-bold text-emerald-600 dark:text-emerald-400">
{active.length} active
</span>
</span>
<div className="flex gap-2">
{ca.has_private_key && (
<Button size="sm" variant="outline" onClick={() => setIssueCaOpen(true)}>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => setIssueCaOpen(true)}>
Issue Cert
</Button>
)}
{ca.issuedCerts.length > 0 && (
<Button size="sm" variant="outline" onClick={() => setManageOpen(true)}>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => setManageOpen(true)}>
Manage
</Button>
)}
@@ -75,24 +78,24 @@ function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
{active.length === 0 ? (
<p className="text-sm text-muted-foreground">No active client certificates for this CA.</p>
) : (
<>
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
{active.slice(0, 5).map((issued) => {
const expired = new Date(issued.valid_to).getTime() < Date.now();
return (
<div key={issued.id} className="flex items-center justify-between gap-2">
<div key={issued.id} className="flex items-center justify-between gap-2 px-3 py-2 bg-background/60">
<span className="text-sm font-mono">{issued.common_name}</span>
<Badge variant={expired ? "destructive" : "default"}>
<Badge variant={expired ? "destructive" : "success"} className="text-[10px] px-1.5 py-0">
{expired ? "Expired" : "Active"}
</Badge>
</div>
);
})}
{active.length > 5 && (
<p className="text-sm text-muted-foreground">
<div className="px-3 py-2 text-xs text-muted-foreground bg-muted/30">
+{active.length - 5} more click &quot;Manage&quot; to view all
</p>
</div>
)}
</>
</div>
)}
</div>
@@ -157,7 +160,6 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
const [expandedId, setExpandedId] = useState<number | null>(null);
const filtered = caCertificates.filter((ca) => {
// CA certs have no expiry status so if filtering by expiry, hide them
if (statusFilter) return false;
if (search) return ca.name.toLowerCase().includes(search.toLowerCase());
return true;
@@ -167,7 +169,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setDrawerCert(null)}>
<Plus className="h-4 w-4 mr-2" />
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add CA Certificate
</Button>
</div>
@@ -175,7 +177,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
{/* Mobile cards */}
<div className="md:hidden flex flex-col gap-3">
{filtered.length === 0 ? (
<Card className="border">
<Card>
<CardContent className="py-10 text-center">
<p className="text-muted-foreground">
{search || statusFilter ? "No CA certificates match" : "No CA certificates configured."}
@@ -186,18 +188,25 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
filtered.map((ca) => {
const activeCount = ca.issuedCerts.filter((c) => !c.revoked_at).length;
return (
<Card key={ca.id} className="border">
<CardContent className="p-3 flex flex-col gap-1.5">
<Card key={ca.id} className="border-l-2 border-l-violet-500">
<CardContent className="p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-bold">{ca.name}</span>
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-violet-500/30 bg-violet-500/10 text-violet-500">
<ShieldCheck className="h-3.5 w-3.5" />
</div>
<span className="text-sm font-semibold">{ca.name}</span>
</div>
<CaActionsMenu ca={ca} onEdit={() => setDrawerCert(ca)} onDelete={() => setDeleteCert(ca)} />
</div>
<div className="flex flex-wrap gap-1.5">
{ca.has_private_key && (
<Badge variant="outline" className="border-green-600 text-green-600 dark:text-green-400">Key stored</Badge>
<Badge variant="success" className="text-[10px] px-1.5 py-0">
<KeyRound className="h-2.5 w-2.5 mr-0.5" />Key stored
</Badge>
)}
{ca.issuedCerts.length > 0 && (
<Badge variant="outline" className={activeCount > 0 ? "border-green-600 text-green-600 dark:text-green-400" : ""}>
<Badge variant={activeCount > 0 ? "info" : "secondary"} className="text-[10px] px-1.5 py-0">
{activeCount}/{ca.issuedCerts.length} active
</Badge>
)}
@@ -236,38 +245,47 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
const expanded = expandedId === ca.id;
return (
<React.Fragment key={ca.id}>
<TableRow>
<TableRow className={expanded ? "bg-muted/20" : ""}>
<TableCell className="pr-0 w-10">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setExpandedId(expanded ? null : ca.id)}
>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{expanded
? <ChevronUp className="h-4 w-4" />
: <ChevronDown className="h-4 w-4" />}
</Button>
</TableCell>
<TableCell>
<span className="font-semibold">{ca.name}</span>
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-violet-500/30 bg-violet-500/10 text-violet-500">
<ShieldCheck className="h-3.5 w-3.5" />
</div>
<span className="text-sm font-semibold">{ca.name}</span>
</div>
</TableCell>
<TableCell>
{ca.has_private_key ? (
<Badge variant="outline" className="border-green-600 text-green-600 dark:text-green-400">Stored</Badge>
<Badge variant="success" className="text-[10px] px-1.5 py-0">
<KeyRound className="h-2.5 w-2.5 mr-0.5" />Stored
</Badge>
) : (
<p className="text-sm text-muted-foreground"></p>
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{ca.issuedCerts.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
<span className="text-sm text-muted-foreground">None</span>
) : (
<Badge variant="outline" className={activeCount > 0 ? "border-green-600 text-green-600 dark:text-green-400" : ""}>
<Badge variant={activeCount > 0 ? "info" : "secondary"} className="text-[10px] px-1.5 py-0">
{activeCount}/{ca.issuedCerts.length} active
</Badge>
)}
</TableCell>
<TableCell>
<p className="text-sm text-muted-foreground">{formatRelativeDate(ca.created_at)}</p>
<span className="text-sm text-muted-foreground">{formatRelativeDate(ca.created_at)}</span>
</TableCell>
<TableCell className="text-right">
<CaActionsMenu

View File

@@ -161,6 +161,7 @@ export function ImportCertDrawer({ open, cert, onClose }: Props) {
size="icon"
className="absolute right-1 top-1 h-7 w-7"
onClick={() => setShowKey((v) => !v)}
aria-label={showKey ? "Hide private key" : "Show private key"}
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>

View File

@@ -16,7 +16,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { AlertTriangle, MoreVertical, Plus } from "lucide-react";
import { AlertTriangle, FileKey, MoreVertical, Plus } from "lucide-react";
import { useState, useTransition } from "react";
import { deleteCertificateAction } from "../actions";
import type { ImportedCertView, ManagedCertView } from "../page";
@@ -36,12 +36,12 @@ function DomainsCell({ domains }: { domains: string[] }) {
return (
<div className="flex flex-wrap gap-1">
{visible.map((d) => (
<Badge key={d} variant="outline" className="text-xs">{d}</Badge>
<Badge key={d} variant="info" className="text-[10px] px-1.5 py-0 font-mono">{d}</Badge>
))}
{rest.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="text-xs cursor-default">+{rest.length} more</Badge>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 cursor-default">+{rest.length}</Badge>
</TooltipTrigger>
<TooltipContent>{rest.join(", ")}</TooltipContent>
</Tooltip>
@@ -100,14 +100,24 @@ function ActionsMenu({ cert, onEdit }: { cert: ImportedCertView; onEdit: () => v
function importedMobileCard(c: ImportedCertView, onEdit: () => void) {
return (
<Card className="border">
<CardContent className="p-3 flex flex-col gap-1">
<Card className={[
"border-l-2",
c.expiryStatus === "expired" ? "border-l-rose-500"
: c.expiryStatus === "expiring_soon" ? "border-l-amber-500"
: "border-l-emerald-500",
].join(" ")}>
<CardContent className="p-4 flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-sm font-bold">{c.name}</span>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-emerald-500/30 bg-emerald-500/10 text-emerald-500">
<FileKey className="h-3.5 w-3.5" />
</div>
<span className="text-sm font-semibold">{c.name}</span>
</div>
<ActionsMenu cert={c} onEdit={onEdit} />
</div>
<p className="text-xs text-muted-foreground">
{c.domains.slice(0, 2).join(", ")}{c.domains.length > 2 ? ` +${c.domains.length - 2} more` : ""}
<p className="text-xs text-muted-foreground font-mono">
{c.domains.slice(0, 2).join(", ")}{c.domains.length > 2 ? ` +${c.domains.length - 2}` : ""}
</p>
<RelativeTime validTo={c.validTo} status={c.expiryStatus} />
</CardContent>
@@ -135,7 +145,21 @@ export function ImportedTab({ importedCerts, managedCerts, search, statusFilter
{
id: "name",
label: "Name",
render: (c: ImportedCertView) => <span className="font-semibold">{c.name}</span>,
render: (c: ImportedCertView) => (
<div className="flex items-start gap-3">
<div className={[
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border",
c.expiryStatus === "expired"
? "border-rose-500/30 bg-rose-500/10 text-rose-500"
: c.expiryStatus === "expiring_soon"
? "border-amber-500/30 bg-amber-500/10 text-amber-500"
: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500",
].join(" ")}>
<FileKey className="h-3.5 w-3.5" />
</div>
<span className="text-sm font-semibold">{c.name}</span>
</div>
),
},
{
id: "domains",
@@ -152,11 +176,11 @@ export function ImportedTab({ importedCerts, managedCerts, search, statusFilter
label: "Used by",
render: (c: ImportedCertView) =>
c.usedBy.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
<span className="text-sm text-muted-foreground"></span>
) : (
<div className="flex flex-wrap gap-1">
{c.usedBy.map((h) => (
<Badge key={h.id} variant="outline" className="text-xs">{h.name}</Badge>
<Badge key={h.id} variant="secondary" className="text-[10px] px-1.5 py-0">{h.name}</Badge>
))}
</div>
),
@@ -173,10 +197,9 @@ export function ImportedTab({ importedCerts, managedCerts, search, statusFilter
return (
<div className="flex flex-col gap-4">
{/* Add button */}
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setDrawerCert(null)}>
<Plus className="h-4 w-4 mr-2" />
<Plus className="h-3.5 w-3.5 mr-1.5" />
Import Certificate
</Button>
</div>
@@ -187,12 +210,16 @@ export function ImportedTab({ importedCerts, managedCerts, search, statusFilter
keyField="id"
emptyMessage="No imported certificates match"
mobileCard={mobileCardRenderer}
rowClassName={(c) =>
c.expiryStatus === "expired" ? "opacity-70"
: c.expiryStatus === "expiring_soon" ? "bg-amber-500/5"
: ""
}
/>
{/* Legacy managed certs */}
{managedCerts.length > 0 && (
<div className="flex flex-col gap-2">
<Alert variant="destructive" className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200">
<Alert className="border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-400">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Legacy &quot;managed&quot; certificate entries detected. These are redundant Caddy handles
@@ -227,7 +254,7 @@ function LegacyManagedTable({ managedCerts }: { managedCerts: ManagedCertView[]
id: "domains",
label: "Domains",
render: (c: ManagedCertView) => (
<p className="text-sm text-muted-foreground">{c.domain_names.join(", ")}</p>
<p className="text-sm text-muted-foreground font-mono">{c.domain_names.join(", ")}</p>
),
},
{
@@ -238,7 +265,7 @@ function LegacyManagedTable({ managedCerts }: { managedCerts: ManagedCertView[]
<Button
size="sm"
variant="outline"
className="border-destructive text-destructive hover:bg-destructive/10"
className="h-7 text-xs border-destructive/50 text-destructive hover:bg-destructive/10"
disabled={isPending}
onClick={() => startTransition(async () => { await deleteCertificateAction(c.id); })}
>

View File

@@ -1,6 +1,7 @@
"use client";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertCircle, CheckCircle2, Clock } from "lucide-react";
import type { CertExpiryStatus } from "../page";
function formatRelative(validTo: string): string {
@@ -10,11 +11,11 @@ function formatRelative(validTo: string): string {
const hours = Math.floor(absDiff / 3600000);
if (diff < 0) {
if (days >= 1) return `EXPIRED ${days} day${days !== 1 ? "s" : ""} ago`;
return `EXPIRED ${hours} hour${hours !== 1 ? "s" : ""} ago`;
if (days >= 1) return `Expired ${days}d ago`;
return `Expired ${hours}h ago`;
}
if (days >= 1) return `in ${days} day${days !== 1 ? "s" : ""}`;
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
if (days >= 1) return `${days}d`;
return `${hours}h`;
}
function formatFull(validTo: string): string {
@@ -33,24 +34,32 @@ export function RelativeTime({
status: CertExpiryStatus | null;
}) {
if (validTo === null || status === null) {
return (
<p className="text-sm text-muted-foreground"></p>
);
return <span className="text-sm text-muted-foreground"></span>;
}
const colorClass =
const config =
status === "expired"
? "text-destructive"
? {
icon: <AlertCircle className="h-3.5 w-3.5" />,
cls: "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400",
}
: status === "expiring_soon"
? "text-yellow-600 dark:text-yellow-400"
: "text-green-600 dark:text-green-400";
? {
icon: <Clock className="h-3.5 w-3.5" />,
cls: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400",
}
: {
icon: <CheckCircle2 className="h-3.5 w-3.5" />,
cls: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
};
return (
<Tooltip>
<TooltipTrigger asChild>
<p className={`text-sm font-medium cursor-default ${colorClass}`}>
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold cursor-default ${config.cls}`}>
{config.icon}
{formatRelative(validTo)}
</p>
</span>
</TooltipTrigger>
<TooltipContent>{formatFull(validTo)}</TooltipContent>
</Tooltip>

View File

@@ -1,6 +1,6 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { AlertCircle, CheckCircle2, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
type Props = {
@@ -11,6 +11,33 @@ type Props = {
onFilter: (f: string | null) => void;
};
type StatChipProps = {
icon: React.ReactNode;
count: number;
label: string;
active: boolean;
onClick: () => void;
base: string;
activeStyle: string;
};
function StatChip({ icon, count, label, active, onClick, base, activeStyle }: StatChipProps) {
return (
<button
onClick={onClick}
aria-pressed={active}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-all cursor-pointer select-none",
active ? activeStyle : base,
)}
>
<span className="flex h-5 w-5 items-center justify-center">{icon}</span>
<span className="text-lg font-bold tabular-nums leading-none">{count}</span>
<span className="text-xs leading-none opacity-80">{label}</span>
</button>
);
}
export function StatusSummaryBar({ expired, expiringSoon, healthy, filter, onFilter }: Props) {
function toggle(key: string) {
onFilter(filter === key ? null : key);
@@ -18,43 +45,33 @@ export function StatusSummaryBar({ expired, expiringSoon, healthy, filter, onFil
return (
<div className="flex flex-wrap gap-2">
<button onClick={() => toggle("expired")} aria-pressed={filter === "expired"}>
<Badge
variant={filter === "expired" ? "destructive" : "outline"}
className={cn(
"cursor-pointer",
filter !== "expired" && "border-destructive text-destructive hover:bg-destructive/10"
)}
>
{expired} expired
</Badge>
</button>
<button onClick={() => toggle("expiring_soon")} aria-pressed={filter === "expiring_soon"}>
<Badge
variant="outline"
className={cn(
"cursor-pointer",
filter === "expiring_soon"
? "bg-yellow-500 text-white border-yellow-500 hover:bg-yellow-600"
: "border-yellow-500 text-yellow-600 dark:text-yellow-400 hover:bg-yellow-50 dark:hover:bg-yellow-900/20"
)}
>
{expiringSoon} expiring soon
</Badge>
</button>
<button onClick={() => toggle("ok")} aria-pressed={filter === "ok"}>
<Badge
variant="outline"
className={cn(
"cursor-pointer",
filter === "ok"
? "bg-green-600 text-white border-green-600 hover:bg-green-700"
: "border-green-600 text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20"
)}
>
{healthy} healthy
</Badge>
</button>
<StatChip
icon={<AlertCircle className="h-4 w-4" />}
count={expired}
label="Expired"
active={filter === "expired"}
onClick={() => toggle("expired")}
base="border-rose-500/30 bg-rose-500/5 text-rose-600 dark:text-rose-400 hover:bg-rose-500/15"
activeStyle="border-rose-500 bg-rose-500 text-white shadow-[0_0_12px_rgba(244,63,94,0.3)]"
/>
<StatChip
icon={<Clock className="h-4 w-4" />}
count={expiringSoon}
label="Expiring soon"
active={filter === "expiring_soon"}
onClick={() => toggle("expiring_soon")}
base="border-amber-500/30 bg-amber-500/5 text-amber-600 dark:text-amber-400 hover:bg-amber-500/15"
activeStyle="border-amber-500 bg-amber-500 text-white shadow-[0_0_12px_rgba(245,158,11,0.3)]"
/>
<StatChip
icon={<CheckCircle2 className="h-4 w-4" />}
count={healthy}
label="Healthy"
active={filter === "ok"}
onClick={() => toggle("ok")}
base="border-emerald-500/30 bg-emerald-500/5 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
activeStyle="border-emerald-500 bg-emerald-500 text-white shadow-[0_0_12px_rgba(16,185,129,0.3)]"
/>
</div>
);
}

View File

@@ -2,12 +2,13 @@
import { useEffect, useRef, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { MoreHorizontal } from "lucide-react";
import { MoreHorizontal, Network, ArrowRight } from "lucide-react";
import type { L4ProxyHost } from "@/src/lib/models/l4-proxy-hosts";
import { toggleL4ProxyHostAction } from "./actions";
import { PageHeader } from "@/components/ui/PageHeader";
import { SearchField } from "@/components/ui/SearchField";
import { DataTable } from "@/components/ui/DataTable";
import { StatusChip } from "@/components/ui/StatusChip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
@@ -26,6 +27,7 @@ type Props = {
hosts: L4ProxyHost[];
pagination: { total: number; page: number; perPage: number };
initialSearch: string;
initialSort?: { sortBy: string; sortDir: "asc" | "desc" };
};
function formatMatcher(host: L4ProxyHost): string {
@@ -37,7 +39,14 @@ function formatMatcher(host: L4ProxyHost): string {
}
}
export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }: Props) {
function ProtocolBadge({ protocol }: { protocol: string }) {
if (protocol === "tcp") {
return <Badge variant="info">{protocol.toUpperCase()}</Badge>;
}
return <Badge variant="warning">{protocol.toUpperCase()}</Badge>;
}
export default function L4ProxyHostsClient({ hosts, pagination, initialSearch, initialSort }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [duplicateHost, setDuplicateHost] = useState<L4ProxyHost | null>(null);
const [editHost, setEditHost] = useState<L4ProxyHost | null>(null);
@@ -73,48 +82,64 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }:
const columns = [
{
id: "name",
label: "Name",
label: "Name / Matcher",
sortKey: "name",
render: (host: L4ProxyHost) => (
<div>
<p className="text-sm font-medium">{host.name}</p>
<p className="text-xs text-muted-foreground">{formatMatcher(host)}</p>
<div className="flex items-start gap-3">
<div className={[
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border",
host.protocol === "tcp"
? "border-cyan-500/30 bg-cyan-500/10 text-cyan-500"
: "border-amber-500/30 bg-amber-500/10 text-amber-500",
].join(" ")}>
<Network className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-sm font-semibold leading-tight">{host.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">{formatMatcher(host)}</p>
</div>
</div>
),
},
{
id: "protocol",
label: "Protocol",
sortKey: "protocol",
width: 90,
render: (host: L4ProxyHost) => (
<Badge variant={host.protocol === "tcp" ? "default" : "secondary"}>
{host.protocol.toUpperCase()}
</Badge>
),
render: (host: L4ProxyHost) => <ProtocolBadge protocol={host.protocol} />,
},
{
id: "listen",
label: "Listen",
sortKey: "listen_address",
render: (host: L4ProxyHost) => (
<span className="text-sm text-muted-foreground font-mono">{host.listen_address}</span>
<span className="text-sm font-mono font-medium tabular-nums text-foreground/80">
{host.listen_address}
</span>
),
},
{
id: "upstreams",
label: "Upstreams",
render: (host: L4ProxyHost) => (
<span className="text-sm text-muted-foreground font-mono">
{host.upstreams[0]}{host.upstreams.length > 1 && ` +${host.upstreams.length - 1} more`}
</span>
<div className="flex items-center gap-1.5">
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="text-sm font-mono font-medium text-foreground/80">
{host.upstreams[0]}
{host.upstreams.length > 1 && (
<span className="ml-1 text-muted-foreground">+{host.upstreams.length - 1}</span>
)}
</span>
</div>
),
},
{
id: "status",
label: "Status",
width: 100,
sortKey: "enabled",
width: 110,
render: (host: L4ProxyHost) => (
<Badge variant={host.enabled ? "default" : "secondary"}>
{host.enabled ? "Active" : "Paused"}
</Badge>
<StatusChip status={host.enabled ? "active" : "inactive"} />
),
},
{
@@ -153,22 +178,23 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }:
];
const mobileCard = (host: L4ProxyHost) => (
<Card>
<Card className={[
"border-l-2",
host.protocol === "tcp" ? "border-l-cyan-500" : "border-l-amber-500",
].join(" ")}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{host.name}</p>
<Badge variant={host.protocol === "tcp" ? "default" : "secondary"}>
{host.protocol.toUpperCase()}
</Badge>
<p className="text-sm font-semibold truncate">{host.name}</p>
<ProtocolBadge protocol={host.protocol} />
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{host.listen_address} {host.upstreams[0]}{host.upstreams.length > 1 ? ` +${host.upstreams.length - 1}` : ""}
{host.listen_address}
<span className="mx-1 text-muted-foreground"></span>
{host.upstreams[0]}{host.upstreams.length > 1 ? ` +${host.upstreams.length - 1}` : ""}
</p>
<Badge variant={host.enabled ? "default" : "secondary"} className="w-fit mt-1">
{host.enabled ? "Active" : "Paused"}
</Badge>
<StatusChip status={host.enabled ? "active" : "inactive"} className="w-fit mt-1" />
</div>
<div className="flex items-center gap-1 shrink-0">
<Switch
@@ -179,6 +205,7 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }:
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -218,7 +245,9 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }:
keyField="id"
emptyMessage={searchTerm ? "No L4 hosts match your search" : "No L4 proxy hosts found"}
pagination={pagination}
sort={initialSort}
mobileCard={mobileCard}
rowClassName={(host) => host.enabled ? "" : "opacity-75"}
/>
<CreateL4HostDialog

View File

@@ -5,18 +5,20 @@ import { requireAdmin } from "@/src/lib/auth";
const PER_PAGE = 25;
interface PageProps {
searchParams: Promise<{ page?: string; search?: string }>;
searchParams: Promise<{ page?: string; search?: string; sortBy?: string; sortDir?: string }>;
}
export default async function L4ProxyHostsPage({ searchParams }: PageProps) {
await requireAdmin();
const { page: pageParam, search: searchParam } = await searchParams;
const { page: pageParam, search: searchParam, sortBy: sortByParam, sortDir: sortDirParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const search = searchParam?.trim() || undefined;
const offset = (page - 1) * PER_PAGE;
const sortBy = sortByParam || undefined;
const sortDir = (sortDirParam === "asc" || sortDirParam === "desc") ? sortDirParam : "desc";
const [hosts, total] = await Promise.all([
listL4ProxyHostsPaginated(PER_PAGE, offset, search),
listL4ProxyHostsPaginated(PER_PAGE, offset, search, sortBy, sortDir),
countL4ProxyHosts(search),
]);
@@ -25,6 +27,7 @@ export default async function L4ProxyHostsPage({ searchParams }: PageProps) {
hosts={hosts}
pagination={{ total, page, perPage: PER_PAGE }}
initialSearch={search ?? ""}
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }}
/>
);
}

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { MoreHorizontal } from "lucide-react";
import { Globe, MoreHorizontal, ArrowRight, Shield } from "lucide-react";
import type { AccessList } from "@/lib/models/access-lists";
import type { Certificate } from "@/lib/models/certificates";
import type { ProxyHost } from "@/lib/models/proxy-hosts";
@@ -12,6 +12,7 @@ import { toggleProxyHostAction } from "./actions";
import { PageHeader } from "@/components/ui/PageHeader";
import { SearchField } from "@/components/ui/SearchField";
import { DataTable } from "@/components/ui/DataTable";
import { StatusChip } from "@/components/ui/StatusChip";
import { CreateHostDialog, EditHostDialog, DeleteHostDialog } from "@/components/proxy-hosts/HostDialogs";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -33,9 +34,10 @@ type Props = {
authentikDefaults: AuthentikSettings | null;
pagination: { total: number; page: number; perPage: number };
initialSearch: string;
initialSort?: { sortBy: string; sortDir: "asc" | "desc" };
};
export default function ProxyHostsClient({ hosts, certificates, accessLists, caCertificates, authentikDefaults, pagination, initialSearch }: Props) {
export default function ProxyHostsClient({ hosts, certificates, accessLists, caCertificates, authentikDefaults, pagination, initialSearch, initialSort }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [duplicateHost, setDuplicateHost] = useState<ProxyHost | null>(null);
const [editHost, setEditHost] = useState<ProxyHost | null>(null);
@@ -73,33 +75,72 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
const columns = [
{
id: "name",
label: "Name",
label: "Name / Domain",
sortKey: "name",
render: (host: ProxyHost) => (
<div>
<p className="text-sm font-medium">{host.name}</p>
<p className="text-xs text-muted-foreground font-mono">
{host.domains[0]}{host.domains.length > 1 && ` +${host.domains.length - 1}`}
</p>
<div className="flex items-start gap-3">
<div className={[
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border",
host.enabled
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-500"
: "border-zinc-500/20 bg-zinc-500/10 text-zinc-400"
].join(" ")}>
<Globe className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-sm font-semibold leading-tight">{host.name}</p>
<p className="text-xs text-muted-foreground font-mono mt-0.5">
{host.domains[0]}
{host.domains.length > 1 && (
<span className="ml-1 text-muted-foreground">+{host.domains.length - 1}</span>
)}
</p>
</div>
</div>
),
},
{
id: "target",
label: "Target",
label: "Upstream",
sortKey: "upstreams",
render: (host: ProxyHost) => (
<p className="text-sm text-muted-foreground font-mono">
{host.upstreams[0]}{host.upstreams.length > 1 && ` +${host.upstreams.length - 1} more`}
</p>
<div className="flex items-center gap-1.5">
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="text-sm font-mono font-medium text-foreground/80">
{host.upstreams[0]}
{host.upstreams.length > 1 && (
<span className="ml-1 text-muted-foreground">+{host.upstreams.length - 1}</span>
)}
</span>
</div>
),
},
{
id: "features",
label: "Features",
render: (host: ProxyHost) => (
<div className="flex flex-wrap gap-1">
{host.certificate_id && (
<Badge variant="info" className="text-[10px] px-1.5 py-0">TLS</Badge>
)}
{host.access_list_id && (
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
<Shield className="h-2.5 w-2.5 mr-0.5" />Auth
</Badge>
)}
{!host.certificate_id && !host.access_list_id && (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
),
},
{
id: "status",
label: "Status",
width: 100,
sortKey: "enabled",
width: 110,
render: (host: ProxyHost) => (
<Badge variant={host.enabled ? "default" : "secondary"}>
{host.enabled ? "Active" : "Paused"}
</Badge>
<StatusChip status={host.enabled ? "active" : "inactive"} />
),
},
{
@@ -138,17 +179,23 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
];
const mobileCard = (host: ProxyHost) => (
<Card>
<Card className={[
"border-l-2",
host.enabled ? "border-l-emerald-500" : "border-l-zinc-500/30",
].join(" ")}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1 min-w-0">
<p className="text-sm font-medium truncate">{host.name}</p>
<p className="text-sm font-semibold truncate">{host.name}</p>
<p className="text-xs text-muted-foreground font-mono truncate">
{host.domains[0]}{host.domains.length > 1 ? ` +${host.domains.length - 1}` : ""} {host.upstreams[0]}
{host.domains[0]}{host.domains.length > 1 ? ` +${host.domains.length - 1}` : ""}
<span className="mx-1 text-muted-foreground"></span>
{host.upstreams[0]}
</p>
<Badge variant={host.enabled ? "default" : "secondary"} className="w-fit mt-1">
{host.enabled ? "Active" : "Paused"}
</Badge>
<div className="flex items-center gap-1.5 mt-1">
<StatusChip status={host.enabled ? "active" : "inactive"} />
{host.certificate_id && <Badge variant="info" className="text-[10px] px-1.5 py-0">TLS</Badge>}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Switch
@@ -159,6 +206,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -196,7 +244,9 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
keyField="id"
emptyMessage={searchTerm ? "No hosts match your search" : "No proxy hosts found"}
pagination={pagination}
sort={initialSort}
mobileCard={mobileCard}
rowClassName={(host) => host.enabled ? "" : "opacity-75"}
/>
<CreateHostDialog

View File

@@ -9,18 +9,20 @@ import { requireAdmin } from "@/src/lib/auth";
const PER_PAGE = 25;
interface PageProps {
searchParams: Promise<{ page?: string; search?: string }>;
searchParams: Promise<{ page?: string; search?: string; sortBy?: string; sortDir?: string }>;
}
export default async function ProxyHostsPage({ searchParams }: PageProps) {
await requireAdmin();
const { page: pageParam, search: searchParam } = await searchParams;
const { page: pageParam, search: searchParam, sortBy: sortByParam, sortDir: sortDirParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const search = searchParam?.trim() || undefined;
const offset = (page - 1) * PER_PAGE;
const sortBy = sortByParam || undefined;
const sortDir = (sortDirParam === "asc" || sortDirParam === "desc") ? sortDirParam : "desc";
const [hosts, total, certificates, caCertificates, accessLists, authentikDefaults] = await Promise.all([
listProxyHostsPaginated(PER_PAGE, offset, search),
listProxyHostsPaginated(PER_PAGE, offset, search, sortBy, sortDir),
countProxyHosts(search),
listCertificates(),
listCaCertificates(),
@@ -37,6 +39,7 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
authentikDefaults={authentikDefaults}
pagination={{ total, page, perPage: PER_PAGE }}
initialSearch={search ?? ""}
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -348,7 +348,7 @@ function GlobalSuppressedRules({
{pendingRule && (
<div className="mt-3 px-4 py-3 rounded-lg border border-yellow-500 bg-muted max-w-[480px]">
<p className="text-sm font-mono font-bold text-red-400">Rule {pendingRule.id}</p>
<p className={cn("text-xs block mt-0.5", pendingRule.message ? "text-muted-foreground" : "text-muted-foreground/60")}>
<p className={cn("text-xs block mt-0.5", pendingRule.message ? "text-muted-foreground" : "text-muted-foreground")}>
{pendingRule.message ?? "No description available — rule has not triggered yet"}
</p>
<div className="flex items-center gap-2 mt-3">
@@ -393,7 +393,7 @@ function GlobalSuppressedRules({
>
<div className="flex-1 min-w-0">
<p className="text-sm font-mono font-bold text-red-400">Rule {id}</p>
<p className={cn("text-xs block mt-0.5", messages[id] ? "text-muted-foreground" : "text-muted-foreground/60")}>
<p className={cn("text-xs block mt-0.5", messages[id] ? "text-muted-foreground" : "text-muted-foreground")}>
{messages[id] ?? "No description available — rule has not triggered yet"}
</p>
</div>
@@ -473,7 +473,7 @@ export default function WafEventsClient({ events, pagination, initialSearch, glo
</div>
<p className="text-xs font-mono text-muted-foreground break-all">{event.host || "—"}</p>
{event.ruleId && (
<span className="text-xs text-muted-foreground/60">Rule #{event.ruleId}</span>
<span className="text-xs text-muted-foreground">Rule #{event.ruleId}</span>
)}
</CardContent>
</Card>

View File

@@ -16,7 +16,7 @@
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--muted-foreground: oklch(0.502 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
@@ -29,22 +29,22 @@
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card: oklch(0.205 0.008 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover: oklch(0.205 0.008 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.606 0.25 292.717);
--primary-foreground: oklch(0.969 0.016 293.756);
--secondary: oklch(0.274 0.006 286.033);
--secondary: oklch(0.28 0.008 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--muted: oklch(0.28 0.008 286.033);
--muted-foreground: oklch(0.85 0.010 286.067);
--accent: oklch(0.28 0.008 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--border: oklch(1 0 0 / 16%);
--input: oklch(1 0 0 / 20%);
--ring: oklch(0.38 0.189 293.745);
}