diff --git a/main.go b/main.go index 145f4f3..d80c696 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "encoding/json" "encoding/pem" + "errors" "fmt" "log" _ "monitor-site/migrations" @@ -151,6 +152,12 @@ func main() { if newStatus == "down" || newStatus == "paused" { deleteServerConnection(newRecord) } + + // if server is set to pending, try to connect + if newStatus == "pending" { + go updateServer(newRecord) + } + // alerts handleStatusAlerts(newStatus, oldRecord) return nil @@ -218,6 +225,13 @@ func updateServer(record *models.Record) { // get server stats from agent systemData, err := requestJson(&server) 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()) updateServerStatus(record, "down") return @@ -307,7 +321,7 @@ func getServerConnection(server *Server) (*ssh.Client, error) { func requestJson(server *Server) (SystemData, error) { session, err := server.Client.NewSession() if err != nil { - return SystemData{}, err + return SystemData{}, errors.New("retry") } defer session.Close() diff --git a/site/bun.lockb b/site/bun.lockb index 1f21930..cd79976 100755 Binary files a/site/bun.lockb and b/site/bun.lockb differ diff --git a/site/package.json b/site/package.json index 0874a10..d81f089 100644 --- a/site/package.json +++ b/site/package.json @@ -26,6 +26,8 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "d3-scale": "^4.0.2", + "d3-time": "^3.1.0", "lucide-react": "^0.407.0", "nanostores": "^0.10.3", "pocketbase": "^0.21.3", diff --git a/site/src/components/charts/chart-time-select.tsx b/site/src/components/charts/chart-time-select.tsx index 0cfe125..10686b9 100644 --- a/site/src/components/charts/chart-time-select.tsx +++ b/site/src/components/charts/chart-time-select.tsx @@ -6,7 +6,8 @@ import { SelectValue, } from '@/components/ui/select' 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 { useEffect } from 'react' @@ -19,16 +20,20 @@ export default function ChartTimeSelect({ className }: { className?: string }) { }, []) return ( - $chartTime.set(value)} + > - + - 1 hour - 12 hours - 24 hours - 1 week - 30 days + {Object.entries(chartTimeData).map(([value, { label }]) => ( + + {label} + + ))} ) diff --git a/site/src/components/charts/container-cpu-chart.tsx b/site/src/components/charts/container-cpu-chart.tsx index cc082a6..7c1006a 100644 --- a/site/src/components/charts/container-cpu-chart.tsx +++ b/site/src/components/charts/container-cpu-chart.tsx @@ -8,14 +8,20 @@ import { ChartTooltipContent, } from '@/components/ui/chart' import { useMemo } from 'react' -import { calculateXaxisTicks, formatShortDate, formatShortTime } from '@/lib/utils' +import { formatShortDate, hourWithMinutes } from '@/lib/utils' import Spinner from '../spinner' export default function ContainerCpuChart({ chartData, + ticks, }: { chartData: Record[] + ticks: number[] }) { + if (!chartData.length || !ticks.length) { + return + } + const chartConfig = useMemo(() => { let config = {} as Record< string, @@ -45,18 +51,12 @@ export default function ContainerCpuChart({ const hue = ((i * 360) / length) % 360 config[key] = { label: key, - color: `hsl(${hue}, 60%, 60%)`, + color: `hsl(${hue}, 60%, 55%)`, } } return config satisfies ChartConfig }, [chartData]) - const ticks = useMemo(() => calculateXaxisTicks(chartData), [chartData]) - - if (!chartData.length) { - return - } - return ( ( [] }) { - if (!chartData.length) { +export default function ({ + chartData, + ticks, +}: { + chartData: Record[] + ticks: number[] +}) { + if (!chartData.length || !ticks.length) { return } @@ -45,14 +51,12 @@ export default function ({ chartData }: { chartData: Record calculateXaxisTicks(chartData), [chartData]) - return ( { x = x / 1024 return x % 1 === 0 ? x : x.toFixed(1) @@ -88,7 +92,7 @@ export default function ({ chartData }: { chartData: Record ( } - - const ticks = useMemo(() => calculateXaxisTicks(chartData), [chartData]) + const chartTime = useStore($chartTime) return ( @@ -42,10 +48,10 @@ export default function CpuChart({ chartData }: { chartData: { time: number; cpu ticks={ticks} type="number" scale={'time'} - axisLine={false} - tickMargin={8} minTickGap={35} - tickFormatter={formatShortTime} + tickMargin={8} + axisLine={false} + tickFormatter={chartTimeData[chartTime].format} /> } @@ -33,8 +32,6 @@ export default function DiskChart({ return Math.round(chartData[0]?.disk) }, [chartData]) - const ticks = useMemo(() => calculateXaxisTicks(chartData), [chartData]) - // const ticks = useMemo(() => { // let ticks = [0] // for (let i = 1; i < diskSize; i += diskSize / 5) { @@ -65,7 +62,7 @@ export default function DiskChart({ minTickGap={8} tickLine={false} axisLine={false} - unit={' GiB'} + unit={' GB'} /> {/* todo: short time if first date is same day, otherwise short date */} formatShortDate(data[0].payload.time)} indicator="line" /> @@ -92,7 +89,7 @@ export default function DiskChart({ /> + } + + return ( + + + + + {/* todo: short time if first date is same day, otherwise short date */} + + formatShortDate(data[0].payload.time)} + indicator="line" + /> + } + /> + + + + + ) +} diff --git a/site/src/components/charts/mem-chart.tsx b/site/src/components/charts/mem-chart.tsx index ca51abd..7cf2d3b 100644 --- a/site/src/components/charts/mem-chart.tsx +++ b/site/src/components/charts/mem-chart.tsx @@ -6,21 +6,21 @@ import { ChartTooltip, ChartTooltipContent, } from '@/components/ui/chart' -import { calculateXaxisTicks, formatShortDate, formatShortTime } from '@/lib/utils' +import { formatShortDate, hourWithMinutes } from '@/lib/utils' import { useMemo } from 'react' import Spinner from '../spinner' export default function MemChart({ chartData, + ticks, }: { chartData: { time: number; mem: number; memUsed: number; memCache: number }[] + ticks: number[] }) { - if (!chartData.length) { + if (!chartData.length || !ticks.length) { return } - const ticks = useMemo(() => calculateXaxisTicks(chartData), [chartData]) - const totalMem = useMemo(() => { return Math.ceil(chartData[0]?.mem) }, [chartData]) @@ -56,7 +56,7 @@ export default function MemChart({ tickLine={false} allowDecimals={false} axisLine={false} - tickFormatter={(v) => `${v} GiB`} + tickFormatter={(v) => `${v} GB`} /> {/* todo: short time if first date is same day, otherwise short date */} a.name.localeCompare(b.name)} labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} @@ -87,7 +87,7 @@ export default function MemChart({ /> import('../charts/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 }) { const servers = useStore($systems) const updatedSystem = useStore($updatedSystem) + const chartTime = useStore($chartTime) + const [ticks, setTicks] = useState([] as number[]) const [server, setServer] = useState({} as SystemRecord) const [containers, setContainers] = useState([] as ContainerStatsRecord[]) @@ -34,6 +38,9 @@ export default function ServerDetail({ name }: { name: string }) { const [diskChartData, setDiskChartData] = useState( [] as { time: number; disk: number; diskUsed: number }[] ) + const [diskIoChartData, setDiskIoChartData] = useState( + [] as { time: number; read: number; write: number }[] + ) const [dockerCpuChartData, setDockerCpuChartData] = useState( [] as Record[] ) @@ -44,7 +51,6 @@ export default function ServerDetail({ name }: { name: string }) { useEffect(() => { document.title = `${name} / Beszel` return () => { - console.log('unmounting') setServer({} as SystemRecord) setCpuChartData([]) setMemChartData([]) @@ -55,14 +61,14 @@ export default function ServerDetail({ name }: { name: string }) { }, [name]) useEffect(() => { - if (server?.id && server.name === name) { + if (server.id && server.name === name) { return } const matchingServer = servers.find((s) => s.name === name) as SystemRecord if (matchingServer) { setServer(matchingServer) } - }, [name, server]) + }, [name, server, servers]) // if visiting directly, make sure server gets set when servers are loaded // useEffect(() => { @@ -77,17 +83,14 @@ export default function ServerDetail({ name }: { name: string }) { // get stats useEffect(() => { - if (!('id' in server)) { - console.log('no id in server') + if (!server.id) { return - } else { - console.log('id in server') } pb.collection('system_stats') .getFullList({ filter: `system="${server.id}" && created > "${getPbTimestamp('1h')}"`, fields: 'created,stats', - sort: '-created', + sort: 'created', }) .then((records) => { // console.log('sctats', records) @@ -101,17 +104,15 @@ export default function ServerDetail({ name }: { name: string }) { } }, [updatedSystem]) - // get cpu data + // create cpu / mem / disk data for charts useEffect(() => { if (!serverStats.length) { return } - - console.log('stats', serverStats) - // let maxCpu = 0 const cpuData = [] as typeof cpuChartData const memData = [] as typeof memChartData const diskData = [] as typeof diskChartData + const diskIoData = [] as typeof diskIoChartData for (let { created, stats } of serverStats) { const time = new Date(created).getTime() cpuData.push({ time, cpu: stats.cpu }) @@ -122,18 +123,33 @@ export default function ServerDetail({ name }: { name: string }) { memCache: stats.mb, }) diskData.push({ time, disk: stats.d, diskUsed: stats.du }) + diskIoData.push({ time, read: stats.dr, write: stats.dw }) } - setCpuChartData(cpuData.reverse()) - setMemChartData(memData.reverse()) - setDiskChartData(diskData.reverse()) + setCpuChartData(cpuData) + setMemChartData(memData) + setDiskChartData(diskData) + setDiskIoChartData(diskIoData) }, [serverStats]) 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('container_stats') .getFullList({ filter: `system="${server.id}" && created > "${getPbTimestamp('1h')}"`, fields: 'created,stats', - sort: '-created', + sort: 'created', }) .then((records) => { setContainers(records) @@ -158,19 +174,18 @@ export default function ServerDetail({ name }: { name: string }) { dockerMemData.push(memData) } // console.log('containerMemData', containerMemData) - setDockerCpuChartData(dockerCpuData.reverse()) - setDockerMemChartData(dockerMemData.reverse()) + setDockerCpuChartData(dockerCpuData) + setDockerMemChartData(dockerMemData) }, [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.trunc(uptime / 3600)} hours` } - return `${Math.floor(server.info?.u / 86400)} days` + return `${Math.trunc(server.info?.u / 86400)} days` }, [server.info?.u]) - if (!('id' in server)) { + if (!server.id) { return null } @@ -220,25 +235,34 @@ export default function ServerDetail({ name }: { name: string }) { title="Total CPU Usage" description="Average system-wide CPU utilization as a percentage" > - + {dockerCpuChartData.length > 0 && ( - + )} - + {dockerMemChartData.length > 0 && ( - + )} - - + + + + + diff --git a/site/src/lib/stores.ts b/site/src/lib/stores.ts index 161e5e7..a40ca9f 100644 --- a/site/src/lib/stores.ts +++ b/site/src/lib/stores.ts @@ -1,6 +1,6 @@ import PocketBase from 'pocketbase' -import { atom } from 'nanostores' -import { AlertRecord, SystemRecord } from '@/types' +import { atom, WritableAtom } from 'nanostores' +import { AlertRecord, ChartTimes, SystemRecord } from '@/types' import { createRouter } from '@nanostores/router' /** PocketBase JS Client */ @@ -35,4 +35,4 @@ export const $alerts = atom([] as AlertRecord[]) export const $publicKey = atom('') /** Chart time period */ -export const $chartTime = atom('1h') +export const $chartTime = atom('1h') as WritableAtom diff --git a/site/src/lib/utils.ts b/site/src/lib/utils.ts index 37f1a7d..9b688b4 100644 --- a/site/src/lib/utils.ts +++ b/site/src/lib/utils.ts @@ -2,9 +2,10 @@ import { toast } from '@/components/ui/use-toast' import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' import { $alerts, $systems, pb } from './stores' -import { AlertRecord, SystemRecord } from '@/types' +import { AlertRecord, ChartTimes, SystemRecord } from '@/types' import { RecordModel, RecordSubscription } from 'pocketbase' import { WritableAtom } from 'nanostores' +import { timeDay, timeHour } from 'd3-time' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -42,32 +43,35 @@ export const updateAlerts = () => { }) } -const shortTimeFormatter = new Intl.DateTimeFormat(undefined, { - // day: 'numeric', - // month: 'numeric', - // year: '2-digit', - // hour12: false, +const hourWithMinutesFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: 'numeric', }) -export const formatShortTime = (timestamp: string) => { - // console.log('ts', timestamp) - return shortTimeFormatter.format(new Date(timestamp)) +export const hourWithMinutes = (timestamp: string) => { + return hourWithMinutesFormatter.format(new Date(timestamp)) } const shortDateFormatter = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', - // year: '2-digit', - // hour12: false, hour: 'numeric', minute: 'numeric', }) export const formatShortDate = (timestamp: string) => { - console.log('ts', timestamp) + // console.log('ts', 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) => ((document.querySelector("link[rel='icon']") as HTMLLinkElement).href = newIconUrl) @@ -103,33 +107,42 @@ export function updateRecordList( $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') +export function getPbTimestamp(timeString: ChartTimes) { + const d = chartTimeData[timeString].getOffset(new Date()) + const year = d.getUTCFullYear() + const month = String(d.getUTCMonth() + 1).padStart(2, '0') + const day = String(d.getUTCDate()).padStart(2, '0') + const hours = String(d.getUTCHours()).padStart(2, '0') + const minutes = String(d.getUTCMinutes()).padStart(2, '0') + const seconds = String(d.getUTCSeconds()).padStart(2, '0') return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` } -export const calculateXaxisTicks = (chartData: any[]) => { - const ticks: number[] = [] - const lastDate = chartData.at(-1)!.time - for (let i = 60; i >= 0; i--) { - ticks.push(lastDate - i * 60 * 1000) - } - return ticks +export const chartTimeData = { + '1h': { + label: '1 hour', + format: (timestamp: string) => hourWithMinutes(timestamp), + getOffset: (endTime: Date) => timeHour.offset(endTime, -1), + }, + '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), + }, } diff --git a/site/src/types.d.ts b/site/src/types.d.ts index 7966e77..1b58a70 100644 --- a/site/src/types.d.ts +++ b/site/src/types.d.ts @@ -75,3 +75,5 @@ export interface AlertRecord extends RecordModel { name: string // user: string } + +export type ChartTimes = '1h' | '12h' | '24h' | '1w' | '30d'