display agent connection type in hub (ssh, websocket)

This commit is contained in:
henrygd
2025-09-21 17:49:22 -04:00
parent d0b6e725c8
commit 0d157b5857
8 changed files with 104 additions and 43 deletions

View File

@@ -9,19 +9,21 @@ import (
"time" "time"
"github.com/henrygd/beszel/agent/health" "github.com/henrygd/beszel/agent/health"
"github.com/henrygd/beszel/internal/entities/system"
) )
// ConnectionManager manages the connection state and events for the agent. // ConnectionManager manages the connection state and events for the agent.
// It handles both WebSocket and SSH connections, automatically switching between // It handles both WebSocket and SSH connections, automatically switching between
// them based on availability and managing reconnection attempts. // them based on availability and managing reconnection attempts.
type ConnectionManager struct { type ConnectionManager struct {
agent *Agent // Reference to the parent agent agent *Agent // Reference to the parent agent
State ConnectionState // Current connection state State ConnectionState // Current connection state
eventChan chan ConnectionEvent // Channel for connection events eventChan chan ConnectionEvent // Channel for connection events
wsClient *WebSocketClient // WebSocket client for hub communication wsClient *WebSocketClient // WebSocket client for hub communication
serverOptions ServerOptions // Configuration for SSH server serverOptions ServerOptions // Configuration for SSH server
wsTicker *time.Ticker // Ticker for WebSocket connection attempts wsTicker *time.Ticker // Ticker for WebSocket connection attempts
isConnecting bool // Prevents multiple simultaneous reconnection attempts isConnecting bool // Prevents multiple simultaneous reconnection attempts
ConnectionType system.ConnectionType
} }
// ConnectionState represents the current connection state of the agent. // ConnectionState represents the current connection state of the agent.
@@ -144,15 +146,18 @@ func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
switch newState { switch newState {
case WebSocketConnected: case WebSocketConnected:
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host) slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
c.ConnectionType = system.ConnectionTypeWebSocket
c.stopWsTicker() c.stopWsTicker()
_ = c.agent.StopServer() _ = c.agent.StopServer()
c.isConnecting = false c.isConnecting = false
case SSHConnected: case SSHConnected:
// stop new ws connection attempts // stop new ws connection attempts
slog.Info("SSH connection established") slog.Info("SSH connection established")
c.ConnectionType = system.ConnectionTypeSSH
c.stopWsTicker() c.stopWsTicker()
c.isConnecting = false c.isConnecting = false
case Disconnected: case Disconnected:
c.ConnectionType = system.ConnectionTypeNone
if c.isConnecting { if c.isConnecting {
// Already handling reconnection, avoid duplicate attempts // Already handling reconnection, avoid duplicate attempts
return return

View File

@@ -212,6 +212,7 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// update base system info // update base system info
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
a.systemInfo.Cpu = systemStats.Cpu a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.LoadAvg = systemStats.LoadAvg a.systemInfo.LoadAvg = systemStats.LoadAvg
// TODO: remove these in future release in favor of load avg array // TODO: remove these in future release in favor of load avg array

View File

@@ -84,6 +84,14 @@ const (
Freebsd Freebsd
) )
type ConnectionType = uint8
const (
ConnectionTypeNone ConnectionType = iota
ConnectionTypeSSH
ConnectionTypeWebSocket
)
type Info struct { type Info struct {
Hostname string `json:"h" cbor:"0,keyasint"` Hostname string `json:"h" cbor:"0,keyasint"`
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
@@ -105,7 +113,8 @@ type Info struct {
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"` BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
// TODO: remove load fields in future release in favor of load avg array // TODO: remove load fields in future release in favor of load avg array
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
} }
// Final data structure to return to the hub // Final data structure to return to the hub

View File

@@ -3,7 +3,15 @@ import { Plural, Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { timeTicks } from "d3-time" import { timeTicks } from "d3-time"
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from "lucide-react" import {
ChevronRightSquareIcon,
ClockArrowUp,
CpuIcon,
GlobeIcon,
LayoutGridIcon,
MonitorIcon,
XIcon,
} from "lucide-react"
import { subscribeKeys } from "nanostores" import { subscribeKeys } from "nanostores"
import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import AreaChartDefault from "@/components/charts/area-chart" import AreaChartDefault from "@/components/charts/area-chart"
@@ -16,7 +24,7 @@ import MemChart from "@/components/charts/mem-chart"
import SwapChart from "@/components/charts/swap-chart" import SwapChart from "@/components/charts/swap-chart"
import TemperatureChart from "@/components/charts/temperature-chart" import TemperatureChart from "@/components/charts/temperature-chart"
import { getPbTimestamp, pb } from "@/lib/api" import { getPbTimestamp, pb } from "@/lib/api"
import { ChartType, Os, SystemStatus, Unit } from "@/lib/enums" import { ChartType, ConnectionType, Os, SystemStatus, Unit } from "@/lib/enums"
import { batteryStateTranslations } from "@/lib/i18n" import { batteryStateTranslations } from "@/lib/i18n"
import { import {
$allSystemsByName, $allSystemsByName,
@@ -47,7 +55,7 @@ import { $router, navigate } from "../router"
import Spinner from "../spinner" import Spinner from "../spinner"
import { Button } from "../ui/button" import { Button } from "../ui/button"
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card" import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WindowsIcon } from "../ui/icons" import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } from "../ui/icons"
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { Separator } from "../ui/separator" import { Separator } from "../ui/separator"
@@ -130,7 +138,7 @@ async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
function dockerOrPodman(str: string, system: SystemRecord) { function dockerOrPodman(str: string, system: SystemRecord) {
if (system.info.p) { if (system.info.p) {
str = str.replace("docker", "podman").replace("Docker", "Podman") return str.replace("docker", "podman").replace("Docker", "Podman")
} }
return str return str
} }
@@ -407,25 +415,45 @@ export default memo(function SystemDetail({ name }: { name: string }) {
<div> <div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1> <h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90"> <div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
<div className="capitalize flex gap-2 items-center"> <TooltipProvider>
<span className={cn("relative flex h-3 w-3")}> <Tooltip>
{system.status === SystemStatus.Up && ( <TooltipTrigger asChild>
<span <div className="capitalize flex gap-2 items-center">
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" <span className={cn("relative flex h-3 w-3")}>
style={{ animationDuration: "1.5s" }} {system.status === SystemStatus.Up && (
></span> <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>
{system.info.ct === ConnectionType.WebSocket ? (
<div className="flex gap-1 items-center">
<WebSocketIcon className="size-4" /> WebSocket
</div>
) : (
<div className="flex gap-1 items-center">
<ChevronRightSquareIcon className="size-4" strokeWidth={2} /> SSH
</div>
)}
</TooltipContent>
)} )}
<span </Tooltip>
className={cn("relative inline-flex rounded-full h-3 w-3", { </TooltipProvider>
"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>
{systemInfo.map(({ value, label, Icon, hide }) => { {systemInfo.map(({ value, label, Icon, hide }) => {
if (hide || !value) { if (hide || !value) {
return null return null

View File

@@ -6,6 +6,7 @@ import type { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-tabl
import type { ClassValue } from "clsx" import type { ClassValue } from "clsx"
import { import {
ArrowUpDownIcon, ArrowUpDownIcon,
ChevronRightSquareIcon,
CopyIcon, CopyIcon,
CpuIcon, CpuIcon,
HardDriveIcon, HardDriveIcon,
@@ -20,7 +21,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { memo, useMemo, useRef, useState } from "react" import { memo, useMemo, useRef, useState } from "react"
import { isReadOnlyUser, pb } from "@/lib/api" import { isReadOnlyUser, pb } from "@/lib/api"
import { MeterState, SystemStatus } from "@/lib/enums" import { ConnectionType, MeterState, SystemStatus } from "@/lib/enums"
import { $longestSystemNameLen, $userSettings } from "@/lib/stores" import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
import { import {
cn, cn,
@@ -54,7 +55,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu" } from "../ui/dropdown-menu"
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons" import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon, WebSocketIcon } from "../ui/icons"
const STATUS_COLORS = { const STATUS_COLORS = {
[SystemStatus.Up]: "bg-green-500", [SystemStatus.Up]: "bg-green-500",
@@ -271,18 +272,18 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
return null return null
} }
const system = info.row.original const system = info.row.original
const color = {
"text-green-500": version === globalThis.BESZEL.HUB_VERSION,
"text-yellow-500": version !== globalThis.BESZEL.HUB_VERSION,
"text-red-500": system.status !== SystemStatus.Up,
}
return ( return (
<span className={cn("flex gap-1.5 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}> <div className={cn("flex gap-1.5 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
<IndicatorDot {system.info.ct === ConnectionType.WebSocket && <WebSocketIcon className={cn("size-3", color)} />}
system={system} {system.info.ct === ConnectionType.SSH && <ChevronRightSquareIcon className={cn("size-3", color)} />}
className={ {!system.info.ct && <IndicatorDot system={system} className={cn(color, "bg-current mx-0.5")} />}
(system.status !== SystemStatus.Up && STATUS_COLORS[SystemStatus.Paused]) ||
(version === globalThis.BESZEL.HUB_VERSION && STATUS_COLORS[SystemStatus.Up]) ||
STATUS_COLORS[SystemStatus.Pending]
}
/>
<span className="truncate max-w-14">{info.getValue() as string}</span> <span className="truncate max-w-14">{info.getValue() as string}</span>
</span> </div>
) )
}, },
}, },

View File

@@ -130,3 +130,12 @@ export function HourglassIcon(props: SVGProps<SVGSVGElement>) {
</svg> </svg>
) )
} }
export function WebSocketIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 256 193" {...props} fill="currentColor">
<title>WebSocket</title>
<path d="M192 145h32V68l-36-35-22 22 26 27zm32 16H113l-26-27 11-11 22 22h45l-44-45 11-11 44 44V88l-21-22 11-11-55-55H0l32 32h65l24 23-34 34-24-23V48H32v31l55 55-23 22 36 36h156z" />
</svg>
)
}

View File

@@ -53,3 +53,9 @@ export enum HourFormat {
"12h" = "12h", "12h" = "12h",
"24h" = "24h", "24h" = "24h",
} }
/** Connection type */
export enum ConnectionType {
SSH = 1,
WebSocket,
}

View File

@@ -1,5 +1,5 @@
import type { RecordModel } from "pocketbase" import type { RecordModel } from "pocketbase"
import type { Unit, Os, BatteryState, HourFormat } from "./lib/enums" import type { Unit, Os, BatteryState, HourFormat, ConnectionType } from "@/lib/enums"
// global window properties // global window properties
declare global { declare global {
@@ -75,6 +75,8 @@ export interface SystemInfo {
dt?: number dt?: number
/** operating system */ /** operating system */
os?: Os os?: Os
/** connection type */
ct?: ConnectionType
} }
export interface SystemStats { export interface SystemStats {