Sanitized mirror from private repository - 2026-04-18 11:19:59 UTC
This commit is contained in:
379
dashboard/ui/app/network/page.tsx
Normal file
379
dashboard/ui/app/network/page.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user