Initial commit: Beszel fork with Domain Locker integration

This commit is contained in:
Tomas Dvorak
2026-04-21 15:39:43 +02:00
commit 363d708e91
440 changed files with 160889 additions and 0 deletions
@@ -0,0 +1,26 @@
import { useLingui } from "@lingui/react/macro"
import { memo, useEffect, useMemo } from "react"
import ContainersTable from "@/components/containers-table/containers-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
export default memo(() => {
const { t } = useLingui()
useEffect(() => {
document.title = `${t`All Containers`} / Beszel`
}, [t])
return useMemo(
() => (
<>
<div className="grid gap-4">
<ActiveAlerts />
<ContainersTable />
</div>
<FooterRepoLink />
</>
),
[]
)
})
@@ -0,0 +1,687 @@
import { memo, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Trans } from "@lingui/react/macro"
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Globe,
Calendar,
Clock,
Shield,
Server,
MapPin,
RefreshCw,
ExternalLink,
Edit3,
Trash2,
CheckCircle2,
AlertTriangle,
XCircle,
Lock,
Key,
Fingerprint,
FileText,
User,
Mail,
Phone,
Building,
} from "lucide-react"
import { getDomain, getDomainHistory, refreshDomain, formatDate, formatDays } from "@/lib/domains"
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from "recharts"
import { Link, navigate } from "@/components/router"
// Status badge component
function StatusBadge({ status }: { status: string }) {
const configs = {
active: { color: "bg-green-500", icon: CheckCircle2, text: "Active" },
expiring: { color: "bg-yellow-500", icon: AlertTriangle, text: "Expiring Soon" },
expired: { color: "bg-red-500", icon: XCircle, text: "Expired" },
unknown: { color: "bg-gray-500", icon: AlertTriangle, text: "Unknown" },
paused: { color: "bg-blue-500", icon: Clock, text: "Paused" },
}
const config = configs[status as keyof typeof configs] || configs.unknown
const Icon = config.icon
return (
<div className="flex items-center gap-2">
<div className={`h-2.5 w-2.5 rounded-full ${config.color}`} />
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize text-sm">{config.text}</span>
</div>
)
}
// Info card component
function InfoCard({ title, value, icon: Icon, subtitle, className }: { title: string; value: string; icon: any; subtitle?: string; className?: string }) {
return (
<Card className={className}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-lg">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-muted-foreground">{title}</p>
<p className="font-semibold truncate">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
</div>
</CardContent>
</Card>
)
}
export default memo(function DomainDetail({ id }: { id: string }) {
const { toast } = useToast()
const [activeTab, setActiveTab] = useState("overview")
const { data: domain, isLoading: isDomainLoading } = useQuery({
queryKey: ["domain", id],
queryFn: () => getDomain(id),
refetchInterval: 60000,
})
const { data: history } = useQuery({
queryKey: ["domain-history", id],
queryFn: () => getDomainHistory(id),
})
const handleRefresh = async () => {
try {
await refreshDomain(id)
toast({ title: "Domain refresh started" })
} catch (error) {
toast({
title: "Failed to refresh domain",
variant: "destructive",
})
}
}
const handleDelete = () => {
if (confirm("Are you sure you want to delete this domain?")) {
// Delete domain logic would go here
toast({ title: "Domain deleted" })
navigate("/")
}
}
if (isDomainLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!domain) {
return (
<div className="text-center py-12">
<Globe className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h2 className="text-xl font-semibold mb-2">Domain not found</h2>
<p className="text-muted-foreground">The domain you are looking for does not exist.</p>
<Button asChild className="mt-4">
<Link href="/">Go back home</Link>
</Button>
</div>
)
}
// Prepare chart data from history
const chartData = history?.map((h: any) => ({
date: new Date(h.created).toLocaleDateString(),
daysUntilExpiry: h.days_until_expiry || 0,
sslDaysUntil: h.ssl_days_until || 0,
})) || []
return (
<div className="grid gap-4 mb-14">
{/* Header */}
<Card>
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
{domain.favicon_url ? (
<img src={domain.favicon_url} alt="" className="h-8 w-8" />
) : (
<Globe className="h-6 w-6 text-primary" />
)}
</div>
<div>
<h1 className="text-2xl font-bold">{domain.domain_name}</h1>
<div className="flex items-center gap-2 mt-1">
<StatusBadge status={domain.status} />
{domain.tags?.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="mr-2 h-4 w-4" />
<Trans>Refresh</Trans>
</Button>
<Button variant="outline" size="sm" asChild>
<a href={`https://${domain.domain_name}`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
<Trans>Visit</Trans>
</a>
</Button>
<Button variant="outline" size="sm">
<Edit3 className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete}>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Info Grid */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<InfoCard
title="Registrar"
value={domain.registrar_name || "Unknown"}
icon={Server}
/>
<InfoCard
title="Domain Expiry"
value={formatDate(domain.expiry_date)}
subtitle={formatDays(domain.days_until_expiry)}
icon={Calendar}
className={domain.days_until_expiry !== undefined && domain.days_until_expiry <= 30 ? "text-yellow-600" : ""}
/>
<InfoCard
title="SSL Expiry"
value={domain.ssl_valid_to ? formatDate(domain.ssl_valid_to) : "No SSL"}
subtitle={domain.ssl_valid_to ? formatDays(domain.ssl_days_until) : undefined}
icon={Shield}
className={domain.ssl_days_until !== undefined && domain.ssl_days_until <= 14 ? "text-red-600" : ""}
/>
<InfoCard
title="Location"
value={domain.host_country || "Unknown"}
subtitle={domain.host_isp}
icon={MapPin}
/>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="contents">
<TabsList className="h-11 p-1.5 w-full shadow-xs overflow-auto justify-start">
<TabsTrigger value="overview" className="flex items-center gap-1.5 px-4">
<Globe className="size-3.5" />
<Trans>Overview</Trans>
</TabsTrigger>
<TabsTrigger value="dns" className="flex items-center gap-1.5 px-4">
<Server className="size-3.5" />
<Trans>DNS Records</Trans>
</TabsTrigger>
<TabsTrigger value="ssl" className="flex items-center gap-1.5 px-4">
<Lock className="size-3.5" />
<Trans>SSL Certificate</Trans>
</TabsTrigger>
<TabsTrigger value="whois" className="flex items-center gap-1.5 px-4">
<FileText className="size-3.5" />
<Trans>WHOIS Info</Trans>
</TabsTrigger>
<TabsTrigger value="history" className="flex items-center gap-1.5 px-4">
<Clock className="size-3.5" />
<Trans>History</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="contents">
<div className="grid gap-4">
{/* Expiry Timeline Chart */}
<Card>
<CardHeader>
<CardTitle>Domain Expiry Timeline</CardTitle>
<CardDescription>Days until domain and SSL expiry over time</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorDomain" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorSsl" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Area
type="monotone"
dataKey="daysUntilExpiry"
stroke="#3b82f6"
fillOpacity={1}
fill="url(#colorDomain)"
name="Domain Days"
/>
<Area
type="monotone"
dataKey="sslDaysUntil"
stroke="#22c55e"
fillOpacity={1}
fill="url(#colorSsl)"
name="SSL Days"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Additional Info */}
<div className="grid sm:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>IP Addresses</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{domain.ipv4_addresses?.map((ip: string) => (
<div key={ip} className="flex items-center gap-2">
<Badge variant="secondary">IPv4</Badge>
<code className="text-sm">{ip}</code>
</div>
))}
{domain.ipv6_addresses?.map((ip: string) => (
<div key={ip} className="flex items-center gap-2">
<Badge variant="secondary">IPv6</Badge>
<code className="text-sm">{ip}</code>
</div>
))}
{!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && (
<p className="text-muted-foreground">No IP addresses found</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Valuation</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Purchase Price</span>
<span className="font-medium">${domain.purchase_price || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Current Value</span>
<span className="font-medium">${domain.current_value || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Renewal Cost</span>
<span className="font-medium">${domain.renewal_cost || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Auto-renew</span>
<Badge variant={domain.auto_renew ? "default" : "secondary"}>
{domain.auto_renew ? "Yes" : "No"}
</Badge>
</div>
</CardContent>
</Card>
</div>
{/* Notes */}
{domain.notes && (
<Card>
<CardHeader>
<CardTitle>Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{domain.notes}</p>
</CardContent>
</Card>
)}
</div>
</TabsContent>
<TabsContent value="dns" className="contents">
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>DNS Records</CardTitle>
<CardDescription>Name servers, mail exchangers, and text records</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Nameservers */}
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Server className="h-4 w-4" />
Nameservers
<Badge variant="secondary" className="ml-2">{domain.name_servers?.length || 0}</Badge>
</h4>
<div className="space-y-1">
{domain.name_servers?.map((ns: string, i: number) => (
<div key={i} className="flex items-center gap-2">
<Badge variant="outline">NS</Badge>
<code className="text-sm">{ns}</code>
</div>
))}
{!domain.name_servers?.length && (
<p className="text-muted-foreground text-sm">No nameservers found</p>
)}
</div>
</div>
{/* MX Records */}
{domain.mx_records && domain.mx_records.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Mail className="h-4 w-4" />
Mail Servers (MX)
<Badge variant="secondary" className="ml-2">{domain.mx_records.length}</Badge>
</h4>
<div className="space-y-1">
{domain.mx_records?.map((mx: string, i: number) => (
<div key={i} className="flex items-center gap-2">
<Badge variant="outline">MX</Badge>
<code className="text-sm">{mx}</code>
</div>
))}
</div>
</div>
)}
{/* TXT Records */}
{domain.txt_records && domain.txt_records.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<FileText className="h-4 w-4" />
TXT Records
<Badge variant="secondary" className="ml-2">{domain.txt_records.length}</Badge>
</h4>
<div className="space-y-1">
{domain.txt_records?.map((txt: string, i: number) => (
<div key={i} className="flex items-start gap-2">
<Badge variant="outline">TXT</Badge>
<code className="text-sm break-all">{txt}</code>
</div>
))}
</div>
</div>
)}
{/* DNSSEC */}
{domain.dnssec && (
<div>
<h4 className="text-sm font-medium mb-2">DNSSEC</h4>
<Badge variant={domain.dnssec === "signed" ? "default" : "secondary"}>
{domain.dnssec}
</Badge>
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="ssl" className="contents">
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>SSL Certificate Details</CardTitle>
<CardDescription>Certificate information and validity</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{domain.ssl_valid_to ? (
<>
{/* Validity */}
<div className="grid sm:grid-cols-2 gap-4">
<InfoCard
title="Valid From"
value={formatDate(domain.ssl_valid_from)}
icon={Calendar}
/>
<InfoCard
title="Valid Until"
value={formatDate(domain.ssl_valid_to)}
subtitle={formatDays(domain.ssl_days_until)}
icon={Shield}
className={domain.ssl_days_until !== undefined && domain.ssl_days_until <= 14 ? "text-red-600" : ""}
/>
</div>
{/* Issuer & Subject */}
<div className="space-y-4">
<div className="flex items-start gap-3">
<Building className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Issuer</p>
<p className="font-medium">{domain.ssl_issuer || "Unknown"}</p>
{domain.ssl_issuer_country && (
<p className="text-sm text-muted-foreground">Country: {domain.ssl_issuer_country}</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Globe className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Subject</p>
<p className="font-medium">{domain.ssl_subject || "Unknown"}</p>
</div>
</div>
</div>
{/* Technical Details */}
<div className="grid sm:grid-cols-2 gap-4 pt-4 border-t">
<div>
<p className="text-sm text-muted-foreground mb-1">Key Size</p>
<p className="font-medium">{domain.ssl_key_size ? `${domain.ssl_key_size} bits` : "Unknown"}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">Signature Algorithm</p>
<p className="font-medium">{domain.ssl_signature_algo || "Unknown"}</p>
</div>
{domain.ssl_fingerprint && (
<div className="sm:col-span-2">
<p className="text-sm text-muted-foreground mb-1">Fingerprint</p>
<code className="text-sm break-all">{domain.ssl_fingerprint}</code>
</div>
)}
</div>
</>
) : (
<div className="text-center py-8">
<Shield className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">No SSL certificate information available</p>
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="whois" className="contents">
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>WHOIS Information</CardTitle>
<CardDescription>Domain registration details</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Registrar */}
<div className="space-y-2">
<h4 className="text-sm font-medium flex items-center gap-2">
<Building className="h-4 w-4" />
Registrar
</h4>
<div className="grid sm:grid-cols-2 gap-2">
<div>
<p className="text-sm text-muted-foreground">Name</p>
<p className="font-medium">{domain.registrar_name || "Unknown"}</p>
</div>
{domain.registrar_id && (
<div>
<p className="text-sm text-muted-foreground">IANA ID</p>
<p className="font-medium">{domain.registrar_id}</p>
</div>
)}
{domain.registry_domain_id && (
<div className="sm:col-span-2">
<p className="text-sm text-muted-foreground">Registry Domain ID</p>
<p className="font-medium">{domain.registry_domain_id}</p>
</div>
)}
</div>
</div>
{/* Important Dates */}
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Calendar className="h-4 w-4" />
Important Dates
</h4>
<div className="grid sm:grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Registration</p>
<p className="font-medium">{formatDate(domain.creation_date)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Last Updated</p>
<p className="font-medium">{formatDate(domain.updated_date)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Expires</p>
<p className="font-medium">{formatDate(domain.expiry_date)}</p>
</div>
</div>
</div>
{/* Registrant Contact */}
{(domain.registrant_name || domain.registrant_org) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<User className="h-4 w-4" />
Registrant Contact
</h4>
<div className="grid sm:grid-cols-2 gap-2">
{domain.registrant_name && (
<div>
<p className="text-sm text-muted-foreground">Name</p>
<p className="font-medium">{domain.registrant_name}</p>
</div>
)}
{domain.registrant_org && (
<div>
<p className="text-sm text-muted-foreground">Organization</p>
<p className="font-medium">{domain.registrant_org}</p>
</div>
)}
{domain.registrant_country && (
<div>
<p className="text-sm text-muted-foreground">Country</p>
<p className="font-medium">{domain.registrant_country}</p>
</div>
)}
{(domain.registrant_city || domain.registrant_state) && (
<div>
<p className="text-sm text-muted-foreground">Location</p>
<p className="font-medium">
{[domain.registrant_city, domain.registrant_state].filter(Boolean).join(", ")}
</p>
</div>
)}
</div>
</div>
)}
{/* Abuse Contact */}
{(domain.abuse_email || domain.abuse_phone) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Abuse Contact
</h4>
<div className="grid sm:grid-cols-2 gap-2">
{domain.abuse_email && (
<div>
<p className="text-sm text-muted-foreground">Email</p>
<a href={`mailto:${domain.abuse_email}`} className="font-medium text-primary hover:underline">
{domain.abuse_email}
</a>
</div>
)}
{domain.abuse_phone && (
<div>
<p className="text-sm text-muted-foreground">Phone</p>
<p className="font-medium">{domain.abuse_phone}</p>
</div>
)}
</div>
</div>
)}
{/* Domain Status */}
{domain.status && domain.status !== "Unknown" && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Shield className="h-4 w-4" />
Domain Status
</h4>
<div className="flex flex-wrap gap-2">
{domain.status.split(", ").map((status: string, i: number) => (
<Badge key={i} variant="secondary">{status}</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="history" className="contents">
<Card>
<CardHeader>
<CardTitle>Change History</CardTitle>
<CardDescription>Historical changes to domain information</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{history?.map((item: any) => (
<div key={item.id} className="flex items-start gap-4 pb-4 border-b last:border-0">
<div className="p-2 bg-muted rounded-lg">
<Clock className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="font-medium">{item.change_type}</p>
<p className="text-sm text-muted-foreground">{item.change_description}</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(item.created).toLocaleString()}
</p>
</div>
</div>
))}
{!history?.length && (
<p className="text-muted-foreground text-center py-8">No history available</p>
)}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
})
@@ -0,0 +1,47 @@
import { useLingui } from "@lingui/react/macro"
import { memo, Suspense, useEffect, useMemo } from "react"
import SystemsTable from "@/components/systems-table/systems-table"
import MonitorsTable from "@/components/monitors-table/monitors-table"
import DomainsTable from "@/components/domains-table/domains-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
export default memo(() => {
const { t } = useLingui()
useEffect(() => {
document.title = `${t`Dashboard`} / Beszel`
}, [t])
return useMemo(
() => (
<>
<div className="flex flex-col gap-6">
{/* Section 1: Device Monitoring (Primary) */}
<section>
<ActiveAlerts />
<Suspense>
<SystemsTable />
</Suspense>
</section>
{/* Section 2: Website & Service Monitoring (Secondary) */}
<section>
<Suspense>
<MonitorsTable />
</Suspense>
</section>
{/* Section 3: Domain Expiry Monitoring */}
<section>
<Suspense>
<DomainsTable />
</Suspense>
</section>
</div>
<FooterRepoLink />
</>
),
[]
)
})
@@ -0,0 +1,520 @@
import { memo, useState, useMemo } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Trans } from "@lingui/react/macro"
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import {
Globe,
Clock,
Activity,
RefreshCw,
ExternalLink,
Edit3,
Trash2,
CheckCircle2,
XCircle,
PauseIcon,
PlayIcon,
TrendingUp,
TrendingDown,
} from "lucide-react"
import {
getMonitor,
getMonitorStats,
getMonitorHeartbeats,
manualCheck,
pauseMonitor,
resumeMonitor,
deleteMonitor,
getMonitorTypeLabel,
formatUptime,
formatPing,
} from "@/lib/monitors"
import { formatDate } from "@/lib/domains"
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, AreaChart, Area } from "recharts"
import { Link, navigate } from "@/components/router"
import { cn } from "@/lib/utils"
// Status badge component
function StatusBadge({ status }: { status: string }) {
const configs = {
up: { color: "bg-green-500", icon: CheckCircle2, text: "Up" },
down: { color: "bg-red-500", icon: XCircle, text: "Down" },
pending: { color: "bg-yellow-500", icon: Clock, text: "Pending" },
paused: { color: "bg-gray-500", icon: PauseIcon, text: "Paused" },
maintenance: { color: "bg-blue-500", icon: Activity, text: "Maintenance" },
}
const config = configs[status as keyof typeof configs] || configs.pending
const Icon = config.icon
return (
<div className="flex items-center gap-2">
<div className={`h-2.5 w-2.5 rounded-full ${config.color}`} />
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize text-sm">{config.text}</span>
</div>
)
}
// Stat card component
function StatCard({
title,
value,
icon: Icon,
subtitle,
trend,
className,
}: {
title: string
value: string
icon: any
subtitle?: string
trend?: "up" | "down" | "neutral"
className?: string
}) {
const TrendIcon = trend === "up" ? TrendingUp : trend === "down" ? TrendingDown : null
return (
<Card className={className}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-lg">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-muted-foreground">{title}</p>
<div className="flex items-center gap-2">
<p className="font-semibold truncate">{value}</p>
{TrendIcon && <TrendIcon className="h-4 w-4 text-muted-foreground" />}
</div>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
</div>
</CardContent>
</Card>
)
}
export default memo(function MonitorDetail({ id }: { id: string }) {
const { toast } = useToast()
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState("overview")
const { data: monitor, isLoading: isMonitorLoading } = useQuery({
queryKey: ["monitor", id],
queryFn: () => getMonitor(id),
refetchInterval: 30000,
})
const { data: stats } = useQuery({
queryKey: ["monitor-stats", id],
queryFn: () => getMonitorStats(id),
})
const { data: heartbeatsData } = useQuery({
queryKey: ["monitor-heartbeats", id],
queryFn: () => getMonitorHeartbeats(id),
})
const heartbeats = heartbeatsData?.heartbeats
const checkMutation = useMutation({
mutationFn: () => manualCheck(id),
onSuccess: (result) => {
toast({
title: `Check complete`,
description: `${monitor?.name} is ${result.status}`,
})
queryClient.invalidateQueries({ queryKey: ["monitor", id] })
queryClient.invalidateQueries({ queryKey: ["monitor-heartbeats", id] })
},
})
const pauseMutation = useMutation({
mutationFn: () => (monitor?.status === "paused" ? resumeMonitor(id) : pauseMonitor(id)),
onSuccess: () => {
toast({
title: monitor?.status === "paused" ? "Monitor resumed" : "Monitor paused",
})
queryClient.invalidateQueries({ queryKey: ["monitor", id] })
},
})
const deleteMutation = useMutation({
mutationFn: () => deleteMonitor(id),
onSuccess: () => {
toast({ title: "Monitor deleted" })
navigate("/")
},
})
const handleDelete = () => {
if (confirm("Are you sure you want to delete this monitor?")) {
deleteMutation.mutate()
}
}
// Prepare chart data from heartbeats
const chartData = useMemo(() => {
if (!heartbeats) return []
return heartbeats
.slice()
.reverse()
.map((h: any) => ({
time: new Date(h.timestamp).toLocaleTimeString(),
responseTime: h.ping || 0,
status: h.status === "up" ? 1 : 0,
}))
}, [heartbeats])
// Calculate stats
const uptimeStats = useMemo(() => {
if (!heartbeats || !Array.isArray(heartbeats) || heartbeats.length === 0) return null
const total = heartbeats.length
const up = heartbeats.filter((h: any) => h.status === "up").length
const avgResponse = heartbeats.reduce((sum: number, h: any) => sum + (h.ping || 0), 0) / total
return {
uptime: ((up / total) * 100).toFixed(2),
avgResponse: avgResponse.toFixed(0),
totalChecks: total,
}
}, [heartbeats])
if (isMonitorLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!monitor) {
return (
<div className="text-center py-12">
<Globe className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h2 className="text-xl font-semibold mb-2">Monitor not found</h2>
<p className="text-muted-foreground">The monitor you are looking for does not exist.</p>
<Button asChild className="mt-4">
<Link href="/">Go back home</Link>
</Button>
</div>
)
}
const isUp = monitor.status === "up"
const isPaused = monitor.status === "paused"
return (
<div className="grid gap-4 mb-14">
{/* Header */}
<Card>
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div
className={cn(
"h-12 w-12 rounded-full flex items-center justify-center",
isUp ? "bg-green-500/10" : isPaused ? "bg-gray-500/10" : "bg-red-500/10"
)}
>
<Globe
className={cn(
"h-6 w-6",
isUp ? "text-green-500" : isPaused ? "text-gray-500" : "text-red-500"
)}
/>
</div>
<div>
<h1 className="text-2xl font-bold">{monitor.name}</h1>
<div className="flex items-center gap-2 mt-1">
<StatusBadge status={monitor.status} />
<Badge variant="secondary">{getMonitorTypeLabel(monitor.type)}</Badge>
{monitor.interval && (
<Badge variant="outline">{monitor.interval}s interval</Badge>
)}
</div>
{monitor.url && (
<p className="text-sm text-muted-foreground mt-1">{monitor.url}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending || isPaused}
>
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
<Trans>Check Now</Trans>
</Button>
{monitor.url && (
<Button variant="outline" size="sm" asChild>
<a href={monitor.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
<Trans>Visit</Trans>
</a>
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => pauseMutation.mutate()}
disabled={pauseMutation.isPending}
>
{monitor.status === "paused" ? (
<>
<PlayIcon className="mr-2 h-4 w-4" />
<Trans>Resume</Trans>
</>
) : (
<>
<PauseIcon className="mr-2 h-4 w-4" />
<Trans>Pause</Trans>
</>
)}
</Button>
<Button variant="outline" size="sm">
<Edit3 className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete}>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Stats Grid */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Uptime (24h)"
value={formatUptime(stats?.uptime_24h ? (stats.uptime_24h.up / stats.uptime_24h.total) * 100 : 0)}
icon={Activity}
trend={stats?.uptime_24h && (stats.uptime_24h.up / stats.uptime_24h.total) * 100 >= 99 ? "up" : "down"}
/>
<StatCard
title="Uptime (7d)"
value={formatUptime(stats?.uptime_7d ? (stats.uptime_7d.up / stats.uptime_7d.total) * 100 : 0)}
icon={Activity}
trend={stats?.uptime_7d && (stats.uptime_7d.up / stats.uptime_7d.total) * 100 >= 99 ? "up" : "down"}
/>
<StatCard
title="Uptime (30d)"
value={formatUptime(stats?.uptime_30d ? (stats.uptime_30d.up / stats.uptime_30d.total) * 100 : 0)}
icon={Activity}
trend={stats?.uptime_30d && (stats.uptime_30d.up / stats.uptime_30d.total) * 100 >= 99 ? "up" : "down"}
/>
<StatCard
title="Response Time"
value={uptimeStats ? `${uptimeStats.avgResponse}ms` : "-"}
subtitle={`${uptimeStats?.totalChecks || 0} checks`}
icon={Clock}
/>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="contents">
<TabsList className="h-11 p-1.5 w-full shadow-xs overflow-auto justify-start">
<TabsTrigger value="overview" className="w-full flex items-center gap-1.5">
<Activity className="size-3.5" />
<Trans>Overview</Trans>
</TabsTrigger>
<TabsTrigger value="response" className="w-full flex items-center gap-1.5">
<TrendingUp className="size-3.5" />
<Trans>Response Times</Trans>
</TabsTrigger>
<TabsTrigger value="history" className="w-full flex items-center gap-1.5">
<Clock className="size-3.5" />
<Trans>Check History</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="contents">
<div className="grid gap-4">
{/* Response Time Chart */}
<Card>
<CardHeader>
<CardTitle>Response Time History</CardTitle>
<CardDescription>Response times for the last 50 checks</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<defs>
<linearGradient id="colorResponse" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} unit="ms" />
<Tooltip
contentStyle={{ backgroundColor: "hsl(var(--card))", border: "1px solid hsl(var(--border))" }}
/>
<Area
type="monotone"
dataKey="responseTime"
stroke="#3b82f6"
fillOpacity={1}
fill="url(#colorResponse)"
name="Response Time (ms)"
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Monitor Details */}
<div className="grid sm:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Monitor Details</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Type</span>
<span className="font-medium">{getMonitorTypeLabel(monitor.type)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Interval</span>
<span className="font-medium">{monitor.interval}s</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Retries</span>
<span className="font-medium">{monitor.retries}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Created</span>
<span className="font-medium">{formatDate(monitor.created)}</span>
</div>
{monitor.last_check && (
<div className="flex justify-between">
<span className="text-muted-foreground">Last Check</span>
<span className="font-medium">{formatDate(monitor.last_check)}</span>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Uptime Statistics</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">24 Hours</span>
<span className="font-medium text-green-600">{formatUptime(stats?.uptime_24h ? (stats.uptime_24h.up / stats.uptime_24h.total) * 100 : 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">7 Days</span>
<span className="font-medium text-green-600">{formatUptime(stats?.uptime_7d ? (stats.uptime_7d.up / stats.uptime_7d.total) * 100 : 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">30 Days</span>
<span className="font-medium text-green-600">{formatUptime(stats?.uptime_30d ? (stats.uptime_30d.up / stats.uptime_30d.total) * 100 : 0)}</span>
</div>
{uptimeStats && (
<div className="flex justify-between">
<span className="text-muted-foreground">Total Checks</span>
<span className="font-medium">{uptimeStats.totalChecks}</span>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</TabsContent>
<TabsContent value="response" className="contents">
<Card>
<CardHeader>
<CardTitle>Response Time Analysis</CardTitle>
<CardDescription>Detailed response time metrics</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorResponseDetail" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} unit="ms" />
<Tooltip
contentStyle={{ backgroundColor: "hsl(var(--card))", border: "1px solid hsl(var(--border))" }}
/>
<Area
type="monotone"
dataKey="responseTime"
stroke="#8b5cf6"
strokeWidth={2}
fillOpacity={1}
fill="url(#colorResponseDetail)"
name="Response Time (ms)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="history" className="contents">
<Card>
<CardHeader>
<CardTitle>Recent Checks</CardTitle>
<CardDescription>Last 50 monitor checks</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Status</TableHead>
<TableHead>Response Time</TableHead>
<TableHead>Message</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{heartbeats?.slice(0, 50).map((hb: any) => (
<TableRow key={hb.id}>
<TableCell>{formatDate(hb.timestamp)}</TableCell>
<TableCell>
<Badge variant={hb.status === "up" ? "default" : "destructive"}>
{hb.status}
</Badge>
</TableCell>
<TableCell>{formatPing(hb.ping)}</TableCell>
<TableCell className="max-w-xs truncate">{hb.message || "-"}</TableCell>
</TableRow>
))}
{!heartbeats?.length && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
No check history available
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
})
@@ -0,0 +1,395 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
DownloadIcon,
Trash2Icon,
} from "lucide-react"
import { memo, useEffect, useState } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { useToast } from "@/components/ui/use-toast"
import { alertInfo } from "@/lib/alerts"
import { pb } from "@/lib/api"
import { cn, formatDuration, formatShortDate, useBrowserStorage } from "@/lib/utils"
import type { AlertsHistoryRecord } from "@/types"
import { alertsHistoryColumns } from "../../alerts-history-columns"
const SectionIntro = memo(() => {
return (
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>Alert History</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>View your 200 most recent alerts.</Trans>
</p>
</div>
)
})
export default function AlertsHistoryDataTable() {
const [data, setData] = useState<AlertsHistoryRecord[]>([])
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
const [globalFilter, setGlobalFilter] = useState("")
const { toast } = useToast()
const [deleteOpen, setDeleteDialogOpen] = useState(false)
// Store pagination preference in local storage
const [pagination, setPagination] = useBrowserStorage<PaginationState>("ah-pagination", {
pageIndex: 0,
pageSize: 10,
})
useEffect(() => {
let unsubscribe: (() => void) | undefined
const pbOptions = {
expand: "system",
fields: "id,name,value,state,created,resolved,expand.system.name",
}
// Initial load
pb.collection<AlertsHistoryRecord>("alerts_history")
.getList(0, 200, {
...pbOptions,
sort: "-created",
})
.then(({ items }) => setData(items))
// Subscribe to changes
;(async () => {
unsubscribe = await pb.collection("alerts_history").subscribe(
"*",
(e) => {
if (e.action === "create") {
setData((current) => [e.record as AlertsHistoryRecord, ...current])
}
if (e.action === "update") {
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as AlertsHistoryRecord) : r)))
}
if (e.action === "delete") {
setData((current) => current.filter((r) => r.id !== e.record.id))
}
},
pbOptions
)
})()
// Unsubscribe on unmount
return () => unsubscribe?.()
}, [])
const table = useReactTable({
data,
columns: [
{
id: "select",
header: ({ table }) => (
<Checkbox
className="ms-2"
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
...alertsHistoryColumns,
],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
pagination,
},
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
const system = row.original.expand?.system?.name ?? ""
const name = row.getValue("name") ?? ""
const created = row.getValue("created") ?? ""
const search = String(filterValue).toLowerCase()
return (
system.toLowerCase().includes(search) ||
(name as string).toLowerCase().includes(search) ||
(created as string).toLowerCase().includes(search)
)
},
})
// Bulk delete handler
const handleBulkDelete = async () => {
setDeleteDialogOpen(false)
const selectedIds = table.getSelectedRowModel().rows.map((row) => row.original.id)
try {
let batch = pb.createBatch()
let inBatch = 0
for (const id of selectedIds) {
batch.collection("alerts_history").delete(id)
inBatch++
if (inBatch > 20) {
await batch.send()
batch = pb.createBatch()
inBatch = 0
}
}
inBatch && (await batch.send())
table.resetRowSelection()
} catch (e) {
toast({
variant: "destructive",
title: t`Error`,
description: `Failed to delete records.`,
})
}
}
// Export to CSV handler
const handleExportCSV = () => {
const selectedRows = table.getSelectedRowModel().rows
if (!selectedRows.length) return
const cells: Record<string, (record: AlertsHistoryRecord) => string> = {
system: (record) => record.expand?.system?.name || record.system,
name: (record) => alertInfo[record.name]?.name() || record.name,
value: (record) => record.value + (alertInfo[record.name]?.unit ?? ""),
state: (record) => (record.resolved ? t`Resolved` : t`Active`),
created: (record) => formatShortDate(record.created),
resolved: (record) => (record.resolved ? formatShortDate(record.resolved) : ""),
duration: (record) => (record.resolved ? formatDuration(record.created, record.resolved) : ""),
}
const csvRows = [Object.keys(cells).join(",")]
for (const row of selectedRows) {
const r = row.original
csvRows.push(
Object.values(cells)
.map((val) => val(r))
.join(",")
)
}
const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "alerts_history.csv"
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="@container w-full">
<div className="@3xl:flex items-end mb-4 gap-4">
<SectionIntro />
<div className="flex items-center gap-2 ms-auto mt-3 @3xl:mt-0">
{table.getFilteredSelectedRowModel().rows.length > 0 && (
<div className="fixed bottom-0 left-0 w-full p-4 grid grid-cols-2 items-center gap-4 z-50 backdrop-blur-md shrink-0 @lg:static @lg:p-0 @lg:w-auto @lg:gap-3">
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteDialogOpen(open)}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="h-9 shrink-0">
<Trash2Icon className="size-4 shrink-0" />
<span className="ms-1">
<Trans>Delete</Trans>
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>This will permanently delete all selected records from the database.</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleBulkDelete}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button variant="outline" className="h-10" onClick={handleExportCSV}>
<DownloadIcon className="size-4" />
<span className="ms-1">
<Trans>Export</Trans>
</span>
</Button>
</div>
)}
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="px-4 w-full max-w-full @3xl:w-64"
/>
</div>
</div>
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-border/50">
{headerGroup.headers.map((header) => (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</tr>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-3">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
<Trans>No results.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between ps-1 tabular-nums">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
<Trans>
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</Trans>
</div>
<div className="flex w-full items-center gap-8 lg:w-fit my-3">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
<Trans>Rows per page</Trans>
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="w-18" id="rows-per-page">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 50, 100, 200].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
<Trans>
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</Trans>
</div>
<div className="ms-auto flex items-center gap-2 lg:ms-0">
<Button
variant="outline"
className="hidden size-9 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeftIcon className="size-5" />
</Button>
<Button
variant="outline"
className="size-9"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="size-5" />
</Button>
<Button
variant="outline"
className="size-9"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="size-5" />
</Button>
<Button
variant="outline"
className="hidden size-9 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRightIcon className="size-5" />
</Button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,97 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { redirectPage } from "@nanostores/router"
import clsx from "clsx"
import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from "lucide-react"
import { useState } from "react"
import { $router } from "@/components/router"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/use-toast"
import { isAdmin, pb } from "@/lib/api"
export default function ConfigYaml() {
const [configContent, setConfigContent] = useState<string>("")
const [isLoading, setIsLoading] = useState(false)
const ButtonIcon = isLoading ? LoaderCircleIcon : FileSlidersIcon
async function fetchConfig() {
try {
setIsLoading(true)
const { config } = await pb.send<{ config: string }>("/api/beszel/config-yaml", {})
setConfigContent(config)
} catch (error: any) {
toast({
title: t`Error`,
description: error.message,
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
if (!isAdmin()) {
redirectPage($router, "settings", { name: "general" })
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>YAML Configuration</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Export your current systems configuration.</Trans>
</p>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<div className="mb-4">
<p className="text-sm text-muted-foreground leading-relaxed my-1">
<Trans>
Systems may be managed in a <code className="bg-muted rounded-sm px-1 text-primary">config.yml</code> file
inside your data directory.
</Trans>
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
On each restart, systems in the database will be updated to match the systems defined in the file.
</Trans>
</p>
<Alert className="my-4 border-destructive text-destructive w-auto table md:pe-6">
<AlertCircleIcon className="size-4.5 stroke-destructive" />
<AlertTitle>
<Trans>Caution - potential data loss</Trans>
</AlertTitle>
<AlertDescription>
<p>
<Trans>
Existing systems not defined in <code>config.yml</code> will be deleted. Please make regular backups.
</Trans>
</p>
</AlertDescription>
</Alert>
</div>
{configContent && (
<Textarea
dir="ltr"
autoFocus
defaultValue={configContent}
spellCheck="false"
rows={Math.min(25, configContent.split("\n").length)}
className="font-mono whitespace-pre"
/>
)}
</div>
<Separator className="my-5" />
<Button type="button" className="mt-2 flex items-center gap-1" onClick={fetchConfig} disabled={isLoading}>
<ButtonIcon className={clsx("h-4 w-4 me-0.5", isLoading && "animate-spin")} />
<Trans>Export configuration</Trans>
</Button>
</div>
)
}
@@ -0,0 +1,289 @@
/** biome-ignore-all lint/correctness/useUniqueElementIds: component is only rendered once */
import { Trans, useLingui } from "@lingui/react/macro"
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
import { useState } from "react"
import { useStore } from "@nanostores/react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import Slider from "@/components/ui/slider"
import { HourFormat, Unit } from "@/lib/enums"
import { dynamicActivate } from "@/lib/i18n"
import languages from "@/lib/languages"
import { $userSettings, defaultLayoutWidth } from "@/lib/stores"
import { chartTimeData, currentHour12 } from "@/lib/utils"
import type { UserSettings } from "@/types"
import { saveSettings } from "./layout"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false)
const { i18n } = useLingui()
const currentUserSettings = useStore($userSettings)
const layoutWidth = currentUserSettings.layoutWidth ?? defaultLayoutWidth
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Partial<UserSettings>
await saveSettings(data)
setIsLoading(false)
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>General</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Change general application options.</Trans>
</p>
</div>
<Separator className="my-4" />
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-2">
<div className="mb-2">
<h3 className="mb-1 text-lg font-medium flex items-center gap-2">
<LanguagesIcon className="h-4 w-4" />
<Trans>Language</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Want to help improve our translations? Check{" "}
<a href="https://crowdin.com/project/beszel" className="link" target="_blank" rel="noopener noreferrer">
Crowdin
</a>{" "}
for details.
</Trans>
</p>
</div>
<Label className="block" htmlFor="lang">
<Trans>Preferred Language</Trans>
</Label>
<Select value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
<SelectTrigger id="lang">
<SelectValue />
</SelectTrigger>
<SelectContent>
{languages.map(([lang, label, e]) => (
<SelectItem key={lang} value={lang}>
<span className="me-2.5">
{e || (
<code
aria-hidden="true"
className="font-mono bg-muted text-[.65em] w-5 h-4 inline-grid place-items-center"
>
{lang}
</code>
)}
</span>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
<div className="grid gap-2">
<div className="mb-2">
<h3 className="mb-1 text-lg font-medium">
<Trans>Layout width</Trans>
</h3>
<Label htmlFor="layoutWidth" className="text-sm text-muted-foreground leading-relaxed">
<Trans>Adjust the width of the main layout</Trans> ({layoutWidth}px)
</Label>
</div>
<Slider
id="layoutWidth"
name="layoutWidth"
value={[layoutWidth]}
onValueChange={(val) => $userSettings.setKey("layoutWidth", val[0])}
min={1000}
max={2000}
step={10}
className="w-full mb-1"
/>
</div>
<Separator />
<div className="grid gap-2">
<div className="mb-2">
<h3 className="mb-1 text-lg font-medium">
<Trans>Chart options</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Adjust display options for charts.</Trans>
</p>
</div>
<div className="grid sm:grid-cols-3 gap-4">
<div className="grid gap-2">
<Label className="block" htmlFor="chartTime">
<Trans>Default time period</Trans>
</Label>
<Select name="chartTime" key={userSettings.chartTime} defaultValue={userSettings.chartTime}>
<SelectTrigger id="chartTime">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(chartTimeData).map(([value, { label }]) => (
<SelectItem key={value} value={value}>
{label()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="block" htmlFor="hourFormat">
<Trans>Time format</Trans>
</Label>
<Select
name="hourFormat"
key={userSettings.hourFormat}
defaultValue={userSettings.hourFormat ?? (currentHour12() ? HourFormat["12h"] : HourFormat["24h"])}
>
<SelectTrigger id="hourFormat">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(HourFormat).map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
<div className="grid gap-2">
<div className="mb-2">
<h3 className="mb-1 text-lg font-medium">
<Trans comment="Temperature / network units">Unit preferences</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Change display units for metrics.</Trans>
</p>
</div>
<div className="grid sm:grid-cols-3 gap-4">
<div className="grid gap-2">
<Label className="block" htmlFor="unitTemp">
<Trans>Temperature unit</Trans>
</Label>
<Select
name="unitTemp"
key={userSettings.unitTemp}
defaultValue={userSettings.unitTemp?.toString() || String(Unit.Celsius)}
>
<SelectTrigger id="unitTemp">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(Unit.Celsius)}>
<Trans>Celsius (°C)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Fahrenheit)}>
<Trans>Fahrenheit (°F)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="block" htmlFor="unitNet">
<Trans comment="Context: Bytes or bits">Network unit</Trans>
</Label>
<Select
name="unitNet"
key={userSettings.unitNet}
defaultValue={userSettings.unitNet?.toString() ?? String(Unit.Bytes)}
>
<SelectTrigger id="unitNet">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(Unit.Bytes)}>
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Bits)}>
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="block" htmlFor="unitDisk">
<Trans>Disk unit</Trans>
</Label>
<Select
name="unitDisk"
key={userSettings.unitDisk}
defaultValue={userSettings.unitDisk?.toString() ?? String(Unit.Bytes)}
>
<SelectTrigger id="unitDisk">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(Unit.Bytes)}>
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Bits)}>
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
<div className="grid gap-2">
<div className="mb-2">
<h3 className="mb-1 text-lg font-medium">
<Trans>Warning thresholds</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Set percentage thresholds for meter colors.</Trans>
</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 items-end">
<div className="grid gap-2">
<Label htmlFor="colorWarn">
<Trans>Warning (%)</Trans>
</Label>
<Input
id="colorWarn"
name="colorWarn"
type="number"
min={1}
max={100}
className="min-w-24"
defaultValue={userSettings.colorWarn ?? 65}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="colorCrit">
<Trans>Critical (%)</Trans>
</Label>
<Input
id="colorCrit"
name="colorCrit"
type="number"
min={1}
max={100}
className="min-w-24"
defaultValue={userSettings.colorCrit ?? 90}
/>
</div>
</div>
</div>
<Separator />
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
<Trans>Save Settings</Trans>
</Button>
</form>
</div>
)
}
@@ -0,0 +1,219 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { redirectPage } from "@nanostores/router"
import { LoaderCircleIcon, SendIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { $router } from "@/components/router"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { toast } from "@/components/ui/use-toast"
import { isAdmin, pb } from "@/lib/api"
import { cn } from "@/lib/utils"
interface HeartbeatStatus {
enabled: boolean
url?: string
interval?: number
method?: string
msg?: string
}
export default function HeartbeatSettings() {
const [status, setStatus] = useState<HeartbeatStatus | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isTesting, setIsTesting] = useState(false)
if (!isAdmin()) {
redirectPage($router, "settings", { name: "general" })
}
useEffect(() => {
fetchStatus()
}, [])
async function fetchStatus() {
try {
setIsLoading(true)
const res = await pb.send<HeartbeatStatus>("/api/beszel/heartbeat-status", {})
setStatus(res)
} catch (error: unknown) {
toast({
title: t`Error`,
description: (error as Error).message,
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
async function sendTestHeartbeat() {
setIsTesting(true)
try {
const res = await pb.send<{ err: string | false }>("/api/beszel/test-heartbeat", {
method: "POST",
})
if ("err" in res && !res.err) {
toast({
title: t`Heartbeat sent successfully`,
description: t`Check your monitoring service`,
})
} else {
toast({
title: t`Error`,
description: (res.err as string) ?? t`Failed to send heartbeat`,
variant: "destructive",
})
}
} catch (error: unknown) {
toast({
title: t`Error`,
description: (error as Error).message,
variant: "destructive",
})
} finally {
setIsTesting(false)
}
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>Heartbeat Monitoring</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it
to the internet.
</Trans>
</p>
</div>
<Separator className="my-4" />
{status?.enabled ? (
<EnabledState status={status} isTesting={isTesting} sendTestHeartbeat={sendTestHeartbeat} />
) : (
<NotEnabledState isLoading={isLoading} />
)}
</div>
)
}
function EnabledState({
status,
isTesting,
sendTestHeartbeat,
}: {
status: HeartbeatStatus
isTesting: boolean
sendTestHeartbeat: () => void
}) {
const TestIcon = isTesting ? LoaderCircleIcon : SendIcon
return (
<div className="space-y-5">
<div className="flex items-center gap-2">
<Badge variant="success">
<Trans>Active</Trans>
</Badge>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<ConfigItem label={t`Endpoint URL`} value={status.url ?? ""} mono />
<ConfigItem label={t`Interval`} value={`${status.interval}s`} />
<ConfigItem label={t`HTTP Method`} value={status.method ?? "POST"} />
</div>
<Separator />
<div>
<h4 className="text-base font-medium mb-1">
<Trans>Test heartbeat</Trans>
</h4>
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
<Trans>Send a single heartbeat ping to verify your endpoint is working.</Trans>
</p>
<Button
type="button"
variant="outline"
className="flex items-center gap-1.5"
onClick={sendTestHeartbeat}
disabled={isTesting}
>
<TestIcon className={cn("size-4", isTesting && "animate-spin")} />
<Trans>Send test heartbeat</Trans>
</Button>
</div>
<Separator />
<div>
<h4 className="text-base font-medium mb-2">
<Trans>Payload format</Trans>
</h4>
<p className="text-sm text-muted-foreground leading-relaxed mb-2">
<Trans>
When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems,
and triggered alerts.
</Trans>
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
The overall status is <code className="bg-muted rounded-sm px-1 text-primary">ok</code> when all systems are
up, <code className="bg-muted rounded-sm px-1 text-primary">warn</code> when alerts are triggered, and{" "}
<code className="bg-muted rounded-sm px-1 text-primary">error</code> when any system is down.
</Trans>
</p>
</div>
</div>
)
}
function NotEnabledState({ isLoading }: { isLoading?: boolean }) {
return (
<div className={cn("grid gap-4", isLoading && "animate-pulse")}>
<div>
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
<Trans>Set the following environment variables on your Beszel hub to enable heartbeat monitoring:</Trans>
</p>
<div className="grid gap-2.5">
<EnvVarItem
name="HEARTBEAT_URL"
description={t`Endpoint URL to ping (required)`}
example="https://uptime.betterstack.com/api/v1/heartbeat/xxxx"
/>
<EnvVarItem name="HEARTBEAT_INTERVAL" description={t`Seconds between pings (default: 60)`} example="60" />
<EnvVarItem
name="HEARTBEAT_METHOD"
description={t`HTTP method: POST, GET, or HEAD (default: POST)`}
example="POST"
/>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>After setting the environment variables, restart your Beszel hub for changes to take effect.</Trans>
</p>
</div>
)
}
function ConfigItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<p className="text-sm font-medium mb-0.5">{label}</p>
<p className={cn("text-sm text-muted-foreground break-all", mono && "font-mono")}>{value}</p>
</div>
)
}
function EnvVarItem({ name, description, example }: { name: string; description: string; example: string }) {
return (
<div className="bg-muted/50 rounded-md px-3 py-2.5 grid gap-1.5">
<code className="text-sm font-mono text-primary font-medium leading-tight">{name}</code>
<p className="text-sm text-muted-foreground">{description}</p>
<p className="text-xs text-muted-foreground">
<Trans>Example:</Trans> <code className="font-mono">{example}</code>
</p>
</div>
)
}
@@ -0,0 +1,163 @@
import { t } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath, redirectPage } from "@nanostores/router"
import {
AlertOctagonIcon,
BellIcon,
FileSlidersIcon,
FingerprintIcon,
HeartPulseIcon,
SettingsIcon,
} from "lucide-react"
import { lazy, useEffect } from "react"
import { $router } from "@/components/router.tsx"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import { toast } from "@/components/ui/use-toast.ts"
import { pb } from "@/lib/api"
import { $userSettings } from "@/lib/stores.ts"
import type { UserSettings } from "@/types"
import { Separator } from "../../ui/separator"
import { SidebarNav } from "./sidebar-nav.tsx"
const generalSettingsImport = () => import("./general.tsx")
const notificationsSettingsImport = () => import("./notifications.tsx")
const configYamlSettingsImport = () => import("./config-yaml.tsx")
const fingerprintsSettingsImport = () => import("./tokens-fingerprints.tsx")
const alertsHistoryDataTableSettingsImport = () => import("./alerts-history-data-table.tsx")
const heartbeatSettingsImport = () => import("./heartbeat.tsx")
const GeneralSettings = lazy(generalSettingsImport)
const NotificationsSettings = lazy(notificationsSettingsImport)
const ConfigYamlSettings = lazy(configYamlSettingsImport)
const FingerprintsSettings = lazy(fingerprintsSettingsImport)
const AlertsHistoryDataTableSettings = lazy(alertsHistoryDataTableSettingsImport)
const HeartbeatSettings = lazy(heartbeatSettingsImport)
export async function saveSettings(newSettings: Partial<UserSettings>) {
try {
// get fresh copy of settings
const req = await pb.collection("user_settings").getFirstListItem("", {
fields: "id,settings",
})
// update user settings
const updatedSettings = await pb.collection("user_settings").update(req.id, {
settings: {
...req.settings,
...newSettings,
},
})
$userSettings.set(updatedSettings.settings)
toast({
title: t`Settings saved`,
description: t`Your user settings have been updated.`,
})
} catch (e) {
// console.error('update settings', e)
toast({
title: t`Failed to save settings`,
description: t`Check logs for more details.`,
variant: "destructive",
})
}
}
export default function SettingsLayout() {
const { t } = useLingui()
const sidebarNavItems = [
{
title: t({ message: `General`, comment: "Context: General settings" }),
href: getPagePath($router, "settings", { name: "general" }),
icon: SettingsIcon,
},
{
title: t`Notifications`,
href: getPagePath($router, "settings", { name: "notifications" }),
icon: BellIcon,
preload: notificationsSettingsImport,
},
{
title: t`Tokens & Fingerprints`,
href: getPagePath($router, "settings", { name: "tokens" }),
icon: FingerprintIcon,
noReadOnly: true,
preload: fingerprintsSettingsImport,
},
{
title: t`Alert History`,
href: getPagePath($router, "settings", { name: "alert-history" }),
icon: AlertOctagonIcon,
preload: alertsHistoryDataTableSettingsImport,
},
{
title: t`Heartbeat`,
href: getPagePath($router, "settings", { name: "heartbeat" }),
icon: HeartPulseIcon,
admin: true,
preload: heartbeatSettingsImport,
},
{
title: t`YAML Config`,
href: getPagePath($router, "settings", { name: "config" }),
icon: FileSlidersIcon,
admin: true,
preload: configYamlSettingsImport,
},
]
const page = useStore($router)
// biome-ignore lint/correctness/useExhaustiveDependencies: no dependencies
useEffect(() => {
document.title = `${t`Settings`} / Beszel`
// @ts-expect-error redirect to account page if no page is specified
if (!page?.params?.name) {
redirectPage($router, "settings", { name: "general" })
}
}, [])
return (
<Card className="pt-5 px-4 pb-8 min-h-96 mb-14 sm:pt-6 sm:px-7">
<CardHeader className="p-0">
<CardTitle className="mb-1">
<Trans>Settings</Trans>
</CardTitle>
<CardDescription>
<Trans>Manage display and notification preferences.</Trans>
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Separator className="hidden md:block my-5" />
<div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-12">
<aside className="md:max-w-52 min-w-40">
<SidebarNav items={sidebarNavItems} />
</aside>
<div className="flex-1 min-w-0">
{/* @ts-ignore */}
<SettingsContent name={page?.params?.name ?? "general"} />
</div>
</div>
</CardContent>
</Card>
)
}
function SettingsContent({ name }: { name: string }) {
const userSettings = useStore($userSettings)
switch (name) {
case "general":
return <GeneralSettings userSettings={userSettings} />
case "notifications":
return <NotificationsSettings userSettings={userSettings} />
case "config":
return <ConfigYamlSettings />
case "tokens":
return <FingerprintsSettings />
case "alert-history":
return <AlertsHistoryDataTableSettings />
case "heartbeat":
return <HeartbeatSettings />
}
}
@@ -0,0 +1,239 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react"
import { type ChangeEventHandler, useEffect, useState } from "react"
import * as v from "valibot"
import { prependBasePath } from "@/components/router"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { InputTags } from "@/components/ui/input-tags"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { toast } from "@/components/ui/use-toast"
import { isAdmin, pb } from "@/lib/api"
import type { UserSettings } from "@/types"
import { saveSettings } from "./layout"
import { QuietHours } from "./quiet-hours"
import type { ClientResponseError } from "pocketbase"
interface ShoutrrrUrlCardProps {
url: string
onUrlChange: ChangeEventHandler<HTMLInputElement>
onRemove: () => void
}
const NotificationSchema = v.object({
emails: v.array(v.pipe(v.string(), v.rfcEmail())),
webhooks: v.array(v.pipe(v.string(), v.url())),
})
const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSettings }) => {
const [webhooks, setWebhooks] = useState(userSettings.webhooks ?? [])
const [emails, setEmails] = useState<string[]>(userSettings.emails ?? [])
const [isLoading, setIsLoading] = useState(false)
// update values when userSettings changes
useEffect(() => {
setWebhooks(userSettings.webhooks ?? [])
setEmails(userSettings.emails ?? [])
}, [userSettings])
function addWebhook() {
setWebhooks([...webhooks, ""])
// focus on the new input
queueMicrotask(() => {
const inputs = document.querySelectorAll("#webhooks input") as NodeListOf<HTMLInputElement>
inputs[inputs.length - 1]?.focus()
})
}
const removeWebhook = (index: number) => setWebhooks(webhooks.filter((_, i) => i !== index))
function updateWebhook(index: number, value: string) {
const newWebhooks = [...webhooks]
newWebhooks[index] = value
setWebhooks(newWebhooks)
}
async function updateSettings() {
setIsLoading(true)
try {
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
await saveSettings(parsedData)
} catch (e: unknown) {
toast({
title: t`Failed to save settings`,
description: (e as Error).message,
variant: "destructive",
})
}
setIsLoading(false)
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>Notifications</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Configure how you receive alert notifications.</Trans>
</p>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed">
<Trans>
Looking instead for where to create alerts? Click the bell <BellIcon className="inline h-4 w-4" /> icons in
the systems table.
</Trans>
</p>
</div>
<Separator className="my-4" />
<div className="space-y-5">
<div className="grid gap-2">
<div className="mb-2">
<h3 className="mb-1 text-lg font-medium">
<Trans>Email notifications</Trans>
</h3>
{isAdmin() && (
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Please{" "}
<a href={prependBasePath("/_/#/settings/mail")} className="link" target="_blank">
configure an SMTP server
</a>{" "}
to ensure alerts are delivered.
</Trans>
</p>
)}
</div>
<Label className="block" htmlFor="email">
<Trans>To email(s)</Trans>
</Label>
<InputTags
value={emails}
onChange={setEmails}
placeholder={t`Enter email address...`}
className="w-full"
type="email"
id="email"
/>
<p className="text-[0.8rem] text-muted-foreground">
<Trans>Save address using enter key or comma. Leave blank to disable email notifications.</Trans>
</p>
</div>
<Separator />
<div className="space-y-3">
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4">
<div>
<h3 className="mb-1 text-lg font-medium">
<Trans>Webhook / Push notifications</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Beszel uses{" "}
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
Shoutrrr
</a>{" "}
to integrate with popular notification services.
</Trans>
</p>
</div>
<Button type="button" variant="outline" className="h-10 shrink-0" onClick={addWebhook}>
<PlusIcon className="size-4" />
<span className="ms-1">
<Trans>Add URL</Trans>
</span>
</Button>
</div>
{webhooks.length > 0 && (
<div className="grid gap-2.5" id="webhooks">
{webhooks.map((webhook, index) => (
<ShoutrrrUrlCard
key={index}
url={webhook}
onUrlChange={(e: React.ChangeEvent<HTMLInputElement>) => updateWebhook(index, e.target.value)}
onRemove={() => removeWebhook(index)}
/>
))}
</div>
)}
</div>
<Separator />
<div className="space-y-3">
<QuietHours />
</div>
<Separator />
<Button
type="button"
className="flex items-center gap-1.5 disabled:opacity-100"
onClick={updateSettings}
disabled={isLoading}
>
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
<Trans>Save Settings</Trans>
</Button>
</div>
</div>
)
}
function showTestNotificationError(msg: string) {
toast({
title: t`Error`,
description: msg ?? t`Failed to send test notification`,
variant: "destructive",
})
}
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
const [isLoading, setIsLoading] = useState(false)
const sendTestNotification = async () => {
setIsLoading(true)
try {
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
if ("err" in res && !res.err) {
toast({
title: t`Test notification sent`,
description: t`Check your notification service`,
})
} else {
showTestNotificationError(res.err)
}
} catch (e: unknown) {
showTestNotificationError((e as ClientResponseError).data?.message)
} finally {
setIsLoading(false)
}
}
return (
<Card className="bg-table-header p-2 md:p-3">
<div className="flex items-center gap-1">
<Input
type="url"
className="light:bg-card"
required
placeholder="generic://webhook.site/xxxxxx"
value={url}
onChange={onUrlChange}
/>
<Button type="button" variant="outline" disabled={isLoading || url === ""} onClick={sendTestNotification}>
{isLoading ? (
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
) : (
<span>
<Trans>
Test <span className="hidden sm:inline">URL</span>
</Trans>
</span>
)}
</Button>
<Button type="button" variant="outline" size="icon" className="shrink-0" aria-label="Delete" onClick={onRemove}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
</Card>
)
}
export default SettingsNotificationsPage
@@ -0,0 +1,535 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import {
MoreHorizontalIcon,
PlusIcon,
Trash2Icon,
ServerIcon,
ClockIcon,
CalendarIcon,
ActivityIcon,
PenSquareIcon,
} from "lucide-react"
import { useEffect, useState } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useToast } from "@/components/ui/use-toast"
import { pb } from "@/lib/api"
import { $systems } from "@/lib/stores"
import { formatShortDate } from "@/lib/utils"
import type { QuietHoursRecord, SystemRecord } from "@/types"
const quietHoursTranslation = t`Quiet Hours`
export function QuietHours() {
const [data, setData] = useState<QuietHoursRecord[]>([])
const [dialogOpen, setDialogOpen] = useState(false)
const [editingRecord, setEditingRecord] = useState<QuietHoursRecord | null>(null)
const { toast } = useToast()
const systems = useStore($systems)
useEffect(() => {
let unsubscribe: (() => void) | undefined
const pbOptions = {
expand: "system",
fields: "id,user,system,type,start,end,expand.system.name",
}
// Initial load
pb.collection<QuietHoursRecord>("quiet_hours")
.getList(0, 200, {
...pbOptions,
sort: "system",
})
.then(({ items }) => setData(items))
// Subscribe to changes
;(async () => {
unsubscribe = await pb.collection("quiet_hours").subscribe(
"*",
(e) => {
if (e.action === "create") {
setData((current) => [e.record as QuietHoursRecord, ...current])
}
if (e.action === "update") {
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r)))
}
if (e.action === "delete") {
setData((current) => current.filter((r) => r.id !== e.record.id))
}
},
pbOptions
)
})()
// Unsubscribe on unmount
return () => unsubscribe?.()
}, [])
const handleDelete = async (id: string) => {
try {
await pb.collection("quiet_hours").delete(id)
} catch (e: unknown) {
toast({
variant: "destructive",
title: t`Error`,
description: (e as Error).message || "Failed to delete quiet hours.",
})
}
}
const openEditDialog = (record: QuietHoursRecord) => {
setEditingRecord(record)
setDialogOpen(true)
}
const closeDialog = () => {
setDialogOpen(false)
setEditingRecord(null)
}
const formatDateTime = (record: QuietHoursRecord) => {
if (record.type === "daily") {
// For daily windows, show only time
const startTime = new Date(record.start).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
const endTime = new Date(record.end).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
return `${startTime} - ${endTime}`
}
// For one-time windows, show full date and time
const start = formatShortDate(record.start)
const end = formatShortDate(record.end)
return `${start} - ${end}`
}
const getWindowState = (record: QuietHoursRecord): "active" | "past" | "inactive" => {
const now = new Date()
if (record.type === "daily") {
// For daily windows, check if current time is within the window
const startDate = new Date(record.start)
const endDate = new Date(record.end)
// Get current time in local timezone
const currentMinutes = now.getHours() * 60 + now.getMinutes()
const startMinutes = startDate.getUTCHours() * 60 + startDate.getUTCMinutes()
const endMinutes = endDate.getUTCHours() * 60 + endDate.getUTCMinutes()
// Convert UTC to local time using the stored date's offset, not the current date's offset
// This avoids DST mismatch when records were saved in a different DST period
const localStartMinutes = (startMinutes - startDate.getTimezoneOffset() + 1440) % 1440
const localEndMinutes = (endMinutes - endDate.getTimezoneOffset() + 1440) % 1440
// Handle cases where window spans midnight
if (localStartMinutes <= localEndMinutes) {
return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "inactive"
} else {
return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "inactive"
}
} else {
// For one-time windows
const startDate = new Date(record.start)
const endDate = new Date(record.end)
if (now >= startDate && now < endDate) {
return "active"
} else if (now >= endDate) {
return "past"
} else {
return "inactive"
}
}
}
return (
<>
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4 mb-3">
<div>
<h3 className="mb-1 text-lg font-medium">{quietHoursTranslation}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Schedule quiet hours where notifications will not be sent, such as during maintenance periods.
</Trans>
</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="h-10 shrink-0" onClick={() => setEditingRecord(null)}>
<PlusIcon className="size-4" />
<span className="ms-1">
<Trans>Add {{ foo: quietHoursTranslation }}</Trans>
</span>
</Button>
</DialogTrigger>
<QuietHoursDialog editingRecord={editingRecord} systems={systems} onClose={closeDialog} toast={toast} />
</Dialog>
</div>
{data.length > 0 && (
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
<Table>
<TableHeader>
<TableRow className="border-border/50">
<TableHead className="px-4">
<span className="flex items-center gap-2">
<ServerIcon className="size-4" />
<Trans>System</Trans>
</span>
</TableHead>
<TableHead className="px-4">
<span className="flex items-center gap-2">
<ClockIcon className="size-4" />
<Trans>Type</Trans>
</span>
</TableHead>
<TableHead className="px-4">
<span className="flex items-center gap-2">
<CalendarIcon className="size-4" />
<Trans>Schedule</Trans>
</span>
</TableHead>
<TableHead className="px-4">
<span className="flex items-center gap-2">
<ActivityIcon className="size-4" />
<Trans>State</Trans>
</span>
</TableHead>
<TableHead className="px-4 text-right sr-only">
<Trans>Actions</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((record) => (
<TableRow key={record.id}>
<TableCell className="px-4 py-3">
{record.system ? record.expand?.system?.name || record.system : <Trans>All Systems</Trans>}
</TableCell>
<TableCell className="px-4 py-3">
{record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>}
</TableCell>
<TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell>
<TableCell className="px-4 py-3">
{(() => {
const state = getWindowState(record)
const stateConfig = {
active: { label: <Trans>Active</Trans>, variant: "success" as const },
past: { label: <Trans>Past</Trans>, variant: "danger" as const },
inactive: { label: <Trans>Inactive</Trans>, variant: "default" as const },
}
const config = stateConfig[state]
return <Badge variant={config.variant}>{config.label}</Badge>
})()}
</TableCell>
<TableCell className="px-4 py-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEditDialog(record)}>
<PenSquareIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleDelete(record.id)}>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</>
)
}
// Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time
function formatDateTimeLocal(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
return `${year}-${month}-${day}T${hours}:${minutes}`
}
function QuietHoursDialog({
editingRecord,
systems,
onClose,
toast,
}: {
editingRecord: QuietHoursRecord | null
systems: SystemRecord[]
onClose: () => void
toast: ReturnType<typeof useToast>["toast"]
}) {
const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "")
const [isGlobal, setIsGlobal] = useState(!editingRecord?.system)
const [windowType, setWindowType] = useState<"one-time" | "daily">(editingRecord?.type || "one-time")
const [startDateTime, setStartDateTime] = useState("")
const [endDateTime, setEndDateTime] = useState("")
const [startTime, setStartTime] = useState("")
const [endTime, setEndTime] = useState("")
useEffect(() => {
if (editingRecord) {
setSelectedSystem(editingRecord.system || "")
setIsGlobal(!editingRecord.system)
setWindowType(editingRecord.type)
if (editingRecord.type === "daily") {
// Extract time from datetime
const start = new Date(editingRecord.start)
const end = editingRecord.end ? new Date(editingRecord.end) : null
setStartTime(start.toTimeString().slice(0, 5))
setEndTime(end ? end.toTimeString().slice(0, 5) : "")
} else {
// For one-time, format as datetime-local (local time, not UTC)
const startDate = new Date(editingRecord.start)
const endDate = editingRecord.end ? new Date(editingRecord.end) : null
setStartDateTime(formatDateTimeLocal(startDate))
setEndDateTime(endDate ? formatDateTimeLocal(endDate) : "")
}
} else {
// Reset form with default dates: today at 12pm and 1pm
const today = new Date()
const noon = new Date(today)
noon.setHours(12, 0, 0, 0)
const onePm = new Date(today)
onePm.setHours(13, 0, 0, 0)
setSelectedSystem("")
setIsGlobal(true)
setWindowType("one-time")
setStartDateTime(formatDateTimeLocal(noon))
setEndDateTime(formatDateTimeLocal(onePm))
setStartTime("12:00")
setEndTime("13:00")
}
}, [editingRecord])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
let startValue: string
let endValue: string | undefined
if (windowType === "daily") {
// For daily windows, convert local time to UTC
// Use today's date so the current DST offset is applied (not a fixed historical date)
const today = new Date().toISOString().split("T")[0]
const startDate = new Date(`${today}T${startTime}:00`)
startValue = startDate.toISOString()
if (endTime) {
const endDate = new Date(`${today}T${endTime}:00`)
endValue = endDate.toISOString()
}
} else {
// For one-time windows, use the datetime values
startValue = new Date(startDateTime).toISOString()
endValue = endDateTime ? new Date(endDateTime).toISOString() : undefined
}
const data = {
user: pb.authStore.record?.id,
system: isGlobal ? undefined : selectedSystem,
type: windowType,
start: startValue,
end: endValue,
}
if (editingRecord) {
await pb.collection("quiet_hours").update(editingRecord.id, data)
} else {
await pb.collection("quiet_hours").create(data)
}
onClose()
} catch (e) {
toast({
variant: "destructive",
title: t`Error`,
description: t`Failed to save settings`,
})
}
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingRecord ? (
<Trans>Edit {{ foo: quietHoursTranslation }}</Trans>
) : (
<Trans>Add {{ foo: quietHoursTranslation }}</Trans>
)}
</DialogTitle>
<DialogDescription>
<Trans>Schedule quiet hours where notifications will not be sent.</Trans>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="global">
<Trans>Global</Trans>
</TabsTrigger>
<TabsTrigger value="system">
<Trans>System</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-4 space-y-4">
<div className="grid gap-2">
<Label htmlFor="system">
<Trans>System</Trans>
</Label>
<Select value={selectedSystem} onValueChange={setSelectedSystem}>
<SelectTrigger id="system">
<SelectValue placeholder={t`Select ${{ foo: t`System`.toLocaleLowerCase() }}`} />
</SelectTrigger>
<SelectContent>
{systems.map((system) => (
<SelectItem key={system.id} value={system.id}>
{system.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Hidden input for native form validation */}
<input
className="sr-only"
type="text"
tabIndex={-1}
autoComplete="off"
value={selectedSystem}
onChange={() => {}}
required={!isGlobal}
/>
</div>
</TabsContent>
</Tabs>
<div className="grid gap-2">
<Label htmlFor="type">
<Trans>Type</Trans>
</Label>
<Select value={windowType} onValueChange={(value: "one-time" | "daily") => setWindowType(value)}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="one-time">
<Trans>One-time</Trans>
</SelectItem>
<SelectItem value="daily">
<Trans>Daily</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
{windowType === "one-time" ? (
<>
<div className="grid gap-2">
<Label htmlFor="start-datetime">
<Trans>Start Time</Trans>
</Label>
<Input
id="start-datetime"
type="datetime-local"
value={startDateTime}
onChange={(e) => setStartDateTime(e.target.value)}
min={formatDateTimeLocal(new Date(new Date().setHours(0, 0, 0, 0)))}
required
className="tabular-nums tracking-tighter"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="end-datetime">
<Trans>End Time</Trans>
</Label>
<Input
id="end-datetime"
type="datetime-local"
value={endDateTime}
onChange={(e) => setEndDateTime(e.target.value)}
min={startDateTime || formatDateTimeLocal(new Date())}
required
className="tabular-nums tracking-tighter"
/>
</div>
</>
) : (
<div className="grid gap-2 grid-cols-2">
<div>
<Label htmlFor="start-time">
<Trans>Start Time</Trans>
</Label>
<Input
className="tabular-nums tracking-tighter"
id="start-time"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="end-time">
<Trans>End Time</Trans>
</Label>
<Input
className="tabular-nums tracking-tighter"
id="end-time"
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
required
/>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">{editingRecord ? <Trans>Update</Trans> : <Trans>Create</Trans>}</Button>
</DialogFooter>
</form>
</DialogContent>
)
}
@@ -0,0 +1,74 @@
import { useStore } from "@nanostores/react"
import type React from "react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { isAdmin, isReadOnlyUser } from "@/lib/api"
import { cn } from "@/lib/utils"
import { $router, Link, navigate } from "../../router"
import { buttonVariants } from "../../ui/button"
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string
title: string
icon?: React.FC<React.SVGProps<SVGSVGElement>>
admin?: boolean
noReadOnly?: boolean
preload?: () => Promise<{ default: React.ComponentType<any> }>
}[]
}
export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
const page = useStore($router)
return (
<>
{/* Mobile View */}
<div className="md:hidden">
<Select onValueChange={navigate} value={page?.path}>
<SelectTrigger className="w-full my-3.5">
<SelectValue placeholder="Select page" />
</SelectTrigger>
<SelectContent>
{items.map((item) => {
if (item.admin && !isAdmin()) return null
return (
<SelectItem key={item.href} value={item.href}>
<span className="flex items-center gap-2 truncate">
{item.icon && <item.icon className="size-4" />}
<span className="truncate">{item.title}</span>
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
<Separator />
</div>
{/* Desktop View */}
<nav className={cn("hidden md:grid gap-1 sticky top-6", className)} {...props}>
{items.map((item) => {
if ((item.admin && !isAdmin()) || (item.noReadOnly && isReadOnlyUser())) {
return null
}
return (
<Link
onMouseEnter={() => item.preload?.()}
key={item.href}
href={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
"flex items-center gap-3 justify-start truncate duration-50",
page?.path === item.href ? "bg-muted hover:bg-accent/70" : "hover:bg-accent/50"
)}
>
{item.icon && <item.icon className="size-4 shrink-0" />}
<span className="truncate">{item.title}</span>
</Link>
)
})}
</nav>
</>
)
}
@@ -0,0 +1,417 @@
import { t } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { redirectPage } from "@nanostores/router"
import {
CopyIcon,
ExternalLinkIcon,
FingerprintIcon,
KeyIcon,
MoreHorizontalIcon,
RotateCwIcon,
ServerIcon,
Trash2Icon,
} from "lucide-react"
import { memo, useEffect, useMemo, useState } from "react"
import {
copyDockerCompose,
copyDockerRun,
copyLinuxCommand,
copyWindowsCommand,
type DropdownItem,
InstallDropdown,
} from "@/components/install-dropdowns"
import { $router } from "@/components/router"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/use-toast"
import { isReadOnlyUser, pb } from "@/lib/api"
import { $publicKey } from "@/lib/stores"
import { cn, copyToClipboard, generateToken, getHubURL, tokenMap } from "@/lib/utils"
import type { FingerprintRecord } from "@/types"
const pbFingerprintOptions = {
expand: "system",
fields: "id,fingerprint,token,system,expand.system.name",
}
function sortFingerprints(fingerprints: FingerprintRecord[]) {
return fingerprints.sort((a, b) => a.expand.system.name.localeCompare(b.expand.system.name))
}
const SettingsFingerprintsPage = memo(() => {
if (isReadOnlyUser()) {
redirectPage($router, "settings", { name: "general" })
}
const [fingerprints, setFingerprints] = useState<FingerprintRecord[]>([])
// Get fingerprint records on mount
useEffect(() => {
pb.collection("fingerprints")
.getFullList<FingerprintRecord>(pbFingerprintOptions)
.then((prints) => {
setFingerprints(sortFingerprints(prints))
})
}, [])
// Subscribe to fingerprint updates
useEffect(() => {
let unsubscribe: (() => void) | undefined
;(async () => {
// subscribe to fingerprint updates
unsubscribe = await pb.collection("fingerprints").subscribe(
"*",
(res) => {
setFingerprints((currentFingerprints) => {
if (res.action === "create") {
return sortFingerprints([...currentFingerprints, res.record as FingerprintRecord])
}
if (res.action === "update") {
return currentFingerprints.map((fingerprint) => {
if (fingerprint.id === res.record.id) {
return { ...fingerprint, ...res.record } as FingerprintRecord
}
return fingerprint
})
}
if (res.action === "delete") {
return currentFingerprints.filter((fingerprint) => fingerprint.id !== res.record.id)
}
return currentFingerprints
})
},
pbFingerprintOptions
)
})()
// unsubscribe on unmount
return () => unsubscribe?.()
}, [])
// Update token map whenever fingerprints change
useEffect(() => {
for (const fingerprint of fingerprints) {
tokenMap.set(fingerprint.system, fingerprint.token)
}
}, [fingerprints])
return (
<>
<SectionIntro />
<Separator className="my-4" />
<SectionUniversalToken />
<Separator className="my-4" />
<SectionTable fingerprints={fingerprints} />
</>
)
})
const SectionIntro = memo(() => {
return (
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>Tokens & Fingerprints</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Tokens and fingerprints are used to authenticate WebSocket connections to the hub.</Trans>
</p>
<p className="text-sm text-muted-foreground leading-relaxed mt-1.5">
<Trans>
Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on
first connection.
</Trans>
</p>
</div>
)
})
const SectionUniversalToken = memo(() => {
const [token, setToken] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [checked, setChecked] = useState(false)
const [isPermanent, setIsPermanent] = useState(false)
async function updateToken(enable: number = -1, permanent: number = -1) {
// enable: 0 for disable, 1 for enable, -1 (unset) for get current state
const data = await pb.send(`/api/beszel/universal-token`, {
query: {
token,
enable,
permanent,
},
})
setToken(data.token)
setChecked(data.active)
setIsPermanent(!!data.permanent)
setIsLoading(false)
}
useEffect(() => {
updateToken()
}, [])
return (
<div>
<h3 className="text-lg font-medium mb-2">
<Trans>Universal token</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>When enabled, this token allows agents to self-register without prior system creation.</Trans>
</p>
<div className="mt-3 border rounded-md px-4 py-3 max-w-full">
{!isLoading && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4 min-w-0">
<Switch
checked={checked}
onCheckedChange={(checked) => {
// Keep current permanence preference when enabling/disabling
updateToken(checked ? 1 : 0, isPermanent ? 1 : 0)
}}
/>
<div className="min-w-0 flex-1 overflow-auto">
<span
className={cn(
"text-sm text-primary opacity-60 transition-opacity",
checked ? "opacity-100" : "select-none"
)}
>
{token}
</span>
</div>
<ActionsButtonUniversalToken token={token} checked={checked} />
</div>
{checked && (
<div className="border-t pt-3">
<div className="text-sm font-medium">
<Trans>Persistence</Trans>
</div>
<Tabs
value={isPermanent ? "permanent" : "ephemeral"}
onValueChange={(value) => updateToken(1, value === "permanent" ? 1 : 0)}
className="mt-2"
>
<TabsList>
<TabsTrigger className="xs:min-w-40" value="ephemeral">
<Trans>Ephemeral</Trans>
</TabsTrigger>
<TabsTrigger className="xs:min-w-40" value="permanent">
<Trans>Permanent</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="ephemeral" className="mt-3">
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Expires after one hour or on hub restart.</Trans>
</p>
</TabsContent>
<TabsContent value="permanent" className="mt-3">
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Saved in the database and does not expire until you disable it.</Trans>
</p>
</TabsContent>
</Tabs>
</div>
)}
</div>
)}
</div>
</div>
)
})
const ActionsButtonUniversalToken = memo(({ token, checked }: { token: string; checked: boolean }) => {
const { t } = useLingui()
const publicKey = $publicKey.get()
const port = "45876"
const dropdownItems: DropdownItem[] = [
{
text: t({ message: "Copy docker compose", context: "Button to copy docker compose file content" }),
onClick: () => copyDockerCompose(port, publicKey, token),
icons: [DockerIcon],
},
{
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
onClick: () => copyDockerRun(port, publicKey, token),
icons: [DockerIcon],
},
{
text: t`Copy Linux command`,
onClick: () => copyLinuxCommand(port, publicKey, token),
icons: [TuxIcon],
},
{
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
onClick: () => copyLinuxCommand(port, publicKey, token, true),
icons: [TuxIcon, AppleIcon],
},
{
text: t({ message: "Windows command", context: "Button to copy install command" }),
onClick: () => copyWindowsCommand(port, publicKey, token),
icons: [WindowsIcon],
},
{
text: t({ message: "FreeBSD command", context: "Button to copy install command" }),
onClick: () => copyLinuxCommand(port, publicKey, token),
icons: [FreeBsdIcon],
},
{
text: t`Manual setup instructions`,
url: "https://beszel.dev/guide/agent-installation#binary",
icons: [ExternalLinkIcon],
},
]
return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={!checked}
className={cn("transition-opacity", !checked && "opacity-50")}
>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<InstallDropdown items={dropdownItems} />
</DropdownMenu>
</div>
)
})
const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRecord[] }) => {
const { t } = useLingui()
const isReadOnly = isReadOnlyUser()
const headerCols = useMemo(
() => [
{
label: t`System`,
Icon: ServerIcon,
w: "11em",
},
{
label: t`Token`,
Icon: KeyIcon,
w: "20em",
},
{
label: t`Fingerprint`,
Icon: FingerprintIcon,
w: "20em",
},
],
[t]
)
return (
<div className="rounded-md border overflow-hidden w-full mt-4">
<Table>
<TableHeader>
<tr className="border-border/50">
{headerCols.map((col) => (
<TableHead key={col.label} style={{ minWidth: col.w }}>
<span className="flex items-center gap-2">
<col.Icon className="size-4" />
{col.label}
</span>
</TableHead>
))}
{!isReadOnly && (
<TableHead className="w-0">
<span className="sr-only">
<Trans>Actions</Trans>
</span>
</TableHead>
)}
</tr>
</TableHeader>
<TableBody className="whitespace-pre">
{fingerprints.map((fingerprint) => (
<TableRow key={fingerprint.id}>
<TableCell className="font-medium ps-5 py-2 max-w-60 truncate">
{fingerprint.expand.system.name}
</TableCell>
<TableCell className="font-mono text-[0.95em] py-2">{fingerprint.token}</TableCell>
<TableCell className="font-mono text-[0.95em] py-2">{fingerprint.fingerprint}</TableCell>
{!isReadOnly && (
<TableCell className="py-2 px-4 xl:px-2">
<ActionsButtonTable fingerprint={fingerprint} />
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
})
async function updateFingerprint(fingerprint: FingerprintRecord, rotateToken = false) {
try {
await pb.collection("fingerprints").update(fingerprint.id, {
fingerprint: "",
token: rotateToken ? generateToken() : fingerprint.token,
})
} catch (error: unknown) {
toast({
title: t`Error`,
description: (error as Error).message,
})
}
}
const ActionsButtonTable = memo(({ fingerprint }: { fingerprint: FingerprintRecord }) => {
const envVar = `HUB_URL=${getHubURL()}\nTOKEN=${fingerprint.token}`
const copyEnv = () => copyToClipboard(envVar)
const copyYaml = () => copyToClipboard(envVar.replaceAll("=", ": "))
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"} data-nolink>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={copyYaml}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy YAML</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyEnv}>
<CopyIcon className="me-2.5 size-4" />
<Trans context="Environment variables">Copy env</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => updateFingerprint(fingerprint, true)}>
<RotateCwIcon className="me-2.5 size-4" />
<Trans>Rotate token</Trans>
</DropdownMenuItem>
{fingerprint.fingerprint && (
<DropdownMenuItem onSelect={() => updateFingerprint(fingerprint)}>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete fingerprint</Trans>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
})
export default SettingsFingerprintsPage
@@ -0,0 +1,20 @@
import { useEffect } from "react"
import SmartTable from "@/components/routes/system/smart-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
export default function Smart() {
useEffect(() => {
document.title = `S.M.A.R.T. / Beszel`
}, [])
return (
<>
<div className="grid gap-4">
<ActiveAlerts />
<SmartTable />
</div>
<FooterRepoLink />
</>
)
}
@@ -0,0 +1,284 @@
import { memo, useState } from "react"
import { Trans } from "@lingui/react/macro"
import { compareSemVer, parseSemVer } from "@/lib/utils"
import type { GPUData } from "@/types"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import InfoBar from "./system/info-bar"
import { useSystemData } from "./system/use-system-data"
import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts"
import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
import { LoadAverageChart } from "./system/charts/load-average-chart"
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
import { GpuIcon } from "../ui/icons"
import SystemdTable from "../systemd-table/systemd-table"
import ContainersTable from "../containers-table/containers-table"
const SEMVER_0_14_0 = parseSemVer("0.14.0")
const SEMVER_0_15_0 = parseSemVer("0.15.0")
export default memo(function SystemDetail({ id }: { id: string }) {
const systemData = useSystemData(id)
const {
system,
systemStats,
containerData,
chartData,
containerChartConfigs,
details,
grid,
setGrid,
displayMode,
setDisplayMode,
activeTab,
setActiveTab,
mountedTabs,
tabsRef,
maxValues,
isLongerChart,
showMax,
dataEmpty,
isPodman,
lastGpus,
hasGpuData,
hasGpuEnginesData,
hasGpuPowerData,
} = systemData
// extra margin to add to bottom of page, specifically for temperature chart,
// where the tooltip can go past the bottom of the page if lots of sensors
const [pageBottomExtraMargin, setPageBottomExtraMargin] = useState(0)
if (!system.id) {
return null
}
const hasContainers = containerData.length > 0
const maybeHasSmartData = compareSemVer(chartData.agentVersion, SEMVER_0_15_0) >= 0
const hasContainersTable = hasContainers && compareSemVer(chartData.agentVersion, SEMVER_0_14_0) >= 0
const hasSystemd = system.info.sv
const hasGpu = hasGpuData || hasGpuPowerData
// keep tabsRef in sync for keyboard navigation
const tabs = ["core", "disk"]
if (hasGpu) tabs.push("gpu")
if (hasContainers) tabs.push("containers")
if (hasSystemd) tabs.push("services")
tabsRef.current = tabs
// shared chart props
const coreProps = { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues }
function defaultLayout() {
return (
<>
{/* main charts */}
<div className="grid xl:grid-cols-2 gap-4">
<CpuChart {...coreProps} />
{hasContainers && (
<ContainerCpuChart
chartData={chartData}
grid={grid}
dataEmpty={dataEmpty}
isPodman={isPodman}
cpuConfig={containerChartConfigs.cpu}
/>
)}
<MemoryChart {...coreProps} />
{hasContainers && (
<ContainerMemoryChart
chartData={chartData}
grid={grid}
dataEmpty={dataEmpty}
isPodman={isPodman}
memoryConfig={containerChartConfigs.memory}
/>
)}
<RootDiskCharts systemData={systemData} />
<BandwidthChart {...coreProps} systemStats={systemStats} />
{hasContainers && (
<ContainerNetworkChart
chartData={chartData}
grid={grid}
dataEmpty={dataEmpty}
isPodman={isPodman}
networkConfig={containerChartConfigs.network}
/>
)}
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
<TemperatureChart {...coreProps} />
<BatteryChart {...coreProps} />
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
</div>
{hasGpuData && lastGpus && (
<GpuDetailCharts
chartData={chartData}
grid={grid}
dataEmpty={dataEmpty}
lastGpus={lastGpus as Record<string, GPUData>}
hasGpuEnginesData={hasGpuEnginesData}
/>
)}
<ExtraFsCharts systemData={systemData} />
{maybeHasSmartData && <LazySmartTable systemId={system.id} />}
{hasContainersTable && <LazyContainersTable systemId={system.id} />}
{hasSystemd && <LazySystemdTable systemId={system.id} />}
</>
)
}
function tabbedLayout() {
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="contents">
<TabsList className="h-11 p-1.5 w-full shadow-xs overflow-auto justify-start">
<TabsTrigger value="core" className="w-full flex items-center gap-1.5">
<CpuIcon className="size-3.5" />
<Trans context="Core system metrics">Core</Trans>
</TabsTrigger>
<TabsTrigger value="disk" className="w-full flex items-center gap-1.5">
<HardDriveIcon className="size-3.5" />
<Trans>Disk</Trans>
</TabsTrigger>
{hasGpu && (
<TabsTrigger value="gpu" className="w-full flex items-center gap-2">
<GpuIcon className="size-3.5" />
<Trans>GPU</Trans>
</TabsTrigger>
)}
{hasContainers && (
<TabsTrigger value="containers" className="w-full flex items-center gap-2">
<ContainerIcon className="size-3.5" />
<Trans>Containers</Trans>
</TabsTrigger>
)}
{hasSystemd && (
<TabsTrigger value="services" className="w-full flex items-center gap-2">
<TerminalSquareIcon className="size-3.5" />
<Trans>Services</Trans>
</TabsTrigger>
)}
</TabsList>
<TabsContent value="core" forceMount className={activeTab === "core" ? "contents" : "hidden"}>
<div className="grid xl:grid-cols-2 gap-4">
<CpuChart {...coreProps} />
<MemoryChart {...coreProps} />
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
<BandwidthChart {...coreProps} systemStats={systemStats} />
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
<BatteryChart {...coreProps} />
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
</div>
</TabsContent>
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
{mountedTabs.has("disk") && (
<>
<div className="grid xl:grid-cols-2 gap-4">
<RootDiskCharts systemData={systemData} />
</div>
<ExtraFsCharts systemData={systemData} />
{maybeHasSmartData && <LazySmartTable systemId={system.id} />}
</>
)}
</TabsContent>
{hasGpu && (
<TabsContent value="gpu" forceMount className={activeTab === "gpu" ? "contents" : "hidden"}>
<div className="grid xl:grid-cols-2 gap-4">
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
</div>
{hasGpuData && lastGpus && (
<GpuDetailCharts
chartData={chartData}
grid={grid}
dataEmpty={dataEmpty}
lastGpus={lastGpus as Record<string, GPUData>}
hasGpuEnginesData={hasGpuEnginesData}
/>
)}
</TabsContent>
)}
{hasContainers && (
<TabsContent value="containers" forceMount className={activeTab === "containers" ? "contents" : "hidden"}>
{mountedTabs.has("containers") && (
<>
<div className="grid xl:grid-cols-2 gap-4">
<ContainerCpuChart
chartData={chartData}
grid={grid}
dataEmpty={dataEmpty}
isPodman={isPodman}
cpuConfig={containerChartConfigs.cpu}
/>
<ContainerMemoryChart
chartData={chartData}
grid={grid}
dataEmpty={dataEmpty}
isPodman={isPodman}
memoryConfig={containerChartConfigs.memory}
/>
<ContainerNetworkChart
chartData={chartData}
grid={grid}
dataEmpty={dataEmpty}
isPodman={isPodman}
networkConfig={containerChartConfigs.network}
/>
</div>
{hasContainersTable && <ContainersTable systemId={system.id} />}
</>
)}
</TabsContent>
)}
{hasSystemd && (
<TabsContent value="services" forceMount className={activeTab === "services" ? "contents" : "hidden"}>
{mountedTabs.has("services") && <SystemdTable systemId={system.id} />}
</TabsContent>
)}
</Tabs>
)
}
return (
<div className="grid gap-4 mb-14 overflow-x-clip">
{/* system info */}
<InfoBar
system={system}
chartData={chartData}
grid={grid}
setGrid={setGrid}
displayMode={displayMode}
setDisplayMode={setDisplayMode}
details={details}
/>
{displayMode === "tabs" ? tabbedLayout() : defaultLayout()}
</div>
)
})
@@ -0,0 +1,133 @@
import { t } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { XIcon } from "lucide-react"
import React, { type JSX, memo, useCallback, useEffect, useState } from "react"
import { $containerFilter, $maxValues } from "@/lib/stores"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { cn } from "@/lib/utils"
import Spinner from "../../spinner"
import { Button } from "../../ui/button"
import { Card, CardDescription, CardHeader, CardTitle } from "../../ui/card"
import { ChartAverage, ChartMax } from "../../ui/icons"
import { Input } from "../../ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select"
export function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
const storeValue = useStore(store)
const [inputValue, setInputValue] = useState(storeValue)
const { t } = useLingui()
useEffect(() => {
setInputValue(storeValue)
}, [storeValue])
useEffect(() => {
if (inputValue === storeValue) {
return
}
const handle = window.setTimeout(() => store.set(inputValue), 80)
return () => clearTimeout(handle)
}, [inputValue, storeValue, store])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
}, [])
const handleClear = useCallback(() => {
setInputValue("")
store.set("")
}, [store])
return (
<>
<Input
placeholder={t`Filter...`}
className="ps-4 pe-8 w-full sm:w-44"
onChange={handleChange}
value={inputValue}
/>
{inputValue && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Clear"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
onClick={handleClear}
>
<XIcon className="h-4 w-4" />
</Button>
)}
</>
)
}
export const SelectAvgMax = memo(({ max }: { max: boolean }) => {
const Icon = max ? ChartMax : ChartAverage
return (
<Select value={max ? "max" : "avg"} onValueChange={(e) => $maxValues.set(e === "max")}>
<SelectTrigger className="relative ps-10 pe-5 w-full sm:w-44">
<Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem key="avg" value="avg">
<Trans>Average</Trans>
</SelectItem>
<SelectItem key="max" value="max">
<Trans comment="Chart select field. Please try to keep this short.">Max 1 min</Trans>
</SelectItem>
</SelectContent>
</Select>
)
})
export function ChartCard({
title,
description,
children,
grid,
empty,
cornerEl,
legend,
className,
}: {
title: string
description: string
children: React.ReactNode
grid?: boolean
empty?: boolean
cornerEl?: JSX.Element | null
legend?: boolean
className?: string
}) {
const { isIntersecting, ref } = useIntersectionObserver()
return (
<Card
className={cn(
"px-3 py-5 sm:py-6 sm:px-6 odd:last-of-type:col-span-full min-h-full",
{ "col-span-full": !grid },
className
)}
ref={ref}
>
<CardHeader className="gap-1.5 relative p-0 mb-3 sm:mb-4">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
{cornerEl && <div className="grid sm:justify-end sm:absolute sm:top-0 sm:end-0 my-1 sm:my-0">{cornerEl}</div>}
</CardHeader>
<div className={cn("ps-0 -me-1 -ms-3.5 relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
{
<Spinner
msg={empty ? t`Waiting for enough records to display` : undefined}
className="group-has-[.opacity-100]:invisible duration-100"
/>
}
{isIntersecting && children}
</div>
</Card>
)
}
@@ -0,0 +1,116 @@
import { timeTicks } from "d3-time"
import { getPbTimestamp, pb } from "@/lib/api"
import { chartTimeData } from "@/lib/utils"
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
type ChartTimeData = {
time: number
data: {
ticks: number[]
domain: number[]
}
chartTime: ChartTimes
}
export const cache = new Map<
string,
ChartTimeData | SystemStatsRecord[] | ContainerStatsRecord[] | ChartData["containerData"]
>()
// create ticks and domain for charts
export function getTimeData(chartTime: ChartTimes, lastCreated: number) {
const cached = cache.get("td") as ChartTimeData | undefined
if (cached && cached.chartTime === chartTime) {
if (!lastCreated || cached.time >= lastCreated) {
return cached.data
}
}
// const buffer = chartTime === "1m" ? 400 : 20_000
const now = new Date(Date.now())
const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const data = {
ticks,
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
}
cache.set("td", { time: now.getTime(), data, chartTime })
return data
}
/** Append new records onto prev with gap detection. Converts string `created` values to ms timestamps in place.
* Pass `maxLen` to cap the result length in one copy instead of slicing again after the call. */
export function appendData<T extends { created: string | number | null }>(
prev: T[],
newRecords: T[],
expectedInterval: number,
maxLen?: number
): T[] {
if (!newRecords.length) return prev
// Pre-trim prev so the single slice() below is the only copy we make
const trimmed = maxLen && prev.length >= maxLen ? prev.slice(-(maxLen - newRecords.length)) : prev
const result = trimmed.slice()
let prevTime = (trimmed.at(-1)?.created as number) ?? 0
for (const record of newRecords) {
if (record.created !== null) {
if (typeof record.created === "string") {
record.created = new Date(record.created).getTime()
}
if (prevTime && (record.created as number) - prevTime > expectedInterval * 1.5) {
result.push({ created: null, ...("stats" in record ? { stats: null } : {}) } as T)
}
prevTime = record.created as number
}
result.push(record)
}
return result
}
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
collection: string,
systemId: string,
chartTime: ChartTimes
): Promise<T[]> {
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
const lastCached = cachedStats?.at(-1)?.created as number
return await pb.collection<T>(collection).getFullList({
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
id: systemId,
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
type: chartTimeData[chartTime].type,
}),
fields: "created,stats",
sort: "created",
})
}
export function makeContainerData(containers: ContainerStatsRecord[]): ChartData["containerData"] {
const result = [] as ChartData["containerData"]
for (const { created, stats } of containers) {
if (!created) {
result.push({ created: null } as ChartData["containerData"][0])
continue
}
result.push(makeContainerPoint(new Date(created).getTime(), stats))
}
return result
}
/** Transform a single realtime container stats message into a ChartDataContainer point. */
export function makeContainerPoint(
created: number,
stats: ContainerStatsRecord["stats"]
): ChartData["containerData"][0] {
const point: ChartData["containerData"][0] = { created } as ChartData["containerData"][0]
for (const container of stats) {
;(point as Record<string, unknown>)[container.n] = container
}
return point
}
export function dockerOrPodman(str: string, isPodman: boolean): string {
if (isPodman) {
return str.replace("docker", "podman").replace("Docker", "Podman")
}
return str
}
@@ -0,0 +1,99 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { useContainerDataPoints } from "@/components/charts/hooks"
import { decimalString, toFixedFloat } from "@/lib/utils"
import type { ChartConfig } from "@/components/ui/chart"
import type { ChartData } from "@/types"
import { pinnedAxisDomain } from "@/components/ui/chart"
import CpuCoresSheet from "../cpu-sheet"
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
import { dockerOrPodman } from "../chart-data"
export function CpuChart({
chartData,
grid,
dataEmpty,
showMax,
isLongerChart,
maxValues,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
showMax: boolean
isLongerChart: boolean
maxValues: boolean
}) {
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`CPU Usage`}
description={t`Average system-wide CPU utilization`}
cornerEl={
<div className="flex gap-2">
{maxValSelect}
<CpuCoresSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
</div>
}
>
<AreaChartDefault
chartData={chartData}
maxToggled={showMax}
dataPoints={[
{
label: t`CPU Usage`,
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
color: 1,
opacity: 0.4,
},
]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
domain={pinnedAxisDomain()}
/>
</ChartCard>
)
}
export function ContainerCpuChart({
chartData,
grid,
dataEmpty,
isPodman,
cpuConfig,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
isPodman: boolean
cpuConfig: ChartConfig
}) {
const { filter, dataPoints } = useContainerDataPoints(cpuConfig, (key, data) => data[key]?.c ?? null)
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker CPU Usage`, isPodman)}
description={t`Average CPU utilization of containers`}
cornerEl={<FilterBar />}
>
<AreaChartDefault
chartData={chartData}
customData={chartData.containerData}
dataPoints={dataPoints}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}
truncate={true}
itemSorter={(a, b) => b.value - a.value}
/>
</ChartCard>
)
}
@@ -0,0 +1,283 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { SystemStatsRecord } from "@/types"
import { ChartCard, SelectAvgMax } from "../chart-card"
import { Unit } from "@/lib/enums"
import { pinnedAxisDomain } from "@/components/ui/chart"
import DiskIoSheet from "../disk-io-sheet"
import type { SystemData } from "../use-system-data"
import { useStore } from "@nanostores/react"
import { $userSettings } from "@/lib/stores"
// Helpers for indexed dios/diosm access
const dios =
(i: number) =>
({ stats }: SystemStatsRecord) =>
stats?.dios?.[i] ?? 0
const diosMax =
(i: number) =>
({ stats }: SystemStatsRecord) =>
stats?.diosm?.[i] ?? 0
const extraDios =
(name: string, i: number) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.dios?.[i] ?? 0
const extraDiosMax =
(name: string, i: number) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.diosm?.[i] ?? 0
export const diskDataFns = {
// usage
usage: ({ stats }: SystemStatsRecord) => stats?.du ?? 0,
extraUsage:
(name: string) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.du ?? 0,
// throughput
read: ({ stats }: SystemStatsRecord) => stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024,
readMax: ({ stats }: SystemStatsRecord) => stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024,
write: ({ stats }: SystemStatsRecord) => stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024,
writeMax: ({ stats }: SystemStatsRecord) => stats?.diom?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024,
// extra fs throughput
extraRead:
(name: string) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.rb ?? (stats?.efs?.[name]?.r ?? 0) * 1024 * 1024,
extraReadMax:
(name: string) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.rbm ?? (stats?.efs?.[name]?.rm ?? 0) * 1024 * 1024,
extraWrite:
(name: string) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.wb ?? (stats?.efs?.[name]?.w ?? 0) * 1024 * 1024,
extraWriteMax:
(name: string) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.wbm ?? (stats?.efs?.[name]?.wm ?? 0) * 1024 * 1024,
// read/write time
readTime: dios(0),
readTimeMax: diosMax(0),
extraReadTime: (name: string) => extraDios(name, 0),
extraReadTimeMax: (name: string) => extraDiosMax(name, 0),
writeTime: dios(1),
writeTimeMax: diosMax(1),
extraWriteTime: (name: string) => extraDios(name, 1),
extraWriteTimeMax: (name: string) => extraDiosMax(name, 1),
// utilization (IoTime-based, 0-100%)
util: dios(2),
utilMax: diosMax(2),
extraUtil: (name: string) => extraDios(name, 2),
extraUtilMax: (name: string) => extraDiosMax(name, 2),
// r_await / w_await: average service time per read/write operation (ms)
rAwait: dios(3),
rAwaitMax: diosMax(3),
extraRAwait: (name: string) => extraDios(name, 3),
extraRAwaitMax: (name: string) => extraDiosMax(name, 3),
wAwait: dios(4),
wAwaitMax: diosMax(4),
extraWAwait: (name: string) => extraDios(name, 4),
extraWAwaitMax: (name: string) => extraDiosMax(name, 4),
// average queue depth: stored as queue_depth * 100 in Go, divided here
weightedIO: ({ stats }: SystemStatsRecord) => (stats?.dios?.[5] ?? 0) / 100,
weightedIOMax: ({ stats }: SystemStatsRecord) => (stats?.diosm?.[5] ?? 0) / 100,
extraWeightedIO:
(name: string) =>
({ stats }: SystemStatsRecord) =>
(stats?.efs?.[name]?.dios?.[5] ?? 0) / 100,
extraWeightedIOMax:
(name: string) =>
({ stats }: SystemStatsRecord) =>
(stats?.efs?.[name]?.diosm?.[5] ?? 0) / 100,
}
export function RootDiskCharts({ systemData }: { systemData: SystemData }) {
return (
<>
<DiskUsageChart systemData={systemData} />
<DiskIOChart systemData={systemData} />
</>
)
}
export function DiskUsageChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
const { chartData, grid, dataEmpty } = systemData
let diskSize = chartData.systemStats?.at(-1)?.stats.d ?? NaN
if (extraFsName) {
diskSize = chartData.systemStats?.at(-1)?.stats.efs?.[extraFsName]?.d ?? NaN
}
// round to nearest GB
if (diskSize >= 100) {
diskSize = Math.round(diskSize)
}
const title = extraFsName ? `${extraFsName} ${t`Usage`}` : t`Disk Usage`
const description = extraFsName ? t`Disk usage of ${extraFsName}` : t`Usage of root partition`
return (
<ChartCard empty={dataEmpty} grid={grid} title={title} description={description}>
<AreaChartDefault
chartData={chartData}
domain={[0, diskSize]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${decimalString(convertedValue)} ${unit}`
}}
dataPoints={[
{
label: t`Disk Usage`,
color: 4,
opacity: 0.4,
dataKey: extraFsName ? diskDataFns.extraUsage(extraFsName) : diskDataFns.usage,
},
]}
></AreaChartDefault>
</ChartCard>
)
}
export function DiskIOChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
const { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues } = systemData
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const userSettings = useStore($userSettings)
if (!chartData.systemStats?.length) {
return null
}
const title = extraFsName ? `${extraFsName} I/O` : t`Disk I/O`
const description = extraFsName ? t`Throughput of ${extraFsName}` : t`Throughput of root filesystem`
const hasMoreIOMetrics = chartData.systemStats?.some((record) => record.stats?.dios?.at(0))
let CornerEl = maxValSelect
if (hasMoreIOMetrics) {
CornerEl = (
<div className="flex gap-2">
{maxValSelect}
<DiskIoSheet systemData={systemData} extraFsName={extraFsName} title={title} description={description} />
</div>
)
}
let readFn = showMax ? diskDataFns.readMax : diskDataFns.read
let writeFn = showMax ? diskDataFns.writeMax : diskDataFns.write
if (extraFsName) {
readFn = showMax ? diskDataFns.extraReadMax(extraFsName) : diskDataFns.extraRead(extraFsName)
writeFn = showMax ? diskDataFns.extraWriteMax(extraFsName) : diskDataFns.extraWrite(extraFsName)
}
return (
<ChartCard empty={dataEmpty} grid={grid} title={title} description={description} cornerEl={CornerEl}>
<AreaChartDefault
chartData={chartData}
maxToggled={showMax}
// domain={pinnedAxisDomain(true)}
showTotal={true}
dataPoints={[
{
label: t({ message: "Write", comment: "Disk write" }),
dataKey: writeFn,
color: 3,
opacity: 0.3,
},
{
label: t({ message: "Read", comment: "Disk read" }),
dataKey: readFn,
color: 1,
opacity: 0.3,
},
]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
)
}
export function DiskUtilizationChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
const { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues } = systemData
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
if (!chartData.systemStats?.length) {
return null
}
let utilFn = showMax ? diskDataFns.utilMax : diskDataFns.util
if (extraFsName) {
utilFn = showMax ? diskDataFns.extraUtilMax(extraFsName) : diskDataFns.extraUtil(extraFsName)
}
return (
<ChartCard
cornerEl={maxValSelect}
empty={dataEmpty}
grid={grid}
title={t({
message: `I/O Utilization`,
context: "Percent of time the disk is busy with I/O",
})}
description={t`Percent of time the disk is busy with I/O`}
// legend={true}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
domain={pinnedAxisDomain()}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
maxToggled={showMax}
chartProps={{ syncId: "io" }}
dataPoints={[
{
label: t({ message: "Utilization", context: "Disk I/O utilization" }),
dataKey: utilFn,
color: 1,
opacity: 0.4,
},
]}
/>
</ChartCard>
)
}
export function ExtraFsCharts({ systemData }: { systemData: SystemData }) {
const { systemStats } = systemData.chartData
const extraFs = systemStats?.at(-1)?.stats.efs
if (!extraFs || Object.keys(extraFs).length === 0) {
return null
}
return (
<div className="grid xl:grid-cols-2 gap-4">
{Object.keys(extraFs).map((extraFsName) => {
let diskSize = systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN
// round to nearest GB
if (diskSize >= 100) {
diskSize = Math.round(diskSize)
}
return (
<div key={extraFsName} className="contents">
<DiskUsageChart systemData={systemData} extraFsName={extraFsName} />
<DiskIOChart systemData={systemData} extraFsName={extraFsName} />
</div>
)
})}
</div>
)
}
@@ -0,0 +1,232 @@
import { t } from "@lingui/core/macro"
import { useRef, useMemo } from "react"
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
import LineChartDefault from "@/components/charts/line-chart"
import { Unit } from "@/lib/enums"
import { cn, decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartData, GPUData, SystemStatsRecord } from "@/types"
import { ChartCard } from "../chart-card"
/** GPU power draw chart for the main grid */
export function GpuPowerChart({
chartData,
grid,
dataEmpty,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
}) {
const packageKey = " package"
const statsRef = useRef(chartData.systemStats)
statsRef.current = chartData.systemStats
// Derive GPU power config key (cheap per render)
let gpuPowerKey = ""
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const gpus = chartData.systemStats[i].stats?.g
if (gpus) {
const parts: string[] = []
for (const id in gpus) {
const gpu = gpus[id] as GPUData
if (gpu.p !== undefined) parts.push(`${id}:${gpu.n}`)
if (gpu.pp !== undefined) parts.push(`${id}:${gpu.n}${packageKey}`)
}
gpuPowerKey = parts.sort().join("\0")
break
}
}
const dataPoints = useMemo((): DataPoint[] => {
if (!gpuPowerKey) return []
const totals = new Map<string, { label: string; gpuId: string; isPackage: boolean; total: number }>()
for (const record of statsRef.current) {
const gpus = record.stats?.g
if (!gpus) continue
for (const id in gpus) {
const gpu = gpus[id] as GPUData
const key = gpu.n
const existing = totals.get(key)
if (existing) {
existing.total += gpu.p ?? 0
} else {
totals.set(key, { label: gpu.n, gpuId: id, isPackage: false, total: gpu.p ?? 0 })
}
if (gpu.pp !== undefined) {
const pkgKey = `${gpu.n}${packageKey}`
const existingPkg = totals.get(pkgKey)
if (existingPkg) {
existingPkg.total += gpu.pp
} else {
totals.set(pkgKey, { label: pkgKey, gpuId: id, isPackage: true, total: gpu.pp })
}
}
}
}
const sorted = Array.from(totals.values()).sort((a, b) => b.total - a.total)
return sorted.map(
(entry, i): DataPoint => ({
label: entry.label,
dataKey: (data: SystemStatsRecord) => {
const gpu = data.stats?.g?.[entry.gpuId]
return entry.isPackage ? (gpu?.pp ?? 0) : (gpu?.p ?? 0)
},
color: `hsl(${226 + (((i * 360) / sorted.length) % 360)}, 65%, 52%)`,
opacity: 1,
})
)
}, [gpuPowerKey])
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`GPU Power Draw`}
description={t`Average power consumption of GPUs`}
>
<LineChartDefault
legend={dataPoints.length > 1}
chartData={chartData}
dataPoints={dataPoints}
itemSorter={(a: { value: number }, b: { value: number }) => b.value - a.value}
tickFormatter={(val) => `${toFixedFloat(val, 2)}W`}
contentFormatter={({ value }) => `${decimalString(value)}W`}
/>
</ChartCard>
)
}
/** GPU detail grid (engines + per-GPU usage/VRAM) — rendered outside the main 2-col grid */
export function GpuDetailCharts({
chartData,
grid,
dataEmpty,
lastGpus,
hasGpuEnginesData,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
lastGpus: Record<string, GPUData>
hasGpuEnginesData: boolean
}) {
return (
<div className="grid xl:grid-cols-2 gap-4">
{hasGpuEnginesData && (
<ChartCard
legend={true}
empty={dataEmpty}
grid={grid}
title={t`GPU Engines`}
description={t`Average utilization of GPU engines`}
>
<GpuEnginesChart chartData={chartData} />
</ChartCard>
)}
{Object.keys(lastGpus).map((id) => {
const gpu = lastGpus[id] as GPUData
return (
<div key={id} className="contents">
<ChartCard
className={cn(grid && "!col-span-1")}
empty={dataEmpty}
grid={grid}
title={`${gpu.n} ${t`Usage`}`}
description={t`Average utilization of ${gpu.n}`}
>
<AreaChartDefault
chartData={chartData}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
color: 1,
opacity: 0.35,
},
]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
/>
</ChartCard>
{(gpu.mt ?? 0) > 0 && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${gpu.n} VRAM`}
description={t`Precise utilization at the recorded time`}
>
<AreaChartDefault
chartData={chartData}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
color: 2,
opacity: 0.25,
},
]}
max={gpu.mt}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
return `${decimalString(convertedValue)} ${unit}`
}}
/>
</ChartCard>
)}
</div>
)
})}
</div>
)
}
function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
// Derive stable engine config key (cheap per render)
let enginesKey = ""
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const gpus = chartData.systemStats[i].stats?.g
if (!gpus) continue
for (const id in gpus) {
if (gpus[id].e) {
enginesKey = id + "\0" + Object.keys(gpus[id].e).sort().join("\0")
break
}
}
if (enginesKey) break
}
const { gpuId, dataPoints } = useMemo((): { gpuId: string | null; dataPoints: DataPoint[] } => {
if (!enginesKey) return { gpuId: null, dataPoints: [] }
const parts = enginesKey.split("\0")
const gId = parts[0]
const engineNames = parts.slice(1)
return {
gpuId: gId,
dataPoints: engineNames.map((engine, i) => ({
label: engine,
dataKey: ({ stats }: SystemStatsRecord) => stats?.g?.[gId]?.e?.[engine] ?? 0,
color: `hsl(${140 + (((i * 360) / engineNames.length) % 360)}, 65%, 52%)`,
opacity: 0.35,
})),
}
}, [enginesKey])
if (!gpuId) {
return null
}
return (
<LineChartDefault
legend={true}
chartData={chartData}
dataPoints={dataPoints}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
/>
)
}
@@ -0,0 +1,55 @@
import { t } from "@lingui/core/macro"
import type { ChartData } from "@/types"
import { ChartCard } from "../chart-card"
import LineChartDefault from "@/components/charts/line-chart"
import { decimalString, toFixedFloat } from "@/lib/utils"
export function LoadAverageChart({
chartData,
grid,
dataEmpty,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
}) {
const { major, minor } = chartData.agentVersion
if (major === 0 && minor <= 12) {
return null
}
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Load Average`}
description={t`System load averages over time`}
legend={true}
>
<LineChartDefault
chartData={chartData}
contentFormatter={(item) => decimalString(item.value)}
tickFormatter={(value) => {
return String(toFixedFloat(value, 2))
}}
legend={true}
dataPoints={[
{
label: t({ message: `1 min`, comment: "Load average" }),
color: "hsl(271, 81%, 60%)", // Purple
dataKey: ({ stats }) => stats?.la?.[0],
},
{
label: t({ message: `5 min`, comment: "Load average" }),
color: "hsl(217, 91%, 60%)", // Blue
dataKey: ({ stats }) => stats?.la?.[1],
},
{
label: t({ message: `15 min`, comment: "Load average" }),
color: "hsl(25, 95%, 53%)", // Orange
dataKey: ({ stats }) => stats?.la?.[2],
},
]}
></LineChartDefault>
</ChartCard>
)
}
@@ -0,0 +1,170 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { useContainerDataPoints } from "@/components/charts/hooks"
import { Unit } from "@/lib/enums"
import type { ChartConfig } from "@/components/ui/chart"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
import { dockerOrPodman } from "../chart-data"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import { pinnedAxisDomain } from "@/components/ui/chart"
export function MemoryChart({
chartData,
grid,
dataEmpty,
showMax,
isLongerChart,
maxValues,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
showMax: boolean
isLongerChart: boolean
maxValues: boolean
}) {
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Memory Usage`}
description={t`Precise utilization at the recorded time`}
cornerEl={maxValSelect}
>
<AreaChartDefault
chartData={chartData}
domain={[0, totalMem]}
itemSorter={(a, b) => a.order - b.order}
maxToggled={showMax}
showTotal={true}
tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${toFixedFloat(convertedValue, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
dataPoints={[
{
label: t`Used`,
dataKey: ({ stats }) => (showMax ? stats?.mm : stats?.mu),
color: 2,
opacity: 0.4,
stackId: "1",
order: 3,
},
{
label: "ZFS ARC",
dataKey: ({ stats }) => (showMax ? null : stats?.mz),
color: "hsla(175 60% 45% / 0.8)",
opacity: 0.5,
order: 2,
},
{
label: t`Cache / Buffers`,
dataKey: ({ stats }) => (showMax ? null : stats?.mb),
color: "hsla(160 60% 45% / 0.5)",
opacity: 0.4,
stackId: "1",
order: 1,
},
]}
/>
</ChartCard>
)
}
export function ContainerMemoryChart({
chartData,
grid,
dataEmpty,
isPodman,
memoryConfig,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
isPodman: boolean
memoryConfig: ChartConfig
}) {
const { filter, dataPoints } = useContainerDataPoints(memoryConfig, (key, data) => data[key]?.m ?? null)
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Memory Usage`, isPodman)}
description={dockerOrPodman(t`Memory usage of docker containers`, isPodman)}
cornerEl={<FilterBar />}
>
<AreaChartDefault
chartData={chartData}
customData={chartData.containerData}
dataPoints={dataPoints}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
return `${toFixedFloat(value, val >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={(item) => {
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
return `${decimalString(value)} ${unit}`
}}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}
truncate={true}
itemSorter={(a, b) => b.value - a.value}
/>
</ChartCard>
)
}
export function SwapChart({
chartData,
grid,
dataEmpty,
systemStats,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
systemStats: SystemStatsRecord[]
}) {
// const userSettings = useStore($userSettings)
const hasSwapData = (systemStats.at(-1)?.stats.su ?? 0) > 0
if (!hasSwapData) {
return null
}
return (
<ChartCard empty={dataEmpty} grid={grid} title={t`Swap Usage`} description={t`Swap space used by the system`}>
<AreaChartDefault
chartData={chartData}
domain={[0, () => toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
contentFormatter={({ value }) => {
// mem values are supplied as GB
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${toFixedFloat(convertedValue, value >= 10 ? 0 : 1)} ${unit}`
}}
dataPoints={[
{
label: t`Used`,
dataKey: ({ stats }) => stats?.su,
color: 2,
opacity: 0.4,
},
]}
></AreaChartDefault>
</ChartCard>
)
}
@@ -0,0 +1,183 @@
import { useMemo } from "react"
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { useContainerDataPoints } from "@/components/charts/hooks"
import { $userSettings } from "@/lib/stores"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartConfig } from "@/components/ui/chart"
import { pinnedAxisDomain } from "@/components/ui/chart"
import type { ChartData, SystemStatsRecord } from "@/types"
import { Separator } from "@/components/ui/separator"
import NetworkSheet from "../network-sheet"
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
import { dockerOrPodman } from "../chart-data"
export function BandwidthChart({
chartData,
grid,
dataEmpty,
showMax,
isLongerChart,
maxValues,
systemStats,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
showMax: boolean
isLongerChart: boolean
maxValues: boolean
systemStats: SystemStatsRecord[]
}) {
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const userSettings = $userSettings.get()
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Bandwidth`}
cornerEl={
<div className="flex gap-2">
{maxValSelect}
<NetworkSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
</div>
}
description={t`Network traffic of public interfaces`}
>
<AreaChartDefault
chartData={chartData}
maxToggled={showMax}
dataPoints={[
{
label: t`Sent`,
dataKey(data: SystemStatsRecord) {
if (showMax) {
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[0] ?? (data?.stats?.ns ?? 0) * 1024 * 1024
},
color: 5,
opacity: 0.2,
},
{
label: t`Received`,
dataKey(data: SystemStatsRecord) {
if (showMax) {
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[1] ?? (data?.stats?.nr ?? 0) * 1024 * 1024
},
color: 2,
opacity: 0.2,
},
]
// try to place the lesser number in front for better visibility
.sort(() => (systemStats.at(-1)?.stats.b?.[1] ?? 0) - (systemStats.at(-1)?.stats.b?.[0] ?? 0))}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={(data) => {
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
}}
showTotal={true}
/>
</ChartCard>
)
}
export function ContainerNetworkChart({
chartData,
grid,
dataEmpty,
isPodman,
networkConfig,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
isPodman: boolean
networkConfig: ChartConfig
}) {
const userSettings = $userSettings.get()
const { filter, dataPoints, filteredKeys } = useContainerDataPoints(networkConfig, (key, data) => {
const payload = data[key]
if (!payload) return null
const sent = payload?.b?.[0] ?? (payload?.ns ?? 0) * 1024 * 1024
const recv = payload?.b?.[1] ?? (payload?.nr ?? 0) * 1024 * 1024
return sent + recv
})
const contentFormatter = useMemo(() => {
const getRxTxBytes = (record?: { b?: [number, number]; ns?: number; nr?: number }) => {
if (record?.b?.length && record.b.length >= 2) {
return [Number(record.b[0]) || 0, Number(record.b[1]) || 0]
}
return [(record?.ns ?? 0) * 1024 * 1024, (record?.nr ?? 0) * 1024 * 1024]
}
const formatRxTx = (recv: number, sent: number) => {
const { value: receivedValue, unit: receivedUnit } = formatBytes(recv, true, userSettings.unitNet, false)
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, false)
return (
<span className="flex">
{decimalString(receivedValue)} {receivedUnit}
<span className="opacity-70 ms-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{decimalString(sentValue)} {sentUnit}
<span className="opacity-70 ms-0.5"> tx</span>
</span>
)
}
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item
return (item: any, key: string) => {
try {
if (key === "__total__") {
let totalSent = 0
let totalRecv = 0
const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {}
for (const [containerKey, value] of Object.entries(payloadData)) {
if (!value || typeof value !== "object") continue
if (filteredKeys.has(containerKey)) continue
const [sent, recv] = getRxTxBytes(value as { b?: [number, number]; ns?: number; nr?: number })
totalSent += sent
totalRecv += recv
}
return formatRxTx(totalRecv, totalSent)
}
const [sent, recv] = getRxTxBytes(item?.payload?.[key])
return formatRxTx(recv, sent)
} catch {
return null
}
}
}, [filteredKeys, userSettings.unitNet])
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Network I/O`, isPodman)}
description={dockerOrPodman(t`Network traffic of docker containers`, isPodman)}
cornerEl={<FilterBar />}
>
<AreaChartDefault
chartData={chartData}
customData={chartData.containerData}
dataPoints={dataPoints}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={contentFormatter}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}
truncate={true}
itemSorter={(a, b) => b.value - a.value}
/>
</ChartCard>
)
}
@@ -0,0 +1,209 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { batteryStateTranslations } from "@/lib/i18n"
import { $temperatureFilter, $userSettings } from "@/lib/stores"
import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard, FilterBar } from "../chart-card"
import LineChartDefault from "@/components/charts/line-chart"
import { useStore } from "@nanostores/react"
import { useRef, useMemo, useState, useEffect } from "react"
export function BatteryChart({
chartData,
grid,
dataEmpty,
maxValues,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
maxValues: boolean
}) {
const showBatteryChart = chartData.systemStats.at(-1)?.stats.bat
if (!showBatteryChart) {
return null
}
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Battery`}
description={`${t({
message: "Current state",
comment: "Context: Battery state",
})}: ${batteryStateTranslations[chartData.systemStats.at(-1)?.stats.bat?.[1] ?? 0]()}`}
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
dataPoints={[
{
label: t`Charge`,
dataKey: ({ stats }) => stats?.bat?.[0],
color: 1,
opacity: 0.35,
},
]}
domain={[0, 100]}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
/>
</ChartCard>
)
}
export function TemperatureChart({
chartData,
grid,
dataEmpty,
setPageBottomExtraMargin,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
setPageBottomExtraMargin?: (margin: number) => void
}) {
const showTempChart = chartData.systemStats.at(-1)?.stats.t
const filter = useStore($temperatureFilter)
const userSettings = useStore($userSettings)
const statsRef = useRef(chartData.systemStats)
statsRef.current = chartData.systemStats
// Derive sensor names key from latest data point
let sensorNamesKey = ""
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const t = chartData.systemStats[i].stats?.t
if (t) {
sensorNamesKey = Object.keys(t).sort().join("\0")
break
}
}
// Only recompute colors and dataKey functions when sensor names change
const { colorMap, dataKeys, sortedKeys } = useMemo(() => {
const stats = statsRef.current
const tempSums = {} as Record<string, number>
for (const data of stats) {
const t = data.stats?.t
if (!t) continue
for (const key of Object.keys(t)) {
tempSums[key] = (tempSums[key] ?? 0) + t[key]
}
}
const sorted = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
const colorMap = {} as Record<string, string>
const dataKeys = {} as Record<string, (d: SystemStatsRecord) => number | undefined>
for (let i = 0; i < sorted.length; i++) {
const key = sorted[i]
colorMap[key] = `hsl(${((i * 360) / sorted.length) % 360}, 60%, 55%)`
dataKeys[key] = (d: SystemStatsRecord) => d.stats?.t?.[key]
}
return { colorMap, dataKeys, sortedKeys: sorted }
}, [sensorNamesKey])
const dataPoints = useMemo(() => {
return sortedKeys.map((key) => {
const filterTerms = filter
? filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
: []
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
const strokeOpacity = filtered ? 0.1 : 1
return {
label: key,
dataKey: dataKeys[key],
color: colorMap[key],
opacity: strokeOpacity,
}
})
}, [sortedKeys, filter, dataKeys, colorMap])
// test with lots of data points
// const totalPoints = 50
// if (dataPoints.length > 0 && dataPoints.length < totalPoints) {
// let i = 0
// while (dataPoints.length < totalPoints) {
// dataPoints.push({
// label: `Test ${++i}`,
// dataKey: () => 0,
// color: "red",
// opacity: 1,
// })
// }
// }
const chartRef = useRef<HTMLDivElement>(null)
const [addMargin, setAddMargin] = useState(false)
const marginPx = (dataPoints.length - 13) * 18
useEffect(() => {
if (setPageBottomExtraMargin && dataPoints.length > 13 && chartRef.current) {
const checkPosition = () => {
if (!chartRef.current) return
const rect = chartRef.current.getBoundingClientRect()
const actualScrollHeight = addMargin
? document.documentElement.scrollHeight - marginPx
: document.documentElement.scrollHeight
const distanceToBottom = actualScrollHeight - (rect.bottom + window.scrollY)
if (distanceToBottom < 250) {
setAddMargin(true)
setPageBottomExtraMargin(marginPx)
} else {
setAddMargin(false)
setPageBottomExtraMargin(0)
}
}
checkPosition()
const timer = setTimeout(checkPosition, 500)
return () => {
clearTimeout(timer)
}
} else if (addMargin) {
setAddMargin(false)
if (setPageBottomExtraMargin) setPageBottomExtraMargin(0)
}
}, [dataPoints.length, addMargin, marginPx, setPageBottomExtraMargin])
if (!showTempChart) {
return null
}
const legend = dataPoints.length < 12
return (
<div ref={chartRef} className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Temperature`}
description={t`Temperatures of system sensors`}
cornerEl={<FilterBar store={$temperatureFilter} />}
legend={legend}
>
<LineChartDefault
chartData={chartData}
itemSorter={(a, b) => b.value - a.value}
domain={["auto", "auto"]}
legend={legend}
tickFormatter={(val) => {
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return `${toFixedFloat(value, 2)} ${unit}`
}}
contentFormatter={(item) => {
const { value, unit } = formatTemperature(item.value, userSettings.unitTemp)
return `${decimalString(value)} ${unit}`
}}
dataPoints={dataPoints}
></LineChartDefault>
</ChartCard>
</div>
)
}
@@ -0,0 +1,207 @@
import { t } from "@lingui/core/macro"
import { MoreHorizontalIcon } from "lucide-react"
import { memo, useRef, useState } from "react"
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { DialogTitle } from "@/components/ui/dialog"
import { compareSemVer, decimalString, parseSemVer, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard } from "./chart-card"
const minAgentVersion = parseSemVer("0.15.3")
export default memo(function CpuCoresSheet({
chartData,
dataEmpty,
grid,
maxValues,
}: {
chartData: ChartData
dataEmpty: boolean
grid: boolean
maxValues: boolean
}) {
const [cpuCoresOpen, setCpuCoresOpen] = useState(false)
const hasOpened = useRef(false)
const supportsBreakdown = compareSemVer(chartData.agentVersion, minAgentVersion) >= 0
if (!supportsBreakdown) {
return null
}
if (cpuCoresOpen && !hasOpened.current) {
hasOpened.current = true
}
// Latest stats snapshot
const latest = chartData.systemStats.at(-1)?.stats
const cpus = latest?.cpus ?? []
const numCores = cpus.length
const hasBreakdown = (latest?.cpub?.length ?? 0) > 0
// make sure all individual core data points have the same y axis domain to make relative comparison easier
let highestCpuCorePct = 1
if (hasOpened.current) {
for (let i = 0; i < numCores; i++) {
for (let j = 0; j < chartData.systemStats.length; j++) {
const pct = chartData.systemStats[j].stats?.cpus?.[i] ?? 0
if (pct > highestCpuCorePct) {
highestCpuCorePct = pct
}
}
}
}
const breakdownDataPoints = [
{
label: "System",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[1],
color: 3,
opacity: 0.35,
stackId: "a",
},
{
label: "User",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[0],
color: 1,
opacity: 0.35,
stackId: "a",
},
{
label: "IOWait",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[2],
color: 4,
opacity: 0.35,
stackId: "a",
},
{
label: "Steal",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[3],
color: 5,
opacity: 0.35,
stackId: "a",
},
{
label: "Idle",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[4],
color: 2,
opacity: 0.35,
stackId: "a",
},
{
label: t`Other`,
dataKey: ({ stats }: SystemStatsRecord) => {
const total = stats?.cpub?.reduce((acc, curr) => acc + curr, 0) ?? 0
return total > 0 ? 100 - total : null
},
color: `hsl(80, 65%, 52%)`,
opacity: 0.35,
stackId: "a",
},
] as DataPoint[]
return (
<Sheet open={cpuCoresOpen} onOpenChange={setCpuCoresOpen}>
<DialogTitle className="sr-only">{t`CPU Usage`}</DialogTitle>
<SheetTrigger asChild>
<Button
title={t`View more`}
variant="outline"
size="icon"
className="shrink-0 max-sm:absolute max-sm:top-0 max-sm:end-0"
>
<MoreHorizontalIcon />
</Button>
</SheetTrigger>
{hasOpened.current && (
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
{hasBreakdown && (
<ChartCard
key="cpu-breakdown"
empty={dataEmpty}
grid={grid}
title={t`CPU Time Breakdown`}
description={t`Percentage of time spent in each state`}
legend={true}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
legend={true}
dataPoints={breakdownDataPoints}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
reverseStackOrder={true}
itemSorter={() => 1}
domain={[0, 100]}
/>
</ChartCard>
)}
{numCores > 0 && (
<ChartCard
key="cpu-cores-all"
empty={dataEmpty}
grid={grid}
title={t`CPU Cores`}
legend={numCores < 10}
description={t`Per-core average utilization`}
className="min-h-auto"
>
<AreaChartDefault
hideYAxis={true}
chartData={chartData}
maxToggled={maxValues}
legend={numCores < 10}
dataPoints={Array.from({ length: numCores }).map((_, i) => ({
label: `CPU ${i}`,
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i] ?? 1 / (stats?.cpus?.length ?? 1),
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, var(--chart-saturation), var(--chart-lightness))`,
opacity: 0.35,
stackId: "a",
}))}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
reverseStackOrder={true}
itemSorter={() => 1}
/>
</ChartCard>
)}
{Array.from({ length: numCores }).map((_, i) => (
<ChartCard
key={`cpu-core-${i}`}
empty={dataEmpty}
grid={grid}
title={`CPU ${i}`}
description={t`Per-core average utilization`}
legend={false}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
domain={[0, highestCpuCorePct]}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i],
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, 65%, 52%)`,
opacity: 0.35,
},
]}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
/>
</ChartCard>
))}
</SheetContent>
)}
</Sheet>
)
})
@@ -0,0 +1,265 @@
import { t } from "@lingui/core/macro"
import { useStore } from "@nanostores/react"
import { MoreHorizontalIcon } from "lucide-react"
import { memo, useRef, useState } from "react"
import AreaChartDefault from "@/components/charts/area-chart"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { DialogTitle } from "@/components/ui/dialog"
import { $userSettings } from "@/lib/stores"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import { ChartCard, SelectAvgMax } from "@/components/routes/system/chart-card"
import type { SystemData } from "@/components/routes/system/use-system-data"
import { diskDataFns, DiskUtilizationChart } from "./charts/disk-charts"
import { pinnedAxisDomain } from "@/components/ui/chart"
export default memo(function DiskIOSheet({
systemData,
extraFsName,
title,
description,
}: {
systemData: SystemData
extraFsName?: string
title: string
description: string
}) {
const { chartData, grid, dataEmpty, showMax, maxValues, isLongerChart } = systemData
const userSettings = useStore($userSettings)
const [sheetOpen, setSheetOpen] = useState(false)
const hasOpened = useRef(false)
if (sheetOpen && !hasOpened.current) {
hasOpened.current = true
}
// throughput functions, with extra fs variants if needed
let readFn = showMax ? diskDataFns.readMax : diskDataFns.read
let writeFn = showMax ? diskDataFns.writeMax : diskDataFns.write
if (extraFsName) {
readFn = showMax ? diskDataFns.extraReadMax(extraFsName) : diskDataFns.extraRead(extraFsName)
writeFn = showMax ? diskDataFns.extraWriteMax(extraFsName) : diskDataFns.extraWrite(extraFsName)
}
// read and write time functions, with extra fs variants if needed
let readTimeFn = showMax ? diskDataFns.readTimeMax : diskDataFns.readTime
let writeTimeFn = showMax ? diskDataFns.writeTimeMax : diskDataFns.writeTime
if (extraFsName) {
readTimeFn = showMax ? diskDataFns.extraReadTimeMax(extraFsName) : diskDataFns.extraReadTime(extraFsName)
writeTimeFn = showMax ? diskDataFns.extraWriteTimeMax(extraFsName) : diskDataFns.extraWriteTime(extraFsName)
}
// I/O await functions, with extra fs variants if needed
let rAwaitFn = showMax ? diskDataFns.rAwaitMax : diskDataFns.rAwait
let wAwaitFn = showMax ? diskDataFns.wAwaitMax : diskDataFns.wAwait
if (extraFsName) {
rAwaitFn = showMax ? diskDataFns.extraRAwaitMax(extraFsName) : diskDataFns.extraRAwait(extraFsName)
wAwaitFn = showMax ? diskDataFns.extraWAwaitMax(extraFsName) : diskDataFns.extraWAwait(extraFsName)
}
// weighted I/O function, with extra fs variant if needed
let weightedIOFn = showMax ? diskDataFns.weightedIOMax : diskDataFns.weightedIO
if (extraFsName) {
weightedIOFn = showMax ? diskDataFns.extraWeightedIOMax(extraFsName) : diskDataFns.extraWeightedIO(extraFsName)
}
// check for availability of I/O metrics
let hasUtilization = false
let hasAwait = false
let hasWeightedIO = false
for (const record of chartData.systemStats ?? []) {
const dios = record.stats?.dios
if ((dios?.at(2) ?? 0) > 0) hasUtilization = true
if ((dios?.at(3) ?? 0) > 0) hasAwait = true
if ((dios?.at(5) ?? 0) > 0) hasWeightedIO = true
if (hasUtilization && hasAwait && hasWeightedIO) {
break
}
}
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const chartProps = { syncId: "io" }
const queueDepthTranslation = t({ message: "Queue Depth", context: "Disk I/O average queue depth" })
return (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<DialogTitle className="sr-only">{title}</DialogTitle>
<SheetTrigger asChild>
<Button
title={t`View more`}
variant="outline"
size="icon"
className="shrink-0 max-sm:absolute max-sm:top-0 max-sm:end-0"
>
<MoreHorizontalIcon />
</Button>
</SheetTrigger>
{hasOpened.current && (
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
<ChartCard
className="min-h-auto"
empty={dataEmpty}
grid={grid}
title={title}
description={description}
cornerEl={maxValSelect}
// legend={true}
>
<AreaChartDefault
chartData={chartData}
maxToggled={showMax}
chartProps={chartProps}
showTotal={true}
domain={pinnedAxisDomain()}
itemSorter={(a, b) => a.order - b.order}
reverseStackOrder={true}
dataPoints={[
{
label: t`Write`,
dataKey: writeFn,
color: 3,
opacity: 0.4,
stackId: 0,
order: 0,
},
{
label: t`Read`,
dataKey: readFn,
color: 1,
opacity: 0.4,
stackId: 0,
order: 1,
},
]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
{hasUtilization && <DiskUtilizationChart systemData={systemData} extraFsName={extraFsName} />}
<ChartCard
empty={dataEmpty}
grid={grid}
title={t({ message: "I/O Time", context: "Disk I/O total time spent on read/write" })}
description={t({
message: "Total time spent on read/write (can exceed 100%)",
context: "Disk I/O",
})}
className="min-h-auto"
cornerEl={maxValSelect}
>
<AreaChartDefault
chartData={chartData}
domain={pinnedAxisDomain()}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
maxToggled={showMax}
chartProps={chartProps}
showTotal={true}
itemSorter={(a, b) => a.order - b.order}
reverseStackOrder={true}
dataPoints={[
{
label: t`Write`,
dataKey: writeTimeFn,
color: 3,
opacity: 0.4,
stackId: 0,
order: 0,
},
{
label: t`Read`,
dataKey: readTimeFn,
color: 1,
opacity: 0.4,
stackId: 0,
order: 1,
},
]}
/>
</ChartCard>
{hasWeightedIO && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={queueDepthTranslation}
description={t`Average number of I/O operations waiting to be serviced`}
className="min-h-auto"
cornerEl={maxValSelect}
>
<AreaChartDefault
chartData={chartData}
domain={pinnedAxisDomain()}
tickFormatter={(val) => `${toFixedFloat(val, 2)}`}
contentFormatter={({ value }) => decimalString(value, value < 10 ? 3 : 2)}
maxToggled={showMax}
chartProps={chartProps}
dataPoints={[
{
label: queueDepthTranslation,
dataKey: weightedIOFn,
color: 1,
opacity: 0.4,
},
]}
/>
</ChartCard>
)}
{hasAwait && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t({ message: "I/O Await", context: "Disk I/O average operation time (iostat await)" })}
description={t({
message: "Average queue to completion time per operation",
context: "Disk I/O average operation time (iostat await)",
})}
className="min-h-auto"
cornerEl={maxValSelect}
// legend={true}
>
<AreaChartDefault
chartData={chartData}
domain={pinnedAxisDomain()}
tickFormatter={(val) => `${toFixedFloat(val, 2)} ms`}
contentFormatter={({ value }) => `${decimalString(value)} ms`}
maxToggled={showMax}
chartProps={chartProps}
dataPoints={[
{
label: t`Write`,
dataKey: wAwaitFn,
color: 3,
opacity: 0.3,
},
{
label: t`Read`,
dataKey: rAwaitFn,
color: 1,
opacity: 0.3,
},
]}
/>
</ChartCard>
)}
</SheetContent>
)}
</Sheet>
)
})
@@ -0,0 +1,256 @@
import { plural } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import {
AppleIcon,
ChevronRightSquareIcon,
ClockArrowUp,
CpuIcon,
GlobeIcon,
MemoryStickIcon,
MonitorIcon,
Settings2Icon,
} from "lucide-react"
import { useMemo } from "react"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { ConnectionType, connectionTypeLabels, Os, SystemStatus } from "@/lib/enums"
import { cn, formatBytes, getHostDisplayValue, secondsToUptimeString, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types"
export default function InfoBar({
system,
chartData,
grid,
setGrid,
displayMode,
setDisplayMode,
details,
}: {
system: SystemRecord
chartData: ChartData
grid: boolean
setGrid: (grid: boolean) => void
displayMode: "default" | "tabs"
setDisplayMode: (mode: "default" | "tabs") => void
details: SystemDetailsRecord | null
}) {
const { t } = useLingui()
// values for system info bar - use details with fallback to system.info
const systemInfo = useMemo(() => {
if (!system.info) {
return []
}
// Use details if available, otherwise fall back to system.info
const hostname = details?.hostname ?? system.info.h
const kernel = details?.kernel ?? system.info.k
const cores = details?.cores ?? system.info.c
const threads = details?.threads ?? system.info.t ?? 0
const cpuModel = details?.cpu ?? system.info.m
const os = details?.os ?? system.info.os ?? Os.Linux
const osName = details?.os_name
const arch = details?.arch
const memory = details?.memory
const osInfo = {
[Os.Linux]: {
Icon: TuxIcon,
// show kernel in tooltip if os name is available, otherwise show the kernel
value: osName || kernel,
label: osName ? kernel : undefined,
},
[Os.Darwin]: {
Icon: AppleIcon,
value: osName || `macOS ${kernel}`,
},
[Os.Windows]: {
Icon: WindowsIcon,
value: osName || kernel,
label: osName ? kernel : undefined,
},
[Os.FreeBSD]: {
Icon: FreeBsdIcon,
value: osName || kernel,
label: osName ? kernel : undefined,
},
}
const info = [
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
{
value: hostname,
Icon: MonitorIcon,
label: "Hostname",
// hide if hostname is same as host or name
hide: hostname === system.host || hostname === system.name,
},
{ value: secondsToUptimeString(system.info.u), Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
osInfo[os],
{
value: cpuModel,
Icon: CpuIcon,
hide: !cpuModel,
label: `${plural(cores, { one: "# core", other: "# cores" })} / ${plural(threads, { one: "# thread", other: "# threads" })}${arch ? ` / ${arch}` : ""}`,
},
] as {
value: string | number | undefined
label?: string
Icon: React.ElementType
hide?: boolean
}[]
if (memory) {
const memValue = formatBytes(memory, false, undefined, false)
info.push({
value: `${toFixedFloat(memValue.value, memValue.value >= 10 ? 1 : 2)} ${memValue.unit}`,
Icon: MemoryStickIcon,
hide: !memory,
label: t`Memory`,
})
}
return info
}, [system, details, t])
let translatedStatus: string = system.status
if (system.status === SystemStatus.Up) {
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
} else if (system.status === SystemStatus.Down) {
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
}
return (
<Card>
<div className="grid xl:flex xl:gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div className="min-w-0">
<h1 className="text-2xl sm:text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex xl:flex-wrap items-center py-4 xl:p-0 -mt-3 xl:mt-1 gap-3 text-sm text-nowrap opacity-90 overflow-x-auto scrollbar-hide -mx-4 px-4 xl:mx-0">
<Tooltip>
<TooltipTrigger asChild>
<div className="capitalize flex gap-2 items-center">
<span className={cn("relative flex h-3 w-3")}>
{system.status === SystemStatus.Up && (
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: "1.5s" }}
></span>
)}
<span
className={cn("relative inline-flex rounded-full h-3 w-3", {
"bg-green-500": system.status === SystemStatus.Up,
"bg-red-500": system.status === SystemStatus.Down,
"bg-primary/40": system.status === SystemStatus.Paused,
"bg-yellow-500": system.status === SystemStatus.Pending,
})}
></span>
</span>
{translatedStatus}
</div>
</TooltipTrigger>
{system.info.ct && (
<TooltipContent>
<div className="flex gap-1 items-center">
{system.info.ct === ConnectionType.WebSocket ? (
<WebSocketIcon className="size-4" />
) : (
<ChevronRightSquareIcon className="size-4" strokeWidth={2} />
)}
{connectionTypeLabels[system.info.ct as ConnectionType]}
</div>
</TooltipContent>
)}
</Tooltip>
{systemInfo.map(({ value, label, Icon, hide }) => {
if (hide || !value) {
return null
}
const content = (
<div className="flex gap-1.5 items-center">
<Icon className="h-4 w-4" /> {value}
</div>
)
return (
<div key={value} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
) : (
content
)}
</div>
)
})}
</div>
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={t`Settings`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
>
<Settings2Icon className="size-4 opacity-90" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuLabel className="px-3.5">
<Trans context="Layout display options">Display</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
className="px-1 pb-1"
value={displayMode}
onValueChange={(v) => setDisplayMode(v as "default" | "tabs")}
>
<DropdownMenuRadioItem value="default" onSelect={(e) => e.preventDefault()}>
<Trans context="Default system layout option">Default</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="tabs" onSelect={(e) => e.preventDefault()}>
<Trans context="Tabs system layout option">Tabs</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-3.5">
<Trans>Chart width</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
className="px-1 pb-1"
value={grid ? "grid" : "full"}
onValueChange={(v) => setGrid(v === "grid")}
>
<DropdownMenuRadioItem value="grid" onSelect={(e) => e.preventDefault()}>
<Trans>Grid</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="full" onSelect={(e) => e.preventDefault()}>
<Trans>Full</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
)
}
@@ -0,0 +1,36 @@
import { lazy } from "react"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { cn } from "@/lib/utils"
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
export function LazyContainersTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <ContainersTable systemId={systemId} />}
</div>
)
}
const SmartTable = lazy(() => import("./smart-table"))
export function LazySmartTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <SmartTable systemId={systemId} />}
</div>
)
}
const SystemdTable = lazy(() => import("../../systemd-table/systemd-table"))
export function LazySystemdTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver()
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <SystemdTable systemId={systemId} />}
</div>
)
}
@@ -0,0 +1,156 @@
import { t } from "@lingui/core/macro"
import { useStore } from "@nanostores/react"
import { MoreHorizontalIcon } from "lucide-react"
import { memo, useRef, useState } from "react"
import AreaChartDefault from "@/components/charts/area-chart"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { useNetworkInterfaces } from "@/components/charts/hooks"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { DialogTitle } from "@/components/ui/dialog"
import { $userSettings } from "@/lib/stores"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import { ChartCard } from "./chart-card"
export default memo(function NetworkSheet({
chartData,
dataEmpty,
grid,
maxValues,
}: {
chartData: ChartData
dataEmpty: boolean
grid: boolean
maxValues: boolean
}) {
const [netInterfacesOpen, setNetInterfacesOpen] = useState(false)
const userSettings = useStore($userSettings)
const netInterfaces = useNetworkInterfaces(chartData.systemStats.at(-1)?.stats?.ni ?? {})
const showNetLegend = netInterfaces.length > 0 && netInterfaces.length < 15
const hasOpened = useRef(false)
if (netInterfacesOpen && !hasOpened.current) {
hasOpened.current = true
}
if (!netInterfaces.length) {
return null
}
return (
<Sheet open={netInterfacesOpen} onOpenChange={setNetInterfacesOpen}>
<DialogTitle className="sr-only">{t`Network traffic of public interfaces`}</DialogTitle>
<SheetTrigger asChild>
<Button
title={t`View more`}
variant="outline"
size="icon"
className="shrink-0 max-sm:absolute max-sm:top-0 max-sm:end-0"
>
<MoreHorizontalIcon />
</Button>
</SheetTrigger>
{hasOpened.current && (
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Download`}
description={t`Network traffic of public interfaces`}
legend={showNetLegend}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
itemSorter={(a, b) => b.value - a.value}
dataPoints={netInterfaces.data(1)}
legend={showNetLegend}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitNet, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Upload`}
description={t`Network traffic of public interfaces`}
legend={showNetLegend}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
itemSorter={(a, b) => b.value - a.value}
legend={showNetLegend}
dataPoints={netInterfaces.data(0)}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitNet, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Cumulative Download`}
description={t`Total data received for each interface`}
legend={showNetLegend}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
legend={showNetLegend}
dataPoints={netInterfaces.data(3)}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, false, userSettings.unitNet, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Cumulative Upload`}
description={t`Total data sent for each interface`}
legend={showNetLegend}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
legend={showNetLegend}
dataPoints={netInterfaces.data(2)}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, false, userSettings.unitNet, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
</SheetContent>
)}
</Sheet>
)
})
@@ -0,0 +1,849 @@
import { t } from "@lingui/core/macro"
import {
type ColumnDef,
type ColumnFiltersState,
type Column,
type Row,
type SortingState,
type Table as TableType,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import {
Activity,
Box,
Clock,
HardDrive,
BinaryIcon,
RotateCwIcon,
LoaderCircleIcon,
CheckCircle2Icon,
XCircleIcon,
ArrowLeftRightIcon,
MoreHorizontalIcon,
RefreshCwIcon,
ServerIcon,
Trash2Icon,
XIcon,
} from "lucide-react"
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { isReadOnlyUser, pb } from "@/lib/api"
import type { SmartDeviceRecord, SmartAttribute } from "@/types"
import {
formatBytes,
toFixedFloat,
formatTemperature,
cn,
getVisualStringWidth,
secondsToString,
hourWithSeconds,
formatShortDate,
} from "@/lib/utils"
import { Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { $allSystemsById } from "@/lib/stores"
import { ThermometerIcon } from "@/components/ui/icons"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Separator } from "@/components/ui/separator"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { memo, useCallback, useMemo, useEffect, useRef, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
// Column definition for S.M.A.R.T. attributes table
export const smartColumns: ColumnDef<SmartAttribute>[] = [
{
accessorKey: "id",
header: "ID",
},
{
accessorFn: (row) => row.n,
header: "Name",
},
{
accessorFn: (row) => row.rs || row.rv?.toString(),
header: "Value",
},
{
accessorKey: "v",
header: "Normalized",
},
{
accessorKey: "w",
header: "Worst",
},
{
accessorKey: "t",
header: "Threshold",
},
{
// accessorFn: (row) => row.wf,
accessorKey: "wf",
header: "Failing",
},
]
// Function to format capacity display
function formatCapacity(bytes: number): string {
const { value, unit } = formatBytes(bytes)
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
}
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
export const createColumns = (
longestName: number,
longestModel: number,
longestDevice: number
): ColumnDef<SmartDeviceRecord>[] => [
{
id: "system",
accessorFn: (record) => record.system,
sortingFn: (a, b) => {
const allSystems = $allSystemsById.get()
const systemNameA = allSystems[a.original.system]?.name ?? ""
const systemNameB = allSystems[b.original.system]?.name ?? ""
return systemNameA.localeCompare(systemNameB)
},
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return (
<div className="ms-1.5 max-w-40 block truncate" style={{ width: `${longestName / 1.05}ch` }}>
{allSystems[getValue() as string]?.name ?? ""}
</div>
)
},
},
{
accessorKey: "name",
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
cell: ({ getValue }) => (
<div
className="font-medium max-w-40 truncate ms-1"
title={getValue() as string}
style={{ width: `${longestDevice / 1.05}ch` }}
>
{getValue() as string}
</div>
),
},
{
accessorKey: "model",
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Model", comment: "Device model" })} Icon={Box} />
),
cell: ({ getValue }) => (
<div
className="max-w-48 truncate ms-1"
title={getValue() as string}
style={{ width: `${longestModel / 1.05}ch` }}
>
{getValue() as string}
</div>
),
},
{
accessorKey: "capacity",
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
cell: ({ getValue }) => <span className="ms-1">{formatCapacity(getValue() as number)}</span>,
},
{
accessorKey: "state",
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={Activity} />,
cell: ({ getValue }) => {
const status = getValue() as string
return (
<Badge className="ms-1" variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}>
{status}
</Badge>
)
},
},
{
accessorKey: "type",
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
cell: ({ getValue }) => (
<Badge variant="outline" className="ms-1 uppercase">
{getValue() as string}
</Badge>
),
},
{
accessorKey: "hours",
invertSorting: true,
header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />
),
cell: ({ getValue }) => {
const hours = getValue() as number | undefined
if (hours == null) {
return <div className="text-sm text-muted-foreground ms-1">N/A</div>
}
const seconds = hours * 3600
return (
<div className="text-sm ms-1">
<div>{secondsToString(seconds, "hour")}</div>
<div className="text-muted-foreground text-xs">{secondsToString(seconds, "day")}</div>
</div>
)
},
},
{
accessorKey: "cycles",
invertSorting: true,
header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />
),
cell: ({ getValue }) => {
const cycles = getValue() as number | undefined
if (cycles == null) {
return <div className="text-muted-foreground ms-1">N/A</div>
}
return <span className="ms-1">{cycles.toLocaleString()}</span>
},
},
{
accessorKey: "temp",
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
cell: ({ getValue }) => {
const temp = getValue() as number | null | undefined
if (!temp) {
return <div className="text-muted-foreground ms-1">N/A</div>
}
const { value, unit } = formatTemperature(temp)
return <span className="ms-1">{`${value} ${unit}`}</span>
},
},
// {
// accessorKey: "serial",
// sortingFn: (a, b) => a.original.serial.localeCompare(b.original.serial),
// header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
// },
// {
// accessorKey: "firmware",
// sortingFn: (a, b) => a.original.firmware.localeCompare(b.original.firmware),
// header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
// },
{
id: "updated",
invertSorting: true,
accessorFn: (record) => record.updated,
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={Clock} />,
cell: ({ getValue }) => {
const timestamp = getValue() as string
// if today, use hourWithSeconds, otherwise use formatShortDate
const formatter =
new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate
return <span className="ms-1 tabular-nums">{formatter(timestamp)}</span>
},
},
]
function HeaderButton({
column,
name,
Icon,
}: {
column: Column<SmartDeviceRecord>
name: string
Icon: React.ElementType
}) {
const isSorted = column.getIsSorted()
return (
<Button
className={cn(
"h-9 px-3 flex items-center gap-2 duration-50",
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
)}
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{Icon && <Icon className="size-4" />}
{name}
</Button>
)
}
export default function DisksTable({ systemId }: { systemId?: string }) {
const [sorting, setSorting] = useState<SortingState>([{ id: systemId ? "name" : "system", desc: false }])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [rowSelection, setRowSelection] = useState({})
const [smartDevices, setSmartDevices] = useState<SmartDeviceRecord[] | undefined>(undefined)
const [activeDiskId, setActiveDiskId] = useState<string | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null)
const [globalFilter, setGlobalFilter] = useState("")
const allSystems = useStore($allSystemsById)
// duplicate the devices to test with more rows
// if (
// smartDevices?.length &&
// smartDevices.length < 50 &&
// typeof window !== "undefined" &&
// window.location.hostname === "localhost"
// ) {
// setSmartDevices([...smartDevices, ...smartDevices, ...smartDevices])
// }
// Calculate the right width for the columns based on the longest strings among the displayed devices
const { longestName, longestModel, longestDevice } = useMemo(() => {
const result = { longestName: 0, longestModel: 0, longestDevice: 0 }
if (!smartDevices || Object.keys(allSystems).length === 0) {
return result
}
const seenSystems = new Set<string>()
for (const device of smartDevices) {
if (!systemId && !seenSystems.has(device.system)) {
seenSystems.add(device.system)
const name = allSystems[device.system]?.name ?? ""
result.longestName = Math.max(result.longestName, getVisualStringWidth(name))
}
result.longestModel = Math.max(result.longestModel, getVisualStringWidth(device.model ?? ""))
result.longestDevice = Math.max(result.longestDevice, getVisualStringWidth(device.name ?? ""))
}
return result
}, [smartDevices, systemId, allSystems])
const openSheet = (disk: SmartDeviceRecord) => {
setActiveDiskId(disk.id)
setSheetOpen(true)
}
// Fetch smart devices
useEffect(() => {
const controller = new AbortController()
pb.collection<SmartDeviceRecord>("smart_devices")
.getFullList({
filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined,
fields: SMART_DEVICE_FIELDS,
signal: controller.signal,
})
.then(setSmartDevices)
.catch((err) => {
if (!err.isAbort) {
setSmartDevices([])
}
})
return () => controller.abort()
}, [systemId])
// Subscribe to updates
useEffect(() => {
let unsubscribe: (() => void) | undefined
const pbOptions = systemId
? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
: { fields: SMART_DEVICE_FIELDS }
;(async () => {
try {
unsubscribe = await pb.collection("smart_devices").subscribe(
"*",
(event) => {
const record = event.record as SmartDeviceRecord
setSmartDevices((currentDevices) => {
const devices = currentDevices ?? []
const matchesSystemScope = !systemId || record.system === systemId
if (event.action === "delete") {
return devices.filter((device) => device.id !== record.id)
}
if (!matchesSystemScope) {
// Record moved out of scope; ensure it disappears locally.
return devices.filter((device) => device.id !== record.id)
}
const existingIndex = devices.findIndex((device) => device.id === record.id)
if (existingIndex === -1) {
return [record, ...devices]
}
const next = [...devices]
next[existingIndex] = record
return next
})
},
pbOptions
)
} catch (error) {
console.error("Failed to subscribe to SMART device updates:", error)
}
})()
return () => {
unsubscribe?.()
}
}, [systemId])
const handleRowRefresh = useCallback(async (disk: SmartDeviceRecord) => {
if (!disk.system) return
setRowActionState({ type: "refresh", id: disk.id })
try {
await pb.send("/api/beszel/smart/refresh", {
method: "POST",
query: { system: disk.system },
})
} catch (error) {
console.error("Failed to refresh SMART device:", error)
} finally {
setRowActionState((state) => (state?.id === disk.id ? null : state))
}
}, [])
const handleDeleteDevice = useCallback(async (disk: SmartDeviceRecord) => {
setRowActionState({ type: "delete", id: disk.id })
try {
await pb.collection("smart_devices").delete(disk.id)
// setSmartDevices((current) => current?.filter((device) => device.id !== disk.id))
} catch (error) {
console.error("Failed to delete SMART device:", error)
} finally {
setRowActionState((state) => (state?.id === disk.id ? null : state))
}
}, [])
const actionColumn = useMemo<ColumnDef<SmartDeviceRecord>>(
() => ({
id: "actions",
enableSorting: false,
header: () => (
<span className="sr-only">
<Trans>Actions</Trans>
</span>
),
cell: ({ row }) => {
const disk = row.original
const isRowRefreshing = rowActionState?.id === disk.id && rowActionState.type === "refresh"
const isRowDeleting = rowActionState?.id === disk.id && rowActionState.type === "delete"
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-10"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation()
handleRowRefresh(disk)
}}
disabled={isRowRefreshing || isRowDeleting}
>
<RefreshCwIcon className={cn("me-2.5 size-4", isRowRefreshing && "animate-spin")} />
<Trans>Refresh</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation()
handleDeleteDevice(disk)
}}
disabled={isRowDeleting}
>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
}),
[handleRowRefresh, handleDeleteDevice, rowActionState]
)
// Filter columns based on whether systemId is provided
const tableColumns = useMemo(() => {
const columns = createColumns(longestName, longestModel, longestDevice)
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
return isReadOnlyUser() ? baseColumns : [...baseColumns, actionColumn]
}, [systemId, actionColumn, longestName, longestModel, longestDevice])
const table = useReactTable({
data: smartDevices || ([] as SmartDeviceRecord[]),
columns: tableColumns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
rowSelection,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
const disk = row.original
const systemName = $allSystemsById.get()[disk.system]?.name ?? ""
const device = disk.name ?? ""
const model = disk.model ?? ""
const status = disk.state ?? ""
const type = disk.type ?? ""
const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase()
return (filterValue as string)
.toLowerCase()
.split(" ")
.every((term) => searchString.includes(term))
},
})
const rows = table.getRowModel().rows
// Hide the table on system pages if there's no data, but always show on global page
if (systemId && !smartDevices?.length && !columnFilters.length) {
return null
}
return (
<div>
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">S.M.A.R.T.</CardTitle>
<CardDescription className="flex">
<Trans>Click on a device to view more information.</Trans>
</CardDescription>
</div>
<div className="relative ms-auto w-full max-w-full md:w-64">
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(event) => setGlobalFilter(event.target.value)}
className="px-4 w-full max-w-full md:w-64"
/>
{globalFilter && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t`Clear`}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
onClick={() => setGlobalFilter("")}
>
<XIcon className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
<SmartDevicesTable
table={table}
rows={rows}
colLength={tableColumns.length}
data={smartDevices}
openSheet={openSheet}
/>
</Card>
<DiskSheet diskId={activeDiskId} open={sheetOpen} onOpenChange={setSheetOpen} />
</div>
)
}
const SmartDevicesTable = memo(function SmartDevicesTable({
table,
rows,
colLength,
data,
openSheet,
}: {
table: TableType<SmartDeviceRecord>
rows: Row<SmartDeviceRecord>[]
colLength: number
data: SmartDeviceRecord[] | undefined
openSheet: (disk: SmartDeviceRecord) => void
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
estimateSize: () => 65,
getScrollElement: () => scrollRef.current,
overscan: 5,
})
const virtualRows = virtualizer.getVirtualItems()
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
return (
<div
className={cn(
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto rounded-md border",
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="w-full text-sm text-nowrap">
<SmartTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return <SmartDeviceTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
})
) : (
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
{data ? (
<Trans>No results.</Trans>
) : (
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
)}
</TableCell>
)}
</TableBody>
</table>
</div>
</div>
)
})
function SmartTableHead({ table }: { table: TableType<SmartDeviceRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="px-2">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
)
}
const SmartDeviceTableRow = memo(function SmartDeviceTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<SmartDeviceRecord>
virtualRow: VirtualItem
openSheet: (disk: SmartDeviceRecord) => void
}) {
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="md:ps-5 py-0"
style={{
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})
function DiskSheet({
diskId,
open,
onOpenChange,
}: {
diskId: string | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [disk, setDisk] = useState<SmartDeviceRecord | null>(null)
const [isLoading, setIsLoading] = useState(false)
// Fetch full device record (including attributes) when sheet opens
useEffect(() => {
if (!diskId) {
setDisk(null)
return
}
// Only fetch when opening, not when closing (keeps data visible during close animation)
if (!open) return
setIsLoading(true)
pb.collection<SmartDeviceRecord>("smart_devices")
.getOne(diskId)
.then(setDisk)
.catch(() => setDisk(null))
.finally(() => setIsLoading(false))
}, [open, diskId])
const smartAttributes = disk?.attributes || []
// Find all attributes where when failed is not empty
const failedAttributes = smartAttributes.filter((attr) => attr.wf && attr.wf.trim() !== "")
// Filter columns to only show those that have values in at least one row
const visibleColumns = useMemo(() => {
return smartColumns.filter((column) => {
const accessorKey = "accessorKey" in column ? (column.accessorKey as keyof SmartAttribute | undefined) : undefined
if (!accessorKey) {
return true
}
// Check if any row has a non-empty value for this column
return smartAttributes.some((attr) => {
return attr[accessorKey] !== undefined
})
})
}, [smartAttributes])
const table = useReactTable({
data: smartAttributes,
columns: visibleColumns,
getCoreRowModel: getCoreRowModel(),
})
const unknown = "Unknown"
const deviceName = disk?.name || unknown
const model = disk?.model || unknown
const capacity = disk?.capacity ? formatCapacity(disk.capacity) : unknown
const serialNumber = disk?.serial
const firmwareVersion = disk?.firmware
const status = disk?.state || unknown
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-220 gap-0">
<SheetHeader className="mb-0 border-b">
<SheetTitle>
<Trans>S.M.A.R.T. Details</Trans> - {deviceName}
</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
{model}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{capacity}
{serialNumber && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{serialNumber}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Serial Number</Trans>
</TooltipContent>
</Tooltip>
</>
)}
{firmwareVersion && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{firmwareVersion}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Firmware</Trans>
</TooltipContent>
</Tooltip>
</>
)}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-hidden p-4 flex flex-col gap-4">
{isLoading ? (
<div className="flex justify-center py-8">
<LoaderCircleIcon className="animate-spin size-10 opacity-60" />
</div>
) : (
<>
<Alert className="pb-3 shrink-0">
{status === "PASSED" ? <CheckCircle2Icon className="size-4" /> : <XCircleIcon className="size-4" />}
<AlertTitle>
<Trans>S.M.A.R.T. Self-Test</Trans>: {status}
</AlertTitle>
{failedAttributes.length > 0 && (
<AlertDescription>
<Trans>Failed Attributes:</Trans> {failedAttributes.map((attr) => attr.n).join(", ")}
</AlertDescription>
)}
</Alert>
{smartAttributes.length > 0 ? (
<div className="rounded-md border min-h-0 flex flex-col">
<Table>
<TableHeader className="sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => {
// Check if the attribute is failed
const isFailedAttribute = row.original.wf && row.original.wf.trim() !== ""
return (
<TableRow key={row.id} className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Trans>No S.M.A.R.T. attributes available for this device.</Trans>
</div>
)}
</>
)}
</div>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,346 @@
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { subscribeKeys } from "nanostores"
import { useEffect, useMemo, useRef, useState } from "react"
import { useContainerChartConfigs } from "@/components/charts/hooks"
import { pb } from "@/lib/api"
import { SystemStatus } from "@/lib/enums"
import {
$allSystemsById,
$allSystemsByName,
$chartTime,
$containerFilter,
$direction,
$maxValues,
$systems,
$userSettings,
} from "@/lib/stores"
import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/utils"
import type {
ChartData,
ContainerStatsRecord,
SystemDetailsRecord,
SystemInfo,
SystemRecord,
SystemStats,
SystemStatsRecord,
} from "@/types"
import { $router, navigate } from "../../router"
import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
export type SystemData = ReturnType<typeof useSystemData>
export function useSystemData(id: string) {
const direction = useStore($direction)
const systems = useStore($systems)
const chartTime = useStore($chartTime)
const maxValues = useStore($maxValues)
const [grid, setGrid] = useBrowserStorage("grid", true)
const [displayMode, setDisplayMode] = useBrowserStorage<"default" | "tabs">("displayMode", "default")
const [activeTab, setActiveTabRaw] = useState("core")
const [mountedTabs, setMountedTabs] = useState(() => new Set<string>(["core"]))
const tabsRef = useRef<string[]>(["core", "disk"])
function setActiveTab(tab: string) {
setActiveTabRaw(tab)
setMountedTabs((prev) => (prev.has(tab) ? prev : new Set([...prev, tab])))
}
const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
const persistChartTime = useRef(false)
const statsRequestId = useRef(0)
const [chartLoading, setChartLoading] = useState(true)
const [details, setDetails] = useState<SystemDetailsRecord>({} as SystemDetailsRecord)
useEffect(() => {
return () => {
if (!persistChartTime.current) {
$chartTime.set($userSettings.get().chartTime)
}
persistChartTime.current = false
setSystemStats([])
setContainerData([])
setDetails({} as SystemDetailsRecord)
$containerFilter.set("")
}
}, [id])
// find matching system and update when it changes
useEffect(() => {
if (!systems.length) {
return
}
// allow old system-name slug to work
const store = $allSystemsById.get()[id] ? $allSystemsById : $allSystemsByName
return subscribeKeys(store, [id], (newSystems) => {
const sys = newSystems[id]
if (sys) {
setSystem(sys)
document.title = `${sys?.name} / Beszel`
}
})
}, [id, systems.length])
// hide 1m chart time if system agent version is less than 0.13.0
useEffect(() => {
if (parseSemVer(system?.info?.v) < parseSemVer("0.13.0")) {
$chartTime.set("1h")
}
}, [system?.info?.v])
// fetch system details
useEffect(() => {
// if system.info.m exists, agent is old version without system details
if (!system.id || system.info?.m) {
return
}
pb.collection<SystemDetailsRecord>("system_details")
.getOne(system.id, {
fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman",
headers: {
"Cache-Control": "public, max-age=60",
},
})
.then(setDetails)
}, [system.id])
// subscribe to realtime metrics if chart time is 1m
useEffect(() => {
let unsub = () => {}
if (!system.id || chartTime !== "1m") {
return
}
if (system.status !== SystemStatus.Up || parseSemVer(system?.info?.v).minor < 13) {
$chartTime.set("1h")
return
}
let isFirst = true
pb.realtime
.subscribe(
`rt_metrics`,
(data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => {
const now = Date.now()
const statsPoint = { created: now, stats: data.stats } as SystemStatsRecord
const containerPoint =
data.container?.length > 0
? makeContainerPoint(now, data.container as unknown as ContainerStatsRecord["stats"])
: null
// on first message, make sure we clear out data from other time periods
if (isFirst) {
isFirst = false
setSystemStats([statsPoint])
setContainerData(containerPoint ? [containerPoint] : [])
return
}
setSystemStats((prev) => appendData(prev, [statsPoint], 1000, 60))
if (containerPoint) {
setContainerData((prev) => appendData(prev, [containerPoint], 1000, 60))
}
},
{ query: { system: system.id } }
)
.then((us) => {
unsub = us
})
return () => {
unsub?.()
}
}, [chartTime, system.id])
const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v])
const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max(
(systemStats.at(-1)?.created as number) ?? 0,
(containerData.at(-1)?.created as number) ?? 0
)
return {
systemStats,
containerData,
chartTime,
orientation: direction === "rtl" ? "right" : "left",
...getTimeData(chartTime, lastCreated),
agentVersion,
}
}, [systemStats, containerData, direction])
// Share chart config computation for all container charts
const containerChartConfigs = useContainerChartConfigs(containerData)
// get stats when system "changes." (Not just system to system,
// also when new info comes in via systemManager realtime connection, indicating an update)
useEffect(() => {
if (!system.id || !chartTime || chartTime === "1m") {
return
}
const systemId = system.id
const { expectedInterval } = chartTimeData[chartTime]
const ss_cache_key = `${systemId}_${chartTime}_system_stats`
const cs_cache_key = `${systemId}_${chartTime}_container_stats`
const requestId = ++statsRequestId.current
const cachedSystemStats = cache.get(ss_cache_key) as SystemStatsRecord[] | undefined
const cachedContainerData = cache.get(cs_cache_key) as ChartData["containerData"] | undefined
// Render from cache immediately if available
if (cachedSystemStats?.length) {
setSystemStats(cachedSystemStats)
setContainerData(cachedContainerData || [])
setChartLoading(false)
// Skip the fetch if the latest cached point is recent enough that no new point is expected yet
const lastCreated = cachedSystemStats.at(-1)?.created as number | undefined
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
return
}
} else {
setChartLoading(true)
}
Promise.allSettled([
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
]).then(([systemStats, containerStats]) => {
// If another request has been made since this one, ignore the results
if (requestId !== statsRequestId.current) {
return
}
setChartLoading(false)
// make new system stats
let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[]
if (systemStats.status === "fulfilled" && systemStats.value.length) {
systemData = appendData(systemData, systemStats.value, expectedInterval, 100)
cache.set(ss_cache_key, systemData)
}
setSystemStats(systemData)
// make new container stats
let containerData = (cache.get(cs_cache_key) || []) as ChartData["containerData"]
if (containerStats.status === "fulfilled" && containerStats.value.length) {
containerData = appendData(containerData, makeContainerData(containerStats.value), expectedInterval, 100)
cache.set(cs_cache_key, containerData)
}
setContainerData(containerData)
})
}, [system, chartTime])
// keyboard navigation between systems
// in tabs mode: arrow keys switch tabs, shift+arrow switches systems
// in default mode: arrow keys switch systems
useEffect(() => {
if (!systems.length) {
return
}
const handleKeyUp = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.ctrlKey ||
e.metaKey ||
e.altKey
) {
return
}
const isLeft = e.key === "ArrowLeft" || e.key === "h"
const isRight = e.key === "ArrowRight" || e.key === "l"
if (!isLeft && !isRight) {
return
}
// in tabs mode, plain arrows switch tabs, shift+arrows switch systems
if (displayMode === "tabs") {
if (!e.shiftKey) {
// skip if focused in tablist (Radix handles it natively)
if (e.target instanceof HTMLElement && e.target.closest('[role="tablist"]')) {
return
}
const tabs = tabsRef.current
const currentIdx = tabs.indexOf(activeTab)
const nextIdx = isLeft ? (currentIdx - 1 + tabs.length) % tabs.length : (currentIdx + 1) % tabs.length
setActiveTab(tabs[nextIdx])
return
}
} else if (e.shiftKey) {
return
}
const currentIndex = systems.findIndex((s) => s.id === id)
if (currentIndex === -1 || systems.length <= 1) {
return
}
if (isLeft) {
const prevIndex = (currentIndex - 1 + systems.length) % systems.length
persistChartTime.current = true
setActiveTabRaw("core")
setMountedTabs(new Set(["core"]))
return navigate(getPagePath($router, "system", { id: systems[prevIndex].id }))
}
if (isRight) {
const nextIndex = (currentIndex + 1) % systems.length
persistChartTime.current = true
setActiveTabRaw("core")
setMountedTabs(new Set(["core"]))
return navigate(getPagePath($router, "system", { id: systems[nextIndex].id }))
}
}
return listen(document, "keyup", handleKeyUp)
}, [id, systems, displayMode, activeTab])
// derived values
const isLongerChart = !["1m", "1h"].includes(chartTime)
const showMax = maxValues && isLongerChart
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
const lastGpus = systemStats.at(-1)?.stats?.g
const isPodman = details?.podman ?? system.info?.p ?? false
let hasGpuData = false
let hasGpuEnginesData = false
let hasGpuPowerData = false
if (lastGpus) {
hasGpuData = Object.keys(lastGpus).length > 0
for (let i = 0; i < systemStats.length && (!hasGpuEnginesData || !hasGpuPowerData); i++) {
const gpus = systemStats[i].stats?.g
if (!gpus) continue
for (const id in gpus) {
if (!hasGpuEnginesData && gpus[id].e !== undefined) {
hasGpuEnginesData = true
}
if (!hasGpuPowerData && (gpus[id].p !== undefined || gpus[id].pp !== undefined)) {
hasGpuPowerData = true
}
if (hasGpuEnginesData && hasGpuPowerData) break
}
}
}
return {
system,
systemStats,
containerData,
chartData,
containerChartConfigs,
details,
grid,
setGrid,
displayMode,
setDisplayMode,
activeTab,
setActiveTab,
mountedTabs,
tabsRef,
maxValues,
isLongerChart,
showMax,
dataEmpty,
isPodman,
lastGpus,
hasGpuData,
hasGpuEnginesData,
hasGpuPowerData,
}
}