This commit is contained in:
Henry Dollman
2024-07-16 23:31:38 -04:00
parent 4f3796e9bc
commit 8b09b5092e
14 changed files with 296 additions and 131 deletions

16
main.go
View File

@@ -5,6 +5,7 @@ import (
"crypto/ed25519" "crypto/ed25519"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
"log" "log"
_ "monitor-site/migrations" _ "monitor-site/migrations"
@@ -151,6 +152,12 @@ func main() {
if newStatus == "down" || newStatus == "paused" { if newStatus == "down" || newStatus == "paused" {
deleteServerConnection(newRecord) deleteServerConnection(newRecord)
} }
// if server is set to pending, try to connect
if newStatus == "pending" {
go updateServer(newRecord)
}
// alerts // alerts
handleStatusAlerts(newStatus, oldRecord) handleStatusAlerts(newStatus, oldRecord)
return nil return nil
@@ -218,6 +225,13 @@ func updateServer(record *models.Record) {
// get server stats from agent // get server stats from agent
systemData, err := requestJson(&server) systemData, err := requestJson(&server)
if err != nil { if err != nil {
if err.Error() == "retry" {
// if previous connection was closed, try again
app.Logger().Error("Existing SSH connection closed. Retrying...", "host", server.Host, "port", server.Port)
deleteServerConnection(record)
updateServer(record)
return
}
app.Logger().Error("Failed to get server stats: ", "err", err.Error()) app.Logger().Error("Failed to get server stats: ", "err", err.Error())
updateServerStatus(record, "down") updateServerStatus(record, "down")
return return
@@ -307,7 +321,7 @@ func getServerConnection(server *Server) (*ssh.Client, error) {
func requestJson(server *Server) (SystemData, error) { func requestJson(server *Server) (SystemData, error) {
session, err := server.Client.NewSession() session, err := server.Client.NewSession()
if err != nil { if err != nil {
return SystemData{}, err return SystemData{}, errors.New("retry")
} }
defer session.Close() defer session.Close()

Binary file not shown.

View File

@@ -26,6 +26,8 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"lucide-react": "^0.407.0", "lucide-react": "^0.407.0",
"nanostores": "^0.10.3", "nanostores": "^0.10.3",
"pocketbase": "^0.21.3", "pocketbase": "^0.21.3",

View File

@@ -6,7 +6,8 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
import { cn } from '@/lib/utils' import { chartTimeData, cn } from '@/lib/utils'
import { ChartTimes } from '@/types'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { useEffect } from 'react' import { useEffect } from 'react'
@@ -19,16 +20,20 @@ export default function ChartTimeSelect({ className }: { className?: string }) {
}, []) }, [])
return ( return (
<Select defaultValue="1h" value={chartTime} onValueChange={(value) => $chartTime.set(value)}> <Select
defaultValue="1h"
value={chartTime}
onValueChange={(value: ChartTimes) => $chartTime.set(value)}
>
<SelectTrigger className={cn(className, 'w-40 px-5')}> <SelectTrigger className={cn(className, 'w-40 px-5')}>
<SelectValue placeholder="1h" /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1h">1 hour</SelectItem> {Object.entries(chartTimeData).map(([value, { label }]) => (
<SelectItem value="12h">12 hours</SelectItem> <SelectItem key={label} value={value}>
<SelectItem value="24h">24 hours</SelectItem> {label}
<SelectItem value="1w">1 week</SelectItem> </SelectItem>
<SelectItem value="30d">30 days</SelectItem> ))}
</SelectContent> </SelectContent>
</Select> </Select>
) )

View File

@@ -8,14 +8,20 @@ import {
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { useMemo } from 'react' import { useMemo } from 'react'
import { calculateXaxisTicks, formatShortDate, formatShortTime } from '@/lib/utils' import { formatShortDate, hourWithMinutes } from '@/lib/utils'
import Spinner from '../spinner' import Spinner from '../spinner'
export default function ContainerCpuChart({ export default function ContainerCpuChart({
chartData, chartData,
ticks,
}: { }: {
chartData: Record<string, number | string>[] chartData: Record<string, number | string>[]
ticks: number[]
}) { }) {
if (!chartData.length || !ticks.length) {
return <Spinner />
}
const chartConfig = useMemo(() => { const chartConfig = useMemo(() => {
let config = {} as Record< let config = {} as Record<
string, string,
@@ -45,18 +51,12 @@ export default function ContainerCpuChart({
const hue = ((i * 360) / length) % 360 const hue = ((i * 360) / length) % 360
config[key] = { config[key] = {
label: key, label: key,
color: `hsl(${hue}, 60%, 60%)`, color: `hsl(${hue}, 60%, 55%)`,
} }
} }
return config satisfies ChartConfig return config satisfies ChartConfig
}, [chartData]) }, [chartData])
const ticks = useMemo(() => calculateXaxisTicks(chartData), [chartData])
if (!chartData.length) {
return <Spinner />
}
return ( return (
<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto"> <ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto">
<AreaChart <AreaChart
@@ -86,7 +86,7 @@ export default function ContainerCpuChart({
axisLine={false} axisLine={false}
tickMargin={8} tickMargin={8}
minTickGap={30} minTickGap={30}
tickFormatter={formatShortTime} tickFormatter={hourWithMinutes}
/> />
<ChartTooltip <ChartTooltip
// cursor={false} // cursor={false}
@@ -100,9 +100,10 @@ export default function ContainerCpuChart({
{Object.keys(chartConfig).map((key) => ( {Object.keys(chartConfig).map((key) => (
<Area <Area
key={key} key={key}
// isAnimationActive={chartData.length < 20}
animateNewValues={false} animateNewValues={false}
dataKey={key} dataKey={key}
type="monotone" type="monotoneX"
fill={chartConfig[key].color} fill={chartConfig[key].color}
fillOpacity={0.4} fillOpacity={0.4}
stroke={chartConfig[key].color} stroke={chartConfig[key].color}

View File

@@ -8,11 +8,17 @@ import {
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { useMemo } from 'react' import { useMemo } from 'react'
import { calculateXaxisTicks, formatShortDate, formatShortTime } from '@/lib/utils' import { formatShortDate, hourWithMinutes } from '@/lib/utils'
import Spinner from '../spinner' import Spinner from '../spinner'
export default function ({ chartData }: { chartData: Record<string, number | string>[] }) { export default function ({
if (!chartData.length) { chartData,
ticks,
}: {
chartData: Record<string, number | string>[]
ticks: number[]
}) {
if (!chartData.length || !ticks.length) {
return <Spinner /> return <Spinner />
} }
@@ -45,14 +51,12 @@ export default function ({ chartData }: { chartData: Record<string, number | str
const hue = ((i * 360) / length) % 360 const hue = ((i * 360) / length) % 360
config[key] = { config[key] = {
label: key, label: key,
color: `hsl(${hue}, 60%, 60%)`, color: `hsl(${hue}, 60%, 55%)`,
} }
} }
return config satisfies ChartConfig return config satisfies ChartConfig
}, [chartData]) }, [chartData])
const ticks = useMemo(() => calculateXaxisTicks(chartData), [chartData])
return ( return (
<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto"> <ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto">
<AreaChart <AreaChart
@@ -72,7 +76,7 @@ export default function ({ chartData }: { chartData: Record<string, number | str
allowDecimals={false} allowDecimals={false}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' GiB'} unit={' GB'}
tickFormatter={(x) => { tickFormatter={(x) => {
x = x / 1024 x = x / 1024
return x % 1 === 0 ? x : x.toFixed(1) return x % 1 === 0 ? x : x.toFixed(1)
@@ -88,7 +92,7 @@ export default function ({ chartData }: { chartData: Record<string, number | str
axisLine={false} axisLine={false}
tickMargin={8} tickMargin={8}
minTickGap={30} minTickGap={30}
tickFormatter={formatShortTime} tickFormatter={hourWithMinutes}
/> />
<ChartTooltip <ChartTooltip
// cursor={false} // cursor={false}
@@ -102,10 +106,10 @@ export default function ({ chartData }: { chartData: Record<string, number | str
{Object.keys(chartConfig).map((key) => ( {Object.keys(chartConfig).map((key) => (
<Area <Area
key={key} key={key}
// isAnimationActive={false} isAnimationActive={chartData.length < 20}
animateNewValues={false} animateNewValues={false}
dataKey={key} dataKey={key}
type="monotone" type="monotoneX"
fill={chartConfig[key].color} fill={chartConfig[key].color}
fillOpacity={0.4} fillOpacity={0.4}
stroke={chartConfig[key].color} stroke={chartConfig[key].color}

View File

@@ -6,9 +6,10 @@ import {
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { calculateXaxisTicks, formatShortDate, formatShortTime } from '@/lib/utils' import { chartTimeData, formatShortDate } from '@/lib/utils'
import Spinner from '../spinner' import Spinner from '../spinner'
import { useMemo } from 'react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
const chartConfig = { const chartConfig = {
cpu: { cpu: {
@@ -17,12 +18,17 @@ const chartConfig = {
}, },
} satisfies ChartConfig } satisfies ChartConfig
export default function CpuChart({ chartData }: { chartData: { time: number; cpu: number }[] }) { export default function CpuChart({
if (!chartData?.length) { chartData,
ticks,
}: {
chartData: { time: number; cpu: number }[]
ticks: number[]
}) {
if (!chartData.length || !ticks.length) {
return <Spinner /> return <Spinner />
} }
const chartTime = useStore($chartTime)
const ticks = useMemo(() => calculateXaxisTicks(chartData), [chartData])
return ( return (
<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto"> <ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto">
@@ -42,10 +48,10 @@ export default function CpuChart({ chartData }: { chartData: { time: number; cpu
ticks={ticks} ticks={ticks}
type="number" type="number"
scale={'time'} scale={'time'}
axisLine={false}
tickMargin={8}
minTickGap={35} minTickGap={35}
tickFormatter={formatShortTime} tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/> />
<ChartTooltip <ChartTooltip
animationEasing="ease-out" animationEasing="ease-out"
@@ -60,7 +66,7 @@ export default function CpuChart({ chartData }: { chartData: { time: number; cpu
/> />
<Area <Area
dataKey="cpu" dataKey="cpu"
type="monotone" type="monotoneX"
fill="var(--color-cpu)" fill="var(--color-cpu)"
fillOpacity={0.4} fillOpacity={0.4}
stroke="var(--color-cpu)" stroke="var(--color-cpu)"

View File

@@ -6,26 +6,25 @@ import {
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { calculateXaxisTicks, formatShortDate, formatShortTime } from '@/lib/utils' import { formatShortDate, hourWithMinutes } from '@/lib/utils'
import { useMemo } from 'react' import { useMemo } from 'react'
import Spinner from '../spinner' import Spinner from '../spinner'
// for (const data of chartData) {
// data.month = formatDateShort(data.month)
// }
const chartConfig = { const chartConfig = {
diskUsed: { diskUsed: {
label: 'Disk Usage', label: 'Disk Usage',
color: 'hsl(var(--chart-3))', color: 'hsl(var(--chart-4))',
}, },
} satisfies ChartConfig } satisfies ChartConfig
export default function DiskChart({ export default function DiskChart({
chartData, chartData,
ticks,
}: { }: {
chartData: { time: number; disk: number; diskUsed: number }[] chartData: { time: number; disk: number; diskUsed: number }[]
ticks: number[]
}) { }) {
if (!chartData.length) { if (!chartData.length || !ticks.length) {
return <Spinner /> return <Spinner />
} }
@@ -33,8 +32,6 @@ export default function DiskChart({
return Math.round(chartData[0]?.disk) return Math.round(chartData[0]?.disk)
}, [chartData]) }, [chartData])
const ticks = useMemo(() => calculateXaxisTicks(chartData), [chartData])
// const ticks = useMemo(() => { // const ticks = useMemo(() => {
// let ticks = [0] // let ticks = [0]
// for (let i = 1; i < diskSize; i += diskSize / 5) { // for (let i = 1; i < diskSize; i += diskSize / 5) {
@@ -65,7 +62,7 @@ export default function DiskChart({
minTickGap={8} minTickGap={8}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' GiB'} unit={' GB'}
/> />
{/* todo: short time if first date is same day, otherwise short date */} {/* todo: short time if first date is same day, otherwise short date */}
<XAxis <XAxis
@@ -78,13 +75,13 @@ export default function DiskChart({
axisLine={false} axisLine={false}
tickMargin={8} tickMargin={8}
minTickGap={30} minTickGap={30}
tickFormatter={formatShortTime} tickFormatter={hourWithMinutes}
/> />
<ChartTooltip <ChartTooltip
cursor={false} // cursor={false}
content={ content={
<ChartTooltipContent <ChartTooltipContent
unit=" GiB" unit=" GB"
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
indicator="line" indicator="line"
/> />
@@ -92,7 +89,7 @@ export default function DiskChart({
/> />
<Area <Area
dataKey="diskUsed" dataKey="diskUsed"
type="monotone" type="monotoneX"
fill="var(--color-diskUsed)" fill="var(--color-diskUsed)"
fillOpacity={0.4} fillOpacity={0.4}
stroke="var(--color-diskUsed)" stroke="var(--color-diskUsed)"

View File

@@ -0,0 +1,97 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { formatShortDate, hourWithMinutes } from '@/lib/utils'
import Spinner from '../spinner'
const chartConfig = {
read: {
label: 'Read',
color: 'hsl(var(--chart-5))',
},
write: {
label: 'Write',
color: 'hsl(var(--chart-3))',
},
} satisfies ChartConfig
export default function DiskIoChart({
chartData,
ticks,
}: {
chartData: { time: number; read: number; write: number }[]
ticks: number[]
}) {
if (!chartData.length || !ticks.length) {
return <Spinner />
}
return (
<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto">
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<YAxis
width={75}
domain={[0, 'auto']}
// ticks={ticks}
tickCount={9}
minTickGap={8}
tickLine={false}
axisLine={false}
unit={' MB/s'}
/>
{/* todo: short time if first date is same day, otherwise short date */}
<XAxis
dataKey="time"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
tickLine={true}
axisLine={false}
tickMargin={8}
minTickGap={30}
tickFormatter={hourWithMinutes}
/>
<ChartTooltip
// cursor={false}
content={
<ChartTooltipContent
unit=" MB/s"
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
indicator="line"
/>
}
/>
<Area
dataKey="read"
type="monotoneX"
fill="var(--color-read)"
fillOpacity={0.4}
stroke="var(--color-read)"
/>
<Area
dataKey="write"
type="monotoneX"
fill="var(--color-write)"
fillOpacity={0.4}
stroke="var(--color-write)"
/>
</AreaChart>
</ChartContainer>
)
}

View File

@@ -6,21 +6,21 @@ import {
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { calculateXaxisTicks, formatShortDate, formatShortTime } from '@/lib/utils' import { formatShortDate, hourWithMinutes } from '@/lib/utils'
import { useMemo } from 'react' import { useMemo } from 'react'
import Spinner from '../spinner' import Spinner from '../spinner'
export default function MemChart({ export default function MemChart({
chartData, chartData,
ticks,
}: { }: {
chartData: { time: number; mem: number; memUsed: number; memCache: number }[] chartData: { time: number; mem: number; memUsed: number; memCache: number }[]
ticks: number[]
}) { }) {
if (!chartData.length) { if (!chartData.length || !ticks.length) {
return <Spinner /> return <Spinner />
} }
const ticks = useMemo(() => calculateXaxisTicks(chartData), [chartData])
const totalMem = useMemo(() => { const totalMem = useMemo(() => {
return Math.ceil(chartData[0]?.mem) return Math.ceil(chartData[0]?.mem)
}, [chartData]) }, [chartData])
@@ -56,7 +56,7 @@ export default function MemChart({
tickLine={false} tickLine={false}
allowDecimals={false} allowDecimals={false}
axisLine={false} axisLine={false}
tickFormatter={(v) => `${v} GiB`} tickFormatter={(v) => `${v} GB`}
/> />
{/* todo: short time if first date is same day, otherwise short date */} {/* todo: short time if first date is same day, otherwise short date */}
<XAxis <XAxis
@@ -69,7 +69,7 @@ export default function MemChart({
axisLine={false} axisLine={false}
tickMargin={8} tickMargin={8}
minTickGap={30} minTickGap={30}
tickFormatter={formatShortTime} tickFormatter={hourWithMinutes}
/> />
<ChartTooltip <ChartTooltip
// cursor={false} // cursor={false}
@@ -77,7 +77,7 @@ export default function MemChart({
animationDuration={150} animationDuration={150}
content={ content={
<ChartTooltipContent <ChartTooltipContent
unit="GiB" unit="GB"
// @ts-ignore // @ts-ignore
itemSorter={(a, b) => a.name.localeCompare(b.name)} itemSorter={(a, b) => a.name.localeCompare(b.name)}
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
@@ -87,7 +87,7 @@ export default function MemChart({
/> />
<Area <Area
dataKey="memUsed" dataKey="memUsed"
type="monotone" type="monotoneX"
fill="var(--color-memUsed)" fill="var(--color-memUsed)"
fillOpacity={0.4} fillOpacity={0.4}
stroke="var(--color-memUsed)" stroke="var(--color-memUsed)"
@@ -95,7 +95,7 @@ export default function MemChart({
/> />
<Area <Area
dataKey="memCache" dataKey="memCache"
type="monotone" type="monotoneX"
fill="var(--color-memCache)" fill="var(--color-memCache)"
fillOpacity={0.2} fillOpacity={0.2}
strokeOpacity={0.3} strokeOpacity={0.3}

View File

@@ -1,4 +1,4 @@
import { $updatedSystem, $systems, pb } from '@/lib/stores' import { $updatedSystem, $systems, pb, $chartTime } from '@/lib/stores'
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types' import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
import { Suspense, lazy, useEffect, useMemo, useState } from 'react' import { Suspense, lazy, useEffect, useMemo, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
@@ -6,8 +6,10 @@ import { useStore } from '@nanostores/react'
import Spinner from '../spinner' import Spinner from '../spinner'
import { ClockArrowUp, CpuIcon, GlobeIcon } from 'lucide-react' import { ClockArrowUp, CpuIcon, GlobeIcon } from 'lucide-react'
import ChartTimeSelect from '../charts/chart-time-select' import ChartTimeSelect from '../charts/chart-time-select'
import { cn, getPbTimestamp } from '@/lib/utils' import { chartTimeData, cn, getPbTimestamp } from '@/lib/utils'
import { Separator } from '../ui/separator' import { Separator } from '../ui/separator'
import { scaleTime } from 'd3-scale'
import DiskIoChart from '../charts/disk-io-chart'
const CpuChart = lazy(() => import('../charts/cpu-chart')) const CpuChart = lazy(() => import('../charts/cpu-chart'))
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart')) const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
@@ -23,6 +25,8 @@ function timestampToBrowserTime(timestamp: string) {
export default function ServerDetail({ name }: { name: string }) { export default function ServerDetail({ name }: { name: string }) {
const servers = useStore($systems) const servers = useStore($systems)
const updatedSystem = useStore($updatedSystem) const updatedSystem = useStore($updatedSystem)
const chartTime = useStore($chartTime)
const [ticks, setTicks] = useState([] as number[])
const [server, setServer] = useState({} as SystemRecord) const [server, setServer] = useState({} as SystemRecord)
const [containers, setContainers] = useState([] as ContainerStatsRecord[]) const [containers, setContainers] = useState([] as ContainerStatsRecord[])
@@ -34,6 +38,9 @@ export default function ServerDetail({ name }: { name: string }) {
const [diskChartData, setDiskChartData] = useState( const [diskChartData, setDiskChartData] = useState(
[] as { time: number; disk: number; diskUsed: number }[] [] as { time: number; disk: number; diskUsed: number }[]
) )
const [diskIoChartData, setDiskIoChartData] = useState(
[] as { time: number; read: number; write: number }[]
)
const [dockerCpuChartData, setDockerCpuChartData] = useState( const [dockerCpuChartData, setDockerCpuChartData] = useState(
[] as Record<string, number | string>[] [] as Record<string, number | string>[]
) )
@@ -44,7 +51,6 @@ export default function ServerDetail({ name }: { name: string }) {
useEffect(() => { useEffect(() => {
document.title = `${name} / Beszel` document.title = `${name} / Beszel`
return () => { return () => {
console.log('unmounting')
setServer({} as SystemRecord) setServer({} as SystemRecord)
setCpuChartData([]) setCpuChartData([])
setMemChartData([]) setMemChartData([])
@@ -55,14 +61,14 @@ export default function ServerDetail({ name }: { name: string }) {
}, [name]) }, [name])
useEffect(() => { useEffect(() => {
if (server?.id && server.name === name) { if (server.id && server.name === name) {
return return
} }
const matchingServer = servers.find((s) => s.name === name) as SystemRecord const matchingServer = servers.find((s) => s.name === name) as SystemRecord
if (matchingServer) { if (matchingServer) {
setServer(matchingServer) setServer(matchingServer)
} }
}, [name, server]) }, [name, server, servers])
// if visiting directly, make sure server gets set when servers are loaded // if visiting directly, make sure server gets set when servers are loaded
// useEffect(() => { // useEffect(() => {
@@ -77,17 +83,14 @@ export default function ServerDetail({ name }: { name: string }) {
// get stats // get stats
useEffect(() => { useEffect(() => {
if (!('id' in server)) { if (!server.id) {
console.log('no id in server')
return return
} else {
console.log('id in server')
} }
pb.collection<SystemStatsRecord>('system_stats') pb.collection<SystemStatsRecord>('system_stats')
.getFullList({ .getFullList({
filter: `system="${server.id}" && created > "${getPbTimestamp('1h')}"`, filter: `system="${server.id}" && created > "${getPbTimestamp('1h')}"`,
fields: 'created,stats', fields: 'created,stats',
sort: '-created', sort: 'created',
}) })
.then((records) => { .then((records) => {
// console.log('sctats', records) // console.log('sctats', records)
@@ -101,17 +104,15 @@ export default function ServerDetail({ name }: { name: string }) {
} }
}, [updatedSystem]) }, [updatedSystem])
// get cpu data // create cpu / mem / disk data for charts
useEffect(() => { useEffect(() => {
if (!serverStats.length) { if (!serverStats.length) {
return return
} }
console.log('stats', serverStats)
// let maxCpu = 0
const cpuData = [] as typeof cpuChartData const cpuData = [] as typeof cpuChartData
const memData = [] as typeof memChartData const memData = [] as typeof memChartData
const diskData = [] as typeof diskChartData const diskData = [] as typeof diskChartData
const diskIoData = [] as typeof diskIoChartData
for (let { created, stats } of serverStats) { for (let { created, stats } of serverStats) {
const time = new Date(created).getTime() const time = new Date(created).getTime()
cpuData.push({ time, cpu: stats.cpu }) cpuData.push({ time, cpu: stats.cpu })
@@ -122,18 +123,33 @@ export default function ServerDetail({ name }: { name: string }) {
memCache: stats.mb, memCache: stats.mb,
}) })
diskData.push({ time, disk: stats.d, diskUsed: stats.du }) diskData.push({ time, disk: stats.d, diskUsed: stats.du })
diskIoData.push({ time, read: stats.dr, write: stats.dw })
} }
setCpuChartData(cpuData.reverse()) setCpuChartData(cpuData)
setMemChartData(memData.reverse()) setMemChartData(memData)
setDiskChartData(diskData.reverse()) setDiskChartData(diskData)
setDiskIoChartData(diskIoData)
}, [serverStats]) }, [serverStats])
useEffect(() => { useEffect(() => {
if (!serverStats.length) {
return
}
const now = new Date()
const startTime = chartTimeData[chartTime].getOffset(now)
const scale = scaleTime([startTime.getTime(), now], [0, cpuChartData.length])
setTicks(scale.ticks().map((d) => d.getTime()))
}, [chartTime, serverStats])
useEffect(() => {
if (!server.id) {
return
}
pb.collection<ContainerStatsRecord>('container_stats') pb.collection<ContainerStatsRecord>('container_stats')
.getFullList({ .getFullList({
filter: `system="${server.id}" && created > "${getPbTimestamp('1h')}"`, filter: `system="${server.id}" && created > "${getPbTimestamp('1h')}"`,
fields: 'created,stats', fields: 'created,stats',
sort: '-created', sort: 'created',
}) })
.then((records) => { .then((records) => {
setContainers(records) setContainers(records)
@@ -158,19 +174,18 @@ export default function ServerDetail({ name }: { name: string }) {
dockerMemData.push(memData) dockerMemData.push(memData)
} }
// console.log('containerMemData', containerMemData) // console.log('containerMemData', containerMemData)
setDockerCpuChartData(dockerCpuData.reverse()) setDockerCpuChartData(dockerCpuData)
setDockerMemChartData(dockerMemData.reverse()) setDockerMemChartData(dockerMemData)
}, [containers]) }, [containers])
const uptime = useMemo(() => { const uptime = useMemo(() => {
console.log('making uptime')
let uptime = server.info?.u || 0 let uptime = server.info?.u || 0
if (uptime < 172800) { if (uptime < 172800) {
return `${Math.floor(uptime / 3600)} hours` return `${Math.trunc(uptime / 3600)} hours`
} }
return `${Math.floor(server.info?.u / 86400)} days` return `${Math.trunc(server.info?.u / 86400)} days`
}, [server.info?.u]) }, [server.info?.u])
if (!('id' in server)) { if (!server.id) {
return null return null
} }
@@ -220,25 +235,34 @@ export default function ServerDetail({ name }: { name: string }) {
title="Total CPU Usage" title="Total CPU Usage"
description="Average system-wide CPU utilization as a percentage" description="Average system-wide CPU utilization as a percentage"
> >
<CpuChart chartData={cpuChartData} /> <CpuChart chartData={cpuChartData} ticks={ticks} />
</ChartCard> </ChartCard>
{dockerCpuChartData.length > 0 && ( {dockerCpuChartData.length > 0 && (
<ChartCard title="Docker CPU Usage" description="CPU utilization of docker containers"> <ChartCard title="Docker CPU Usage" description="CPU utilization of docker containers">
<ContainerCpuChart chartData={dockerCpuChartData} /> <ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
</ChartCard> </ChartCard>
)} )}
<ChartCard title="Total Memory Usage" description="Precise utilization at the recorded time"> <ChartCard title="Total Memory Usage" description="Precise utilization at the recorded time">
<MemChart chartData={memChartData} /> <MemChart chartData={memChartData} ticks={ticks} />
</ChartCard> </ChartCard>
{dockerMemChartData.length > 0 && ( {dockerMemChartData.length > 0 && (
<ChartCard title="Docker Memory Usage" description="Memory usage of docker containers"> <ChartCard title="Docker Memory Usage" description="Memory usage of docker containers">
<ContainerMemChart chartData={dockerMemChartData} /> <ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
</ChartCard> </ChartCard>
)} )}
<ChartCard title="Disk Usage" description="Precise usage at the recorded time"> <ChartCard
<DiskChart chartData={diskChartData} /> title="Disk Usage"
description="Usage of partition where the root filesystem is mounted"
>
<DiskChart chartData={diskChartData} ticks={ticks} />
</ChartCard>
<ChartCard
title="Disk I/O"
description="Throughput of disk where the root filesystem is mounted"
>
<DiskIoChart chartData={diskIoChartData} ticks={ticks} />
</ChartCard> </ChartCard>
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -1,6 +1,6 @@
import PocketBase from 'pocketbase' import PocketBase from 'pocketbase'
import { atom } from 'nanostores' import { atom, WritableAtom } from 'nanostores'
import { AlertRecord, SystemRecord } from '@/types' import { AlertRecord, ChartTimes, SystemRecord } from '@/types'
import { createRouter } from '@nanostores/router' import { createRouter } from '@nanostores/router'
/** PocketBase JS Client */ /** PocketBase JS Client */
@@ -35,4 +35,4 @@ export const $alerts = atom([] as AlertRecord[])
export const $publicKey = atom('') export const $publicKey = atom('')
/** Chart time period */ /** Chart time period */
export const $chartTime = atom('1h') export const $chartTime = atom('1h') as WritableAtom<ChartTimes>

View File

@@ -2,9 +2,10 @@ import { toast } from '@/components/ui/use-toast'
import { type ClassValue, clsx } from 'clsx' import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import { $alerts, $systems, pb } from './stores' import { $alerts, $systems, pb } from './stores'
import { AlertRecord, SystemRecord } from '@/types' import { AlertRecord, ChartTimes, SystemRecord } from '@/types'
import { RecordModel, RecordSubscription } from 'pocketbase' import { RecordModel, RecordSubscription } from 'pocketbase'
import { WritableAtom } from 'nanostores' import { WritableAtom } from 'nanostores'
import { timeDay, timeHour } from 'd3-time'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -42,32 +43,35 @@ export const updateAlerts = () => {
}) })
} }
const shortTimeFormatter = new Intl.DateTimeFormat(undefined, { const hourWithMinutesFormatter = new Intl.DateTimeFormat(undefined, {
// day: 'numeric',
// month: 'numeric',
// year: '2-digit',
// hour12: false,
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
}) })
export const formatShortTime = (timestamp: string) => { export const hourWithMinutes = (timestamp: string) => {
// console.log('ts', timestamp) return hourWithMinutesFormatter.format(new Date(timestamp))
return shortTimeFormatter.format(new Date(timestamp))
} }
const shortDateFormatter = new Intl.DateTimeFormat(undefined, { const shortDateFormatter = new Intl.DateTimeFormat(undefined, {
day: 'numeric', day: 'numeric',
month: 'short', month: 'short',
// year: '2-digit',
// hour12: false,
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
}) })
export const formatShortDate = (timestamp: string) => { export const formatShortDate = (timestamp: string) => {
console.log('ts', timestamp) // console.log('ts', timestamp)
return shortDateFormatter.format(new Date(timestamp)) return shortDateFormatter.format(new Date(timestamp))
} }
const dayFormatter = new Intl.DateTimeFormat(undefined, {
day: 'numeric',
month: 'long',
// dateStyle: 'medium',
})
export const formatDay = (timestamp: string) => {
// console.log('ts', timestamp)
return dayFormatter.format(new Date(timestamp))
}
export const updateFavicon = (newIconUrl: string) => export const updateFavicon = (newIconUrl: string) =>
((document.querySelector("link[rel='icon']") as HTMLLinkElement).href = newIconUrl) ((document.querySelector("link[rel='icon']") as HTMLLinkElement).href = newIconUrl)
@@ -103,33 +107,42 @@ export function updateRecordList<T extends RecordModel>(
$store.set(newRecords) $store.set(newRecords)
} }
export function getPbTimestamp(timeString: string) { export function getPbTimestamp(timeString: ChartTimes) {
const now = new Date() const d = chartTimeData[timeString].getOffset(new Date())
let timeValue = parseInt(timeString.slice(0, -1)) const year = d.getUTCFullYear()
let unit = timeString.slice(-1) const month = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
if (unit === 'h') { const hours = String(d.getUTCHours()).padStart(2, '0')
now.setUTCHours(now.getUTCHours() - timeValue) const minutes = String(d.getUTCMinutes()).padStart(2, '0')
} else { const seconds = String(d.getUTCSeconds()).padStart(2, '0')
// 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}` return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} }
export const calculateXaxisTicks = (chartData: any[]) => { export const chartTimeData = {
const ticks: number[] = [] '1h': {
const lastDate = chartData.at(-1)!.time label: '1 hour',
for (let i = 60; i >= 0; i--) { format: (timestamp: string) => hourWithMinutes(timestamp),
ticks.push(lastDate - i * 60 * 1000) getOffset: (endTime: Date) => timeHour.offset(endTime, -1),
} },
return ticks '12h': {
label: '12 hours',
format: (timestamp: string) => hourWithMinutes(timestamp),
getOffset: (endTime: Date) => timeHour.offset(endTime, -12),
},
'24h': {
label: '24 hours',
format: (timestamp: string) => hourWithMinutes(timestamp),
getOffset: (endTime: Date) => timeHour.offset(endTime, -24),
},
'1w': {
label: '1 week',
format: (timestamp: string) => formatDay(timestamp),
getOffset: (endTime: Date) => timeDay.offset(endTime, -7),
},
'30d': {
label: '30 days',
format: (timestamp: string) => formatDay(timestamp),
getOffset: (endTime: Date) => timeDay.offset(endTime, -30),
},
} }

2
site/src/types.d.ts vendored
View File

@@ -75,3 +75,5 @@ export interface AlertRecord extends RecordModel {
name: string name: string
// user: string // user: string
} }
export type ChartTimes = '1h' | '12h' | '24h' | '1w' | '30d'