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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "Manage" 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "managed" 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); })}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user