Sanitized mirror from private repository - 2026-04-18 11:19:59 UTC
This commit is contained in:
450
dashboard/ui/app/infrastructure/page.tsx
Normal file
450
dashboard/ui/app/infrastructure/page.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { usePoll } from "@/lib/use-poll";
|
||||
import type { Container, OverviewStats, KumaStats, DiskUsageEntry } from "@/lib/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DataTable, Column } from "@/components/data-table";
|
||||
import { ContainerLogsModal } from "@/components/container-logs-modal";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { postAPI } from "@/lib/api";
|
||||
import { CardSkeleton } from "@/components/skeleton";
|
||||
|
||||
interface OlaresPod {
|
||||
name: string;
|
||||
namespace: string;
|
||||
status: string;
|
||||
restarts: number;
|
||||
age: string;
|
||||
}
|
||||
|
||||
const endpointColors: Record<string, string> = {
|
||||
atlantis: "text-blue-400",
|
||||
calypso: "text-violet-400",
|
||||
olares: "text-emerald-400",
|
||||
nuc: "text-amber-400",
|
||||
rpi5: "text-cyan-400",
|
||||
homelab: "text-green-400",
|
||||
};
|
||||
|
||||
function getContainerStateColor(state: string): string {
|
||||
const lower = state.toLowerCase();
|
||||
if (lower === "running") return "text-green-400";
|
||||
if (lower === "exited" || lower === "dead") return "text-red-400";
|
||||
if (lower === "created" || lower === "restarting" || lower === "paused") return "text-amber-400";
|
||||
return "text-foreground";
|
||||
}
|
||||
|
||||
const hostColors: Record<string, string> = {
|
||||
atlantis: "text-blue-400",
|
||||
calypso: "text-violet-400",
|
||||
olares: "text-emerald-400",
|
||||
nuc: "text-amber-400",
|
||||
rpi5: "text-cyan-400",
|
||||
homelab: "text-green-400",
|
||||
guava: "text-orange-400",
|
||||
seattle: "text-teal-400",
|
||||
jellyfish: "text-indigo-400",
|
||||
"matrix-ubuntu": "text-pink-400",
|
||||
};
|
||||
|
||||
export default function InfrastructurePage() {
|
||||
const { data: containers } = usePoll<Container[]>(
|
||||
"/api/containers",
|
||||
30000
|
||||
);
|
||||
const { data: overview } = usePoll<OverviewStats>(
|
||||
"/api/stats/overview",
|
||||
60000
|
||||
);
|
||||
const { data: pods } = usePoll<OlaresPod[]>("/api/olares/pods", 30000);
|
||||
const { data: kuma } = usePoll<KumaStats>("/api/kuma/monitors", 60000);
|
||||
const { data: disks } = usePoll<DiskUsageEntry[]>("/api/disk-usage", 300000);
|
||||
const { data: temps } = usePoll<{ host: string; cpu_temp: number; hot_nvme?: { label: string; temp: number } }[]>("/api/temperatures", 60000);
|
||||
|
||||
const [logsTarget, setLogsTarget] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
endpoint: string;
|
||||
} | null>(null);
|
||||
|
||||
const [hoveredMonitor, setHoveredMonitor] = useState<number | null>(null);
|
||||
|
||||
const endpoints = useMemo(() => {
|
||||
if (!containers) return [];
|
||||
return [...new Set(containers.map((c) => c.endpoint))];
|
||||
}, [containers]);
|
||||
|
||||
const containerColumns: Column<Container>[] = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Name",
|
||||
render: (row) => (
|
||||
<span className="font-medium text-foreground">{row.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "state",
|
||||
label: "State",
|
||||
render: (row) => (
|
||||
<StatusBadge
|
||||
color={row.state === "running" ? "green" : "red"}
|
||||
label={row.state}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: "status", label: "Status" },
|
||||
{
|
||||
key: "endpoint",
|
||||
label: "Endpoint",
|
||||
render: (row) => (
|
||||
<span className={`font-medium ${endpointColors[row.endpoint.toLowerCase()] ?? "text-foreground"}`}>
|
||||
{row.endpoint}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
label: "Image",
|
||||
render: (row) => (
|
||||
<span className="truncate max-w-[200px] block">{row.image}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const podColumns: Column<OlaresPod>[] = [
|
||||
{ key: "name", label: "Pod" },
|
||||
{ key: "namespace", label: "Namespace" },
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (row) => (
|
||||
<StatusBadge
|
||||
color={row.status === "Running" ? "green" : "amber"}
|
||||
label={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: "restarts", label: "Restarts" },
|
||||
{ key: "age", label: "Age" },
|
||||
];
|
||||
|
||||
const gpu = overview?.gpu;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-lg font-semibold">Infrastructure</h1>
|
||||
|
||||
{/* Kuma Monitors */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">Uptime Kuma</CardTitle>
|
||||
{kuma && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="secondary" className="text-xs bg-green-500/10 border border-green-500/20 text-green-400">
|
||||
{kuma.up} up
|
||||
</Badge>
|
||||
{kuma.down > 0 && (
|
||||
<Badge variant="secondary" className="text-xs bg-red-500/10 border border-red-500/20 text-red-400">
|
||||
{kuma.down} down
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{kuma.total} total</span>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!kuma ? (
|
||||
<CardSkeleton lines={3} />
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{kuma.monitors.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="relative"
|
||||
onMouseEnter={() => setHoveredMonitor(m.id)}
|
||||
onMouseLeave={() => setHoveredMonitor(null)}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-sm cursor-pointer transition-all ${
|
||||
!m.active
|
||||
? "bg-gray-500/50"
|
||||
: m.status
|
||||
? "bg-green-500 hover:bg-green-400"
|
||||
: "bg-red-500 hover:bg-red-400"
|
||||
}`}
|
||||
style={{
|
||||
boxShadow: !m.active
|
||||
? "none"
|
||||
: m.status
|
||||
? "0 0 4px rgba(34, 197, 94, 0.4)"
|
||||
: "0 0 4px rgba(239, 68, 68, 0.5)",
|
||||
}}
|
||||
/>
|
||||
{hoveredMonitor === m.id && (
|
||||
<div className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 rounded-lg bg-gray-900/95 border border-white/[0.12] text-xs whitespace-nowrap shadow-lg pointer-events-none">
|
||||
<p className="text-foreground font-medium">{m.name}</p>
|
||||
{m.url && <p className="text-muted-foreground/60 mt-0.5">{m.url}</p>}
|
||||
<p className={`mt-0.5 ${m.active ? (m.status ? "text-green-400" : "text-red-400") : "text-gray-400"}`}>
|
||||
{!m.active ? "Inactive" : m.status ? "Up" : "Down"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Container Table */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">Containers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable<Container>
|
||||
data={containers ?? []}
|
||||
columns={containerColumns}
|
||||
searchKey="name"
|
||||
filterKey="endpoint"
|
||||
filterOptions={endpoints}
|
||||
actions={(row) => (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-white/[0.08] hover:bg-white/[0.06]"
|
||||
onClick={() =>
|
||||
setLogsTarget({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
endpoint: row.endpoint,
|
||||
})
|
||||
}
|
||||
>
|
||||
Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-white/[0.08] hover:bg-white/[0.06]"
|
||||
onClick={() =>
|
||||
postAPI(
|
||||
`/api/containers/${row.endpoint}/${row.id}/restart`
|
||||
)
|
||||
}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Row 2: Olares Pods + GPU */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">Olares Pods</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable<OlaresPod>
|
||||
data={pods ?? []}
|
||||
columns={podColumns}
|
||||
searchKey="name"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">GPU Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!gpu ? (
|
||||
<CardSkeleton lines={3} />
|
||||
) : gpu.available ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{gpu.name}
|
||||
</p>
|
||||
{gpu.vram_used_mb != null && gpu.vram_total_mb != null && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mb-1.5">
|
||||
<span>VRAM</span>
|
||||
<span>
|
||||
{(gpu.vram_used_mb / 1024).toFixed(1)} /{" "}
|
||||
{(gpu.vram_total_mb / 1024).toFixed(1)} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="glass-bar-track h-3">
|
||||
<div
|
||||
className="h-full glass-bar-fill bg-gradient-to-r from-blue-500 to-violet-500 transition-all duration-700"
|
||||
style={{
|
||||
width: `${(gpu.vram_used_mb / gpu.vram_total_mb) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground/70">Temperature</p>
|
||||
<p className="text-foreground text-lg font-semibold stat-glow">
|
||||
{gpu.temp_c ?? "--"}°C
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground/70">Power</p>
|
||||
<p className="text-foreground text-lg font-semibold stat-glow">
|
||||
{gpu.power_w ?? "--"}W
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground/70">Utilization</p>
|
||||
<p className="text-foreground text-lg font-semibold stat-glow">
|
||||
{gpu.utilization_pct ?? "--"}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">GPU not available</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
|
||||
{/* Host Temperatures */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">Temperatures</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!temps ? (
|
||||
<CardSkeleton lines={6} />
|
||||
) : temps.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground/60">No temperature data</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{[...temps]
|
||||
.sort((a, b) => b.cpu_temp - a.cpu_temp)
|
||||
.map((t) => {
|
||||
const color =
|
||||
t.cpu_temp >= 80
|
||||
? "from-red-500 to-red-400"
|
||||
: t.cpu_temp >= 60
|
||||
? "from-amber-500 to-amber-400"
|
||||
: "from-green-500 to-emerald-400";
|
||||
const hostCls =
|
||||
hostColors[t.host.toLowerCase()] ?? "text-foreground";
|
||||
// Scale bar: 0-100°C range
|
||||
const barWidth = Math.min(100, t.cpu_temp);
|
||||
|
||||
return (
|
||||
<div key={t.host} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`font-medium ${hostCls}`}>{t.host}</span>
|
||||
{t.hot_nvme && (
|
||||
<span className="text-xs text-red-400/80">
|
||||
{t.hot_nvme.label} {t.hot_nvme.temp}°C
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium tabular-nums-transition min-w-[40px] text-right">
|
||||
{t.cpu_temp}°C
|
||||
</span>
|
||||
</div>
|
||||
<div className="glass-bar-track h-2">
|
||||
<div
|
||||
className={`h-full glass-bar-fill bg-gradient-to-r ${color} transition-all duration-700`}
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disk Usage */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">Disk Usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!disks ? (
|
||||
<CardSkeleton lines={6} />
|
||||
) : disks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground/60">No disk data</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{[...disks]
|
||||
.sort((a, b) => b.used_pct - a.used_pct)
|
||||
.map((d, i) => {
|
||||
const color =
|
||||
d.used_pct >= 85
|
||||
? "from-red-500 to-red-400"
|
||||
: d.used_pct >= 70
|
||||
? "from-amber-500 to-amber-400"
|
||||
: "from-green-500 to-emerald-400";
|
||||
const hostCls =
|
||||
hostColors[d.host.toLowerCase()] ?? "text-foreground";
|
||||
|
||||
return (
|
||||
<div key={`${d.host}-${d.mount}-${i}`} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`font-medium ${hostCls}`}>{d.host}</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-xs truncate">
|
||||
{d.mount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{d.total_gb >= 1000
|
||||
? `${(d.total_gb / 1000).toFixed(1)} TB`
|
||||
: `${Math.round(d.total_gb)} GB`}
|
||||
</span>
|
||||
<span className="text-xs font-medium tabular-nums-transition min-w-[36px] text-right">
|
||||
{d.used_pct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-bar-track h-2">
|
||||
<div
|
||||
className={`h-full glass-bar-fill bg-gradient-to-r ${color} transition-all duration-700`}
|
||||
style={{ width: `${d.used_pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Logs Modal */}
|
||||
<ContainerLogsModal
|
||||
containerId={logsTarget?.id ?? null}
|
||||
containerName={logsTarget?.name ?? ""}
|
||||
endpoint={logsTarget?.endpoint ?? ""}
|
||||
onClose={() => setLogsTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user