380 lines
16 KiB
TypeScript
380 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { usePoll } from "@/lib/use-poll";
|
|
import { StatCard } from "@/components/stat-card";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { StatusBadge } from "@/components/status-badge";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { DataTable, Column } from "@/components/data-table";
|
|
import type { CloudflareStats, AuthentikStats, GiteaActivity } from "@/lib/types";
|
|
import { CardSkeleton } from "@/components/skeleton";
|
|
import { Copyable } from "@/components/copyable";
|
|
|
|
interface AdGuardStats {
|
|
total_queries?: number;
|
|
num_dns_queries?: number;
|
|
blocked?: number;
|
|
num_blocked_filtering?: number;
|
|
avg_time?: number;
|
|
avg_processing_time?: number;
|
|
}
|
|
|
|
interface HeadscaleNode {
|
|
id: string;
|
|
name: string;
|
|
ip_addresses?: string[];
|
|
ip?: string;
|
|
online: boolean;
|
|
last_seen?: string;
|
|
}
|
|
|
|
interface DnsRewrite {
|
|
domain: string;
|
|
answer: string;
|
|
}
|
|
|
|
const nodeColors: Record<string, string> = {
|
|
atlantis: "text-blue-400",
|
|
calypso: "text-violet-400",
|
|
olares: "text-emerald-400",
|
|
nuc: "text-amber-400",
|
|
rpi5: "text-cyan-400",
|
|
"homelab-vm": "text-green-400",
|
|
"matrix-ubuntu": "text-pink-400",
|
|
guava: "text-orange-400",
|
|
seattle: "text-teal-400",
|
|
jellyfish: "text-indigo-400",
|
|
};
|
|
|
|
function getNodeColor(name: string): string {
|
|
const lower = name.toLowerCase();
|
|
for (const [key, cls] of Object.entries(nodeColors)) {
|
|
if (lower.includes(key)) return cls;
|
|
}
|
|
return "text-foreground";
|
|
}
|
|
|
|
function formatTime(ts: string): string {
|
|
try {
|
|
return new Date(ts).toLocaleString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
} catch {
|
|
return ts;
|
|
}
|
|
}
|
|
|
|
const dnsTypeColors: Record<string, string> = {
|
|
A: "text-blue-400",
|
|
AAAA: "text-violet-400",
|
|
CNAME: "text-cyan-400",
|
|
MX: "text-amber-400",
|
|
TXT: "text-green-400",
|
|
SRV: "text-pink-400",
|
|
NS: "text-teal-400",
|
|
};
|
|
|
|
export default function NetworkPage() {
|
|
const { data: adguard } = usePoll<AdGuardStats>("/api/network/adguard", 60000);
|
|
const { data: nodesRaw } = usePoll<HeadscaleNode[] | { nodes: HeadscaleNode[] }>("/api/network/headscale", 30000);
|
|
const { data: rewritesRaw } = usePoll<DnsRewrite[] | { rewrites: DnsRewrite[] }>("/api/network/adguard/rewrites", 120000);
|
|
const { data: cloudflare } = usePoll<CloudflareStats>("/api/network/cloudflare", 120000);
|
|
const { data: authentik } = usePoll<AuthentikStats & { users?: Array<{ username: string; last_login: string; active: boolean }> }>("/api/network/authentik", 60000);
|
|
const { data: gitea } = usePoll<GiteaActivity>("/api/network/gitea", 60000);
|
|
|
|
const nodes = Array.isArray(nodesRaw) ? nodesRaw : (nodesRaw?.nodes ?? []);
|
|
const rewrites = Array.isArray(rewritesRaw) ? rewritesRaw : (rewritesRaw?.rewrites ?? []);
|
|
|
|
const rewriteColumns: Column<DnsRewrite>[] = [
|
|
{
|
|
key: "domain",
|
|
label: "Domain",
|
|
render: (row) => <span className="font-medium text-cyan-400">{row.domain}</span>,
|
|
},
|
|
{
|
|
key: "answer",
|
|
label: "Answer",
|
|
render: (row) => <Copyable text={row.answer} className="text-amber-400 font-mono" />,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<h1 className="text-lg font-semibold">Network</h1>
|
|
|
|
{/* Top row: AdGuard stats */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-3 gap-5">
|
|
<StatCard
|
|
label="Total Queries"
|
|
value={(() => { const v = adguard?.total_queries ?? adguard?.num_dns_queries; return v != null ? v.toLocaleString() : "--"; })()}
|
|
sub="DNS queries"
|
|
/>
|
|
<StatCard
|
|
label="Blocked"
|
|
value={(() => { const v = adguard?.blocked ?? adguard?.num_blocked_filtering; return v != null ? v.toLocaleString() : "--"; })()}
|
|
sub="blocked by filters"
|
|
/>
|
|
<StatCard
|
|
label="Avg Response"
|
|
value={(() => { const v = adguard?.avg_time ?? adguard?.avg_processing_time; return v != null ? `${(v * 1000).toFixed(1)}ms` : "--"; })()}
|
|
sub="processing time"
|
|
/>
|
|
</div>
|
|
|
|
{/* Middle: Headscale nodes grid */}
|
|
<Card>
|
|
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
|
<CardTitle className="text-base font-semibold text-cyan-400">Headscale Nodes</CardTitle>
|
|
{nodes.length > 0 && (
|
|
<Badge variant="secondary" className="text-xs bg-cyan-500/10 border border-cyan-500/20 text-cyan-400">
|
|
{nodes.filter(n => n.online).length}/{nodes.length} online
|
|
</Badge>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{nodes.length === 0 ? (
|
|
(nodesRaw as Record<string,unknown>)?.error ? <p className="text-sm text-red-400">{String((nodesRaw as Record<string,unknown>).error)}</p> : <CardSkeleton lines={4} />
|
|
) : (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
|
{nodes.map((node) => (
|
|
<Card key={node.id} className="overflow-hidden">
|
|
<CardContent className="pt-3 pb-3 px-4">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className={`w-2 h-2 rounded-full shrink-0 ${node.online ? "bg-green-500 glow-green" : "bg-red-500 glow-red"}`} />
|
|
<span className={`text-sm font-medium truncate ${getNodeColor(node.name)}`}>{node.name}</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground/70 font-mono">
|
|
<Copyable text={node.ip_addresses?.[0] ?? node.ip ?? "--"} />
|
|
</p>
|
|
{node.last_seen && (
|
|
<p className="text-xs text-muted-foreground/60 mt-0.5">
|
|
{new Date(node.last_seen).toLocaleString("en-US", {
|
|
month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
|
|
})}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Cloudflare DNS — full width */}
|
|
<Card>
|
|
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
|
<CardTitle className="text-base font-semibold text-orange-400">Cloudflare DNS</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
{cloudflare && (
|
|
<>
|
|
<Badge variant="secondary" className="text-xs bg-orange-500/10 border border-orange-500/20 text-orange-400">
|
|
{cloudflare.proxied} proxied
|
|
</Badge>
|
|
<Badge variant="secondary" className="text-xs bg-white/[0.06] border border-white/[0.08] text-muted-foreground">
|
|
{cloudflare.dns_only} DNS only
|
|
</Badge>
|
|
<Badge variant="secondary" className="text-xs bg-white/[0.04] border border-white/[0.06]">
|
|
{cloudflare.total} total
|
|
</Badge>
|
|
</>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!cloudflare ? (
|
|
<CardSkeleton lines={6} />
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* Type badges */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{Object.entries(cloudflare.types).map(([type, count]) => (
|
|
<Badge
|
|
key={type}
|
|
variant="secondary"
|
|
className={`text-xs bg-white/[0.04] border border-white/[0.08] ${dnsTypeColors[type] ?? "text-foreground"}`}
|
|
>
|
|
{type}: {count}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
{/* Records table */}
|
|
{(cloudflare as CloudflareStats & { records?: { name: string; type: string; content: string; proxied: boolean; ttl: number }[] }).records && (
|
|
<div className="rounded-lg border border-white/[0.06] overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-white/[0.04] text-xs text-muted-foreground uppercase tracking-wider">
|
|
<th className="text-left px-3 py-2 font-medium">Name</th>
|
|
<th className="text-left px-3 py-2 font-medium w-16">Type</th>
|
|
<th className="text-left px-3 py-2 font-medium">Content</th>
|
|
<th className="text-center px-3 py-2 font-medium w-20">Proxy</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{((cloudflare as CloudflareStats & { records?: { name: string; type: string; content: string; proxied: boolean; ttl: number }[] }).records ?? []).map((rec, i) => (
|
|
<tr key={i} className="border-t border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
|
<td className="px-3 py-1.5 text-cyan-400 font-mono text-xs truncate max-w-[220px]">{rec.name}</td>
|
|
<td className="px-3 py-1.5">
|
|
<span className={`text-xs font-medium ${dnsTypeColors[rec.type] ?? ""}`}>{rec.type}</span>
|
|
</td>
|
|
<td className="px-3 py-1.5 text-amber-400/80 font-mono text-xs truncate max-w-[200px]"><Copyable text={rec.content} /></td>
|
|
<td className="px-3 py-1.5 text-center">
|
|
{rec.proxied ? (
|
|
<span className="inline-block w-2 h-2 rounded-full bg-orange-400" style={{ boxShadow: "0 0 6px rgba(251,146,60,0.5)" }} title="Proxied" />
|
|
) : (
|
|
<span className="inline-block w-2 h-2 rounded-full bg-gray-500" title="DNS Only" />
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Authentik, Gitea -- 2 columns */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
|
|
{/* Authentik SSO */}
|
|
<Card>
|
|
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
|
<CardTitle className="text-base font-semibold text-violet-400">Authentik SSO</CardTitle>
|
|
{authentik && (
|
|
<Badge variant="secondary" className="text-xs bg-violet-500/10 border border-violet-500/20 text-violet-400">
|
|
{authentik.active_sessions} sessions
|
|
</Badge>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!authentik ? (
|
|
<CardSkeleton lines={4} />
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* Users */}
|
|
{authentik.users && authentik.users.length > 0 && (
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wider text-muted-foreground/70 font-medium mb-2">Users</p>
|
|
<div className="space-y-2">
|
|
{authentik.users.map((u, i) => (
|
|
<div key={i} className="flex items-center justify-between text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<StatusBadge color={u.active ? "green" : "red"} />
|
|
<span className="text-violet-400 font-medium">{u.username}</span>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground/60">
|
|
{u.last_login === "never" ? "never" : u.last_login.slice(0, 10)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(authentik.recent_events ?? []).length > 0 && (
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wider text-muted-foreground/70 font-medium mb-2">Recent Events</p>
|
|
<div className="space-y-2">
|
|
{(authentik.recent_events ?? []).slice(0, 6).map((evt, i) => (
|
|
<div key={i} className="flex items-center justify-between text-sm">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<StatusBadge
|
|
color={String(evt.action).includes("login") ? "green" : String(evt.action).includes("fail") ? "red" : "blue"}
|
|
label={String(evt.action)}
|
|
/>
|
|
<span className="text-xs text-muted-foreground/60 truncate">{String(evt.user ?? "")}</span>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground/50 shrink-0 ml-2">
|
|
{String(evt.created ?? "").slice(11, 16)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(authentik.recent_events ?? []).length === 0 && !(authentik.users?.length) && (
|
|
<p className="text-sm text-muted-foreground/60">No recent activity</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Gitea Activity */}
|
|
<Card>
|
|
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
|
<CardTitle className="text-base font-semibold text-green-400">Gitea</CardTitle>
|
|
{gitea && gitea.open_prs.length > 0 && (
|
|
<Badge variant="secondary" className="text-xs bg-green-500/10 border border-green-500/20 text-green-400">
|
|
{gitea.open_prs.length} open PR{gitea.open_prs.length !== 1 ? "s" : ""}
|
|
</Badge>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!gitea ? (
|
|
<CardSkeleton lines={4} />
|
|
) : (
|
|
<div className="space-y-4">
|
|
{gitea.commits.length > 0 && (
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wider text-muted-foreground/70 font-medium mb-2">Recent Commits</p>
|
|
<div className="space-y-2">
|
|
{gitea.commits.slice(0, 6).map((c, i) => (
|
|
<div key={i} className="text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-amber-400 font-mono text-xs shrink-0">{c.sha.slice(0, 7)}</span>
|
|
<span className="text-foreground truncate">{c.message.split("\n")[0]}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<span className="text-xs text-muted-foreground/60">{c.author}</span>
|
|
<span className="text-xs text-muted-foreground/40">{formatTime(c.date)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{gitea.open_prs.length > 0 && (
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wider text-muted-foreground/70 font-medium mb-2">Open PRs</p>
|
|
<div className="space-y-1.5">
|
|
{gitea.open_prs.map((pr, i) => (
|
|
<div key={i} className="flex items-center justify-between text-sm">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="text-green-400 font-mono text-xs shrink-0">#{pr.number}</span>
|
|
<span className="text-foreground truncate">{pr.title}</span>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground/60 shrink-0 ml-2">{pr.author}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Bottom: DNS rewrites table */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-base font-semibold">DNS Rewrites</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<DataTable<DnsRewrite>
|
|
data={rewrites}
|
|
columns={rewriteColumns}
|
|
searchKey="domain"
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|