This commit is contained in:
Henry Dollman
2024-07-15 23:19:17 -04:00
parent 6696e1c749
commit cdb069e633
11 changed files with 233 additions and 258 deletions

View File

@@ -169,7 +169,7 @@ func main() {
}
func serverUpdateTicker() {
ticker := time.NewTicker(10 * time.Second)
ticker := time.NewTicker(60 * time.Second)
for range ticker.C {
updateServers()
}

View File

@@ -0,0 +1,35 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { $chartTime } from '@/lib/stores'
import { cn } from '@/lib/utils'
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
export default function ChartTimeSelect({ className }: { className?: string }) {
const chartTime = useStore($chartTime)
useEffect(() => {
// todo make sure this doesn't cause multiple fetches on load
return () => $chartTime.set('1h')
}, [])
return (
<Select defaultValue="1h" value={chartTime} onValueChange={(value) => $chartTime.set(value)}>
<SelectTrigger className={cn(className, 'w-40 px-5')}>
<SelectValue placeholder="1h" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">1 hour</SelectItem>
<SelectItem value="12h">12 hours</SelectItem>
<SelectItem value="24h">24 hours</SelectItem>
<SelectItem value="1w">1 week</SelectItem>
<SelectItem value="30d">30 days</SelectItem>
</SelectContent>
</Select>
)
}

View File

@@ -8,9 +8,6 @@ import {
} from '@/components/ui/chart'
import { formatShortDate, formatShortTime } from '@/lib/utils'
import Spinner from '../spinner'
// for (const data of chartData) {
// data.month = formatDateShort(data.month)
// }
const chartConfig = {
cpu: {

View File

@@ -1,93 +0,0 @@
import { TrendingUp } from 'lucide-react'
import { Label, PolarRadiusAxis, RadialBar, RadialBarChart } from 'recharts'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
const chartData = [{ month: 'january', desktop: 1260, mobile: 570 }]
const chartConfig = {
mobile: {
label: 'Mobile',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig
export function RadialChart() {
const totalVisitors = chartData[0].desktop + chartData[0].mobile
return (
<Card className="flex flex-col">
<CardHeader className="items-center pb-0">
<CardTitle>Radial Chart - Stacked</CardTitle>
<CardDescription>January - June 2024</CardDescription>
</CardHeader>
<CardContent className="flex flex-1 items-center pb-0">
<ChartContainer config={chartConfig} className="mx-auto aspect-square w-full max-w-[250px]">
<RadialBarChart data={chartData} endAngle={180} innerRadius={80} outerRadius={130}>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle">
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) - 16}
className="fill-foreground text-2xl font-bold"
>
{totalVisitors.toLocaleString()}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 4}
className="fill-muted-foreground"
>
Visitors
</tspan>
</text>
)
}
}}
/>
</PolarRadiusAxis>
<RadialBar
dataKey="desktop"
stackId="a"
cornerRadius={5}
fill="var(--color-desktop)"
className="stroke-transparent stroke-2"
/>
<RadialBar
dataKey="mobile"
fill="var(--color-mobile)"
stackId="a"
cornerRadius={5}
className="stroke-transparent stroke-2"
/>
</RadialBarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
Trending up by 5.2% this month <TrendingUp className="h-4 w-4" />
</div>
<div className="leading-none text-muted-foreground">
Showing total visitors for the last 6 months
</div>
</CardFooter>
</Card>
)
}

View File

