Sanitized mirror from private repository - 2026-04-05 12:03:50 UTC
This commit is contained in:
320
dashboard/ui/app/network/page.tsx
Normal file
320
dashboard/ui/app/network/page.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
"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";
|
||||
|
||||
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>("/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) => <span className="text-amber-400 font-mono">{row.answer}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
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() : "\u2014"; })()}
|
||||
sub="DNS queries"
|
||||
/>
|
||||
<StatCard
|
||||
label="Blocked"
|
||||
value={(() => { const v = adguard?.blocked ?? adguard?.num_blocked_filtering; return v != null ? v.toLocaleString() : "\u2014"; })()}
|
||||
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` : "\u2014"; })()}
|
||||
sub="processing time"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Middle: Headscale nodes grid */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">Headscale Nodes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{nodes.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<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">
|
||||
{node.ip_addresses?.[0] ?? node.ip ?? "\u2014"}
|
||||
</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, Authentik, Gitea -- 3 columns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{/* Cloudflare DNS */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold text-orange-400">Cloudflare DNS</CardTitle>
|
||||
{cloudflare && (
|
||||
<Badge variant="secondary" className="text-xs bg-white/[0.06] border border-white/[0.08]">
|
||||
{cloudflare.total} records
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!cloudflare ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.06] px-3 py-2 text-center">
|
||||
<p className="text-2xl font-bold text-orange-400">{cloudflare.proxied}</p>
|
||||
<p className="text-xs text-muted-foreground">Proxied</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.06] px-3 py-2 text-center">
|
||||
<p className="text-2xl font-bold text-muted-foreground">{cloudflare.dns_only}</p>
|
||||
<p className="text-xs text-muted-foreground">DNS Only</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground/70 font-medium mb-2">Record Types</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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 ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{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={evt.action.includes("login") ? "green" : evt.action.includes("fail") ? "red" : "blue"}
|
||||
label={evt.action}
|
||||
/>
|
||||
{evt.user && (
|
||||
<span className="text-xs text-muted-foreground/60 truncate">{evt.user}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground/50 shrink-0 ml-2">
|
||||
{formatTime(evt.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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 ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user