mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-04 13:22:57 +00:00
Initial commit: Beszel fork with Domain Locker integration
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user