@@ -1,24 +1,13 @@
import { $systems, pb } from '@/lib/stores'
import { $updatedSystem, $systems, pb } from '@/lib/stores'
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
import { Suspense, lazy, useEffect, useState } from 'react'
import { Suspense, lazy, useEffect, useMemo, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
import { useStore } from '@nanostores/react'
import Spinner from '../spinner'
// import CpuChart from '../charts/cpu-chart'
// import MemChart from '../charts/mem-chart'
// import DiskChart from '../charts/disk-chart'
// import ContainerCpuChart from '../charts/container-cpu-chart'
// import ContainerMemChart from '../charts/container-mem-chart'
import { CpuIcon, MemoryStickIcon, ServerIcon } from 'lucide-react'
import { RadialChart } from '../charts/radial'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { ClockArrowUp, CpuIcon, GlobeIcon } from 'lucide-react'
import ChartTimeSelect from '../charts/chart-time-select'
import { cn, getPbTimestamp } from '@/lib/utils'
import { Separator } from '../ui/separator'
const CpuChart = lazy(() => import('../charts/cpu-chart'))
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
@@ -31,15 +20,9 @@ function timestampToBrowserTime(timestamp: string) {
return date.toLocaleString()
}
// function addColors(objects: Record<string, any>[]) {
// objects.forEach((obj, index) => {
// const hue = ((index * 360) / objects.length) % 360 // Distribute hues evenly
// obj.fill = `hsl(${hue}, 100%, 50%)` // Set fill to HSL color with full saturation and 50% lightness
// })
// }
export default function ServerDetail({ name }: { name: string }) {
const servers = useStore($systems)
const updatedSystem = useStore($updatedSystem)
const [server, setServer] = useState({} as SystemRecord)
const [containers, setContainers] = useState([] as ContainerStatsRecord[])
@@ -59,38 +42,60 @@ export default function ServerDetail({ name }: { name: string }) {
)
useEffect(() => {
document.title = `${name} / Qoma`
return () => {
setContainerCpuChartData([])
setCpuChartData([])
setMemChartData([])
setDiskChartData([])
document.title = `${name} / Beszel`
if (server?.id && server.name === name) {
return
}
}, [name])
const matchingServer = servers.find((s) => s.name === name) as SystemRecord
if (matchingServer) {
setServer(matchingServer)
}
}, [name, server])
// if visiting directly, make sure server gets set when servers are loaded
// useEffect(() => {
// if (!('id' in server)) {
// const matchingServer = servers.find((s) => s.name === name) as SystemRecord
// if (matchingServer) {
// console.log('setting server')
// setServer(matchingServer)
// }
// }
// }, [servers])
// get stats
useEffect(() => {
if (!('name' in server)) {
if (!('id' in server)) {
console.log('no id in server')
return
} else {
console.log('id in server')
}
pb.collection<SystemStatsRecord>('system_stats')
.getList(1, 60, {
filter: `system="${server.id}"`,
.getFullList({
filter: `system="${server.id}" && created > "${getPbTimestamp('1h')}"`,
fields: 'created,stats',
sort: '-created',
})
.then((records) => {
// console.log('sctats', records)
setServerStats(records.items)
setServerStats(records)
})
}, [server, servers])
}, [server])
useEffect(() => {
if (updatedSystem.id === server.id) {
setServer(updatedSystem)
}
}, [updatedSystem])
// get cpu data
useEffect(() => {
if (!serverStats.length) {
return
}
console.log('stats', serverStats)
// let maxCpu = 0
const cpuData = [] as { time: string; cpu: number }[]
const memData = [] as { time: string; mem: number; memUsed: number; memCache: number }[]
@@ -107,26 +112,17 @@ export default function ServerDetail({ name }: { name: string }) {
}, [serverStats])
useEffect(() => {
if ($systems.get().length === 0) {
// console.log('skipping')
return
}
// console.log('running')
const matchingServer = servers.find((s) => s.name === name) as SystemRecord
// console.log('found server', matchingServer)
setServer(matchingServer)
pb.collection<ContainerStatsRecord>('container_stats')
.getList(1, 60, {
filter: `system="${matchingServer.id}"`,
.getFullList({
filter: `system="${server.id}" && created > "${getPbTimestamp('1h')}"`,
fields: 'created,stats',
sort: '-created',
})
.then((records) => {
// console.log('records', records)
setContainers(records.items)
// console.log('containers', records)
setContainers(records)
})
}, [servers, name])
}, [server])
// container stats for charts
useEffect(() => {
@@ -148,98 +144,85 @@ export default function ServerDetail({ name }: { name: string }) {
setContainerCpuChartData(containerCpuData.reverse())
setContainerMemChartData(containerMemData.reverse())
}, [containers])
const uptime = useMemo(() => {
console.log('making uptime')
let uptime = server.info?.u || 0
if (uptime < 172800) {
return `${Math.floor(uptime / 3600)} hours`
}
return `${Math.floor(server.info?.u / 86400)} days`
}, [server.info?.u])
if (!('id' in server)) {
return null
}
return (
<>
<div className="grid gap-6 mb-10 grid-cols-3">
<Card className="col-span-full">
<CardHeader>
<CardTitle className="flex gap-2 items-center text-3xl">{name}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between gap-6">
<p>{server.status}</p>
<p>Uptime {(server.info?.u / 86400).toLocaleString()} days</p>
<p>
{server.info?.m} ({server.info?.c} cores / {server.info?.t} threads)
</p>
</CardContent>
</Card>
<RadialChart />
<RadialChart />
<RadialChart />
<div className="grid gap-5 mb-10">
<Card>
<div className="grid gap-1.5 px-6 pt-4 pb-5">
<h1 className="text-[1.6rem] font-semibold">{server.name}</h1>
<div className="flex flex-wrap items-center gap-3 text-sm opacity-90">
<div className="capitalize flex gap-2 items-center">
<span className={cn('relative flex h-3 w-3')}>
{server.status === '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': server.status === 'up',
'bg-red-500': server.status === 'down',
'bg-primary/40': server.status === 'paused',
'bg-yellow-500': server.status === 'pending',
})}
></span>
</span>
{server.status}
</div>
<Separator orientation="vertical" className="h-4 bg-primary/30" />
<div className="flex gap-1.5 items-center">
<GlobeIcon className="h-4 w-4" /> {server.host}
</div>
<Separator orientation="vertical" className="h-4 bg-primary/30" />
<div className="flex gap-1.5 items-center">
<ClockArrowUp className="h-4 w-4" /> {uptime}
</div>
<Separator orientation="vertical" className="h-4 bg-primary/30" />
<div className="flex gap-1.5 items-center">
<CpuIcon className="h-4 w-4" />
{server.info?.m} ({server.info?.c}c / {server.info.t}t)
</div>
</div>
</div>
</Card>
<Card className="pb-3 col-span-full">
<CardHeader className="pb-5">
<CardTitle className="flex gap-2 justify-between">
<span>Total CPU Usage</span>
<CpuIcon className="opacity-70" />
</CardTitle>
<CardDescription>
System-wide CPU utilization of the preceding one minute as a percentage
</CardDescription>
</CardHeader>
<CardContent className={'pl-1 w-[calc(100%-2em)] h-52 relative'}>
<Suspense fallback={<Spinner />}>
<CpuChart chartData={cpuChartData} />
</Suspense>
</CardContent>
</Card>
{containerCpuChartData.length > 0 && (
<Card className="pb-3 col-span-full">
<CardHeader className="pb-5">
<CardTitle className="flex gap-2 justify-between">
<span>Docker CPU Usage</span>
<CpuIcon className="opacity-70" />
</CardTitle>{' '}
<CardDescription>CPU utilization of docker containers</CardDescription>
</CardHeader>
<CardContent className={'pl-1 w-[calc(100%-2em)] h-52 relative'}>
<Suspense fallback={<Spinner />}>
<ContainerCpuChart chartData={containerCpuChartData} />
</Suspense>
</CardContent>
</Card>
)}
<Card className="pb-3 col-span-full">
<CardHeader className="pb-5">
<CardTitle>Total Memory Usage</CardTitle>
<CardDescription>Precise utilization at the recorded time</CardDescription>
</CardHeader>
<CardContent className={'pl-1 w-[calc(100%-2em)] h-52 relative'}>
<Suspense fallback={<Spinner />}>
<MemChart chartData={memChartData} />
</Suspense>
</CardContent>
</Card>
{containerMemChartData.length > 0 && (
<Card className="pb-3 col-span-full">
<CardHeader className="pb-5">
<CardTitle className="flex gap-2 justify-between">
<span>Docker Memory Usage</span>
<MemoryStickIcon className="opacity-70" />
</CardTitle>{' '}
<CardDescription>Memory usage of docker containers</CardDescription>
</CardHeader>
<CardContent className={'pl-1 w-[calc(100%-2em)] h-52 relative'}>
<Suspense fallback={<Spinner />}>
<ContainerMemChart chartData={containerMemChartData} />
</Suspense>
</CardContent>
</Card>
)}
<Card className="pb-3 col-span-full">
<CardHeader className="pb-5">
<CardTitle>Disk Usage</CardTitle>
<CardDescription>Precise usage at the recorded time</CardDescription>
</CardHeader>
<CardContent className={'pl-1 w-[calc(100%-2em)] h-52 relative'}>
<Suspense fallback={<Spinner />}>
<DiskChart chartData={diskChartData} />
</Suspense>
</CardContent>
</Card>
</div>
<ChartCard
title="Total CPU Usage"
description="Average system-wide CPU utilization as a percentage"
>
<CpuChart chartData={cpuChartData} />
</ChartCard>
{containerCpuChartData.length > 0 && (
<ChartCard title="Docker CPU Usage" description="CPU utilization of docker containers">
<ContainerCpuChart chartData={containerCpuChartData} />
</ChartCard>
)}
<ChartCard title="Total Memory Usage" description="Precise utilization at the recorded time">
<MemChart chartData={memChartData} />
</ChartCard>
{containerMemChartData.length > 0 && (
<ChartCard title="Docker Memory Usage" description="Memory usage of docker containers">
<ContainerMemChart chartData={containerMemChartData} />
</ChartCard>
)}
<ChartCard title="Disk Usage" description="Precise usage at the recorded time">
<DiskChart chartData={diskChartData} />
</ChartCard>
<Card>
<CardHeader>
<CardTitle className={'mb-3'}>{server.name}</CardTitle>
@@ -251,15 +234,31 @@ export default function ServerDetail({ name }: { name: string }) {
<pre>{JSON.stringify(server, null, 2)}</pre>
</CardContent>
</Card>
{/* <Card>
<CardHeader>
<CardTitle className={'mb-3'}>Containers</CardTitle>
</CardHeader>
<CardContent>
<pre>{JSON.stringify(containers, null, 2)}</pre>
</CardContent>
</Card> */}
</>
</div>
)
}
function ChartCard({
title,
description,
children,
}: {
title: string
description: string
children: React.ReactNode
}) {
return (
<Card className="pb-4 col-span-full">
<CardHeader className="pb-5 pt-4">
<CardTitle className="flex gap-2 items-center justify-between -mb-1.5">
{title}
<ChartTimeSelect className="translate-y-1" />
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className={'pl-1 w-[calc(100%-1.6em)] h-52 relative'}>
<Suspense fallback={<Spinner />}>{children}</Suspense>
</CardContent>
</Card>
)
}

View File

@@ -66,7 +66,8 @@ import AlertsButton from '../table-alerts'
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
const val = info.getValue() as number
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight">
<div className="flex gap-1 items-center tabular-nums tracking-tight">
<span className="w-16">{val.toFixed(2)}%</span>
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
<span
className={cn(
@@ -76,7 +77,6 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
style={{ transform: `scalex(${val}%)` }}
></span>
</span>
<span className="w-16">{val.toFixed(2)}%</span>
</div>
)
}

View File

@@ -3,7 +3,7 @@
@tailwind utilities;
@layer base {
:root {
--background: 30 8% 97.45%;
--background: 30 8% 98.5%;
--foreground: 30 0% 0%;
--card: 30 0% 100%;
--card-foreground: 240 6.67% 2.94%;

View File

@@ -25,8 +25,14 @@ export const $authenticated = atom(pb.authStore.isValid)
/** List of system records */
export const $systems = atom([] as SystemRecord[])
/** Last updated system record (realtime) */
export const $updatedSystem = atom({} as SystemRecord)
/** List of alert records */
export const $alerts = atom([] as AlertRecord[])
/** SSH public key */
export const $publicKey = atom('')
/** Chart time period */
export const $chartTime = atom('1h')

View File

@@ -96,3 +96,25 @@ export function updateRecordList<T extends RecordModel>(
}
$store.set(newRecords)
}
export function getPbTimestamp(timeString: string) {
const now = new Date()
let timeValue = parseInt(timeString.slice(0, -1))
let unit = timeString.slice(-1)
if (unit === 'h') {
now.setUTCHours(now.getUTCHours() - timeValue)
} else {
// d
now.setUTCDate(now.getUTCDate() - timeValue)
}
const year = now.getUTCFullYear()
const month = String(now.getUTCMonth() + 1).padStart(2, '0')
const day = String(now.getUTCDate()).padStart(2, '0')
const hours = String(now.getUTCHours()).padStart(2, '0')
const minutes = String(now.getUTCMinutes()).padStart(2, '0')
const seconds = String(now.getUTCSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}

View File

@@ -3,7 +3,15 @@ import React, { Suspense, lazy, useEffect } from 'react'
import ReactDOM from 'react-dom/client'
import Home from './components/routes/home.tsx'
import { ThemeProvider } from './components/theme-provider.tsx'
import { $alerts, $authenticated, $router, $systems, navigate, pb } from './lib/stores.ts'
import {
$alerts,
$authenticated,
$updatedSystem,
$router,
$systems,
navigate,
pb,
} from './lib/stores.ts'
import { ModeToggle } from './components/mode-toggle.tsx'
import {
cn,
@@ -53,6 +61,7 @@ const App = () => {
// subscribe to real time updates for systems / alerts
pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
updateRecordList(e, $systems)
$updatedSystem.set(e.record)
})
pb.collection<AlertRecord>('alerts').subscribe('*', (e) => {
updateRecordList(e, $alerts)

View File

@@ -18,14 +18,14 @@ type SystemData struct {
}
type SystemInfo struct {
Cores int `json:"c"`
Threads int `json:"t"`
CpuModel string `json:"m"`
Os string `json:"o"`
Uptime uint64 `json:"u"`
Cpu float64 `json:"cpu"`
MemPct float64 `json:"mp"`
DiskPct float64 `json:"dp"`
Cores int `json:"c"`
Threads int `json:"t"`
CpuModel string `json:"m"`
// Os string `json:"o"`
Uptime uint64 `json:"u"`
Cpu float64 `json:"cpu"`
MemPct float64 `json:"mp"`
DiskPct float64 `json:"dp"`
}
type SystemStats struct {