mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-21 21:26:16 +01:00
refactor: unify agent communication with Transport interface
- Introduce `Transport` interface to abstract WebSocket and SSH communication - Add generic `Data` field to `AgentResponse` for streamlined future endpoints - Maintain backward compatibility with legacy hubs and agents using typed fields - Unify fetch operations (SMART, systemd, containers) under a single `request` method - Improve `RequestManager` with deadline awareness and legacy response support - Refactor agent response routing into dedicated `agent/response.go` - Update version to 0.18.0-beta.2
This commit is contained in:
@@ -15,9 +15,6 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
@@ -259,40 +256,16 @@ func (client *WebSocketClient) sendMessage(data any) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendResponse sends a response with optional request ID for the new protocol
|
// sendResponse sends a response with optional request ID.
|
||||||
|
// For ID-based requests, we must populate legacy typed fields for backward
|
||||||
|
// compatibility with older hubs (<= 0.17) that don't read the generic Data field.
|
||||||
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
||||||
if requestID != nil {
|
if requestID != nil {
|
||||||
// New format with ID - use typed fields
|
response := newAgentResponse(data, requestID)
|
||||||
response := common.AgentResponse{
|
|
||||||
Id: requestID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the appropriate typed field based on data type
|
|
||||||
switch v := data.(type) {
|
|
||||||
case *system.CombinedData:
|
|
||||||
response.SystemData = v
|
|
||||||
case *common.FingerprintResponse:
|
|
||||||
response.Fingerprint = v
|
|
||||||
case string:
|
|
||||||
response.String = &v
|
|
||||||
case map[string]smart.SmartData:
|
|
||||||
response.SmartData = v
|
|
||||||
case systemd.ServiceDetails:
|
|
||||||
response.ServiceInfo = v
|
|
||||||
// case []byte:
|
|
||||||
// response.RawBytes = v
|
|
||||||
// case string:
|
|
||||||
// response.RawBytes = []byte(v)
|
|
||||||
default:
|
|
||||||
// For any other type, convert to error
|
|
||||||
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client.sendMessage(response)
|
return client.sendMessage(response)
|
||||||
} else {
|
|
||||||
// Legacy format - send data directly
|
|
||||||
return client.sendMessage(data)
|
|
||||||
}
|
}
|
||||||
|
// Legacy format - send data directly
|
||||||
|
return client.sendMessage(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUserAgent returns one of two User-Agent strings based on current time.
|
// getUserAgent returns one of two User-Agent strings based on current time.
|
||||||
|
|||||||
31
agent/response.go
Normal file
31
agent/response.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newAgentResponse creates an AgentResponse using legacy typed fields.
|
||||||
|
// This maintains backward compatibility with <= 0.17 hubs that expect specific fields.
|
||||||
|
func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
|
||||||
|
response := common.AgentResponse{Id: requestID}
|
||||||
|
switch v := data.(type) {
|
||||||
|
case *system.CombinedData:
|
||||||
|
response.SystemData = v
|
||||||
|
case *common.FingerprintResponse:
|
||||||
|
response.Fingerprint = v
|
||||||
|
case string:
|
||||||
|
response.String = &v
|
||||||
|
case map[string]smart.SmartData:
|
||||||
|
response.SmartData = v
|
||||||
|
case systemd.ServiceDetails:
|
||||||
|
response.ServiceInfo = v
|
||||||
|
default:
|
||||||
|
// For unknown types, use the generic Data field
|
||||||
|
response.Data, _ = cbor.Marshal(data)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
@@ -13,9 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
@@ -165,20 +163,9 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
// responder that writes AgentResponse to stdout
|
// responder that writes AgentResponse to stdout
|
||||||
|
// Uses legacy typed fields for backward compatibility with <= 0.17
|
||||||
sshResponder := func(data any, requestID *uint32) error {
|
sshResponder := func(data any, requestID *uint32) error {
|
||||||
response := common.AgentResponse{Id: requestID}
|
response := newAgentResponse(data, requestID)
|
||||||
switch v := data.(type) {
|
|
||||||
case *system.CombinedData:
|
|
||||||
response.SystemData = v
|
|
||||||
case string:
|
|
||||||
response.String = &v
|
|
||||||
case map[string]smart.SmartData:
|
|
||||||
response.SmartData = v
|
|
||||||
case systemd.ServiceDetails:
|
|
||||||
response.ServiceInfo = v
|
|
||||||
default:
|
|
||||||
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
|
||||||
}
|
|
||||||
return cbor.NewEncoder(w).Encode(response)
|
return cbor.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.18.0-beta.1"
|
Version = "0.18.0-beta.2"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
@@ -34,14 +35,14 @@ type HubRequest[T any] struct {
|
|||||||
// AgentResponse defines the structure for responses sent from agent to hub.
|
// AgentResponse defines the structure for responses sent from agent to hub.
|
||||||
type AgentResponse struct {
|
type AgentResponse struct {
|
||||||
Id *uint32 `cbor:"0,keyasint,omitempty"`
|
Id *uint32 `cbor:"0,keyasint,omitempty"`
|
||||||
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
|
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
|
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
||||||
String *string `cbor:"4,keyasint,omitempty,omitzero"`
|
String *string `cbor:"4,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"`
|
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"`
|
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
|
// Data is the generic response payload for new endpoints (0.18+)
|
||||||
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FingerprintRequest struct {
|
type FingerprintRequest struct {
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/transport"
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
|
||||||
@@ -23,28 +25,30 @@ import (
|
|||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/lxzan/gws"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
type System struct {
|
type System struct {
|
||||||
Id string `db:"id"`
|
Id string `db:"id"`
|
||||||
Host string `db:"host"`
|
Host string `db:"host"`
|
||||||
Port string `db:"port"`
|
Port string `db:"port"`
|
||||||
Status string `db:"status"`
|
Status string `db:"status"`
|
||||||
manager *SystemManager // Manager that this system belongs to
|
manager *SystemManager // Manager that this system belongs to
|
||||||
client *ssh.Client // SSH client for fetching data
|
client *ssh.Client // SSH client for fetching data
|
||||||
data *system.CombinedData // system data from agent
|
sshTransport *transport.SSHTransport // SSH transport for requests
|
||||||
ctx context.Context // Context for stopping the updater
|
data *system.CombinedData // system data from agent
|
||||||
cancel context.CancelFunc // Stops and removes system from updater
|
ctx context.Context // Context for stopping the updater
|
||||||
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
cancel context.CancelFunc // Stops and removes system from updater
|
||||||
agentVersion semver.Version // Agent version
|
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
||||||
updateTicker *time.Ticker // Ticker for updating the system
|
agentVersion semver.Version // Agent version
|
||||||
detailsFetched atomic.Bool // True if static system details have been fetched and saved
|
updateTicker *time.Ticker // Ticker for updating the system
|
||||||
smartFetching atomic.Bool // True if SMART devices are currently being fetched
|
detailsFetched atomic.Bool // True if static system details have been fetched and saved
|
||||||
smartInterval time.Duration // Interval for periodic SMART data updates
|
smartFetching atomic.Bool // True if SMART devices are currently being fetched
|
||||||
lastSmartFetch atomic.Int64 // Unix milliseconds of last SMART data fetch
|
smartInterval time.Duration // Interval for periodic SMART data updates
|
||||||
|
lastSmartFetch atomic.Int64 // Unix milliseconds of last SMART data fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SystemManager) NewSystem(systemId string) *System {
|
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||||
@@ -359,8 +363,78 @@ func (sys *System) getContext() (context.Context, context.CancelFunc) {
|
|||||||
return sys.ctx, sys.cancel
|
return sys.ctx, sys.cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchDataFromAgent attempts to fetch data from the agent,
|
// request sends a request to the agent, trying WebSocket first, then SSH.
|
||||||
// prioritizing WebSocket if available.
|
// This is the unified request method that uses the transport abstraction.
|
||||||
|
func (sys *System) request(ctx context.Context, action common.WebSocketAction, req any, dest any) error {
|
||||||
|
// Try WebSocket first
|
||||||
|
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||||
|
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
|
||||||
|
if err := wsTransport.Request(ctx, action, req, dest); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !shouldFallbackToSSH(err) {
|
||||||
|
return err
|
||||||
|
} else if shouldCloseWebSocket(err) {
|
||||||
|
sys.closeWebSocketConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to SSH if WebSocket fails
|
||||||
|
if err := sys.ensureSSHTransport(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err := sys.sshTransport.RequestWithRetry(ctx, action, req, dest, 1)
|
||||||
|
// Keep legacy SSH client/version fields in sync for other code paths.
|
||||||
|
if sys.sshTransport != nil {
|
||||||
|
sys.client = sys.sshTransport.GetClient()
|
||||||
|
sys.agentVersion = sys.sshTransport.GetAgentVersion()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldFallbackToSSH(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if errors.Is(err, gws.ErrConnClosed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return errors.Is(err, transport.ErrWebSocketNotConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldCloseWebSocket(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return errors.Is(err, gws.ErrConnClosed) || errors.Is(err, transport.ErrWebSocketNotConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSSHTransport ensures the SSH transport is initialized and connected.
|
||||||
|
func (sys *System) ensureSSHTransport() error {
|
||||||
|
if sys.sshTransport == nil {
|
||||||
|
if sys.manager.sshConfig == nil {
|
||||||
|
if err := sys.manager.createSSHClientConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sys.sshTransport = transport.NewSSHTransport(transport.SSHTransportConfig{
|
||||||
|
Host: sys.Host,
|
||||||
|
Port: sys.Port,
|
||||||
|
Config: sys.manager.sshConfig,
|
||||||
|
Timeout: 4 * time.Second,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Sync client state with transport
|
||||||
|
if sys.client != nil {
|
||||||
|
sys.sshTransport.SetClient(sys.client)
|
||||||
|
sys.sshTransport.SetAgentVersion(sys.agentVersion)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchDataFromAgent attempts to fetch data from the agent, prioritizing WebSocket if available.
|
||||||
func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*system.CombinedData, error) {
|
func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*system.CombinedData, error) {
|
||||||
if sys.data == nil {
|
if sys.data == nil {
|
||||||
sys.data = &system.CombinedData{}
|
sys.data = &system.CombinedData{}
|
||||||
@@ -386,112 +460,47 @@ func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*sy
|
|||||||
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
|
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
|
||||||
return nil, errors.New("no websocket connection")
|
return nil, errors.New("no websocket connection")
|
||||||
}
|
}
|
||||||
err := sys.WsConn.RequestSystemData(context.Background(), sys.data, options)
|
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
|
||||||
|
err := wsTransport.Request(context.Background(), common.GetData, options, sys.data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return sys.data, nil
|
return sys.data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchStringFromAgentViaSSH is a generic function to fetch strings via SSH
|
|
||||||
func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
|
|
||||||
var result string
|
|
||||||
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
|
||||||
stdout, err := session.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
stdin, stdinErr := session.StdinPipe()
|
|
||||||
if stdinErr != nil {
|
|
||||||
return false, stdinErr
|
|
||||||
}
|
|
||||||
if err := session.Shell(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
req := common.HubRequest[any]{Action: action, Data: requestData}
|
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
|
||||||
_ = stdin.Close()
|
|
||||||
var resp common.AgentResponse
|
|
||||||
err = cbor.NewDecoder(stdout).Decode(&resp)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if resp.String == nil {
|
|
||||||
return false, errors.New(errorMsg)
|
|
||||||
}
|
|
||||||
result = *resp.String
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchContainerInfoFromAgent fetches container info from the agent
|
// FetchContainerInfoFromAgent fetches container info from the agent
|
||||||
func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) {
|
func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) {
|
||||||
// fetch via websocket
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
defer cancel()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
var result string
|
||||||
defer cancel()
|
err := sys.request(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, &result)
|
||||||
return sys.WsConn.RequestContainerInfo(ctx, containerID)
|
return result, err
|
||||||
}
|
|
||||||
// fetch via SSH
|
|
||||||
return sys.fetchStringFromAgentViaSSH(common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchContainerLogsFromAgent fetches container logs from the agent
|
// FetchContainerLogsFromAgent fetches container logs from the agent
|
||||||
func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) {
|
func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) {
|
||||||
// fetch via websocket
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
defer cancel()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
var result string
|
||||||
defer cancel()
|
err := sys.request(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, &result)
|
||||||
return sys.WsConn.RequestContainerLogs(ctx, containerID)
|
return result, err
|
||||||
}
|
|
||||||
// fetch via SSH
|
|
||||||
return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
|
// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
|
||||||
func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) {
|
func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) {
|
||||||
// fetch via websocket
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
defer cancel()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return sys.WsConn.RequestSystemdInfo(ctx, serviceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result systemd.ServiceDetails
|
var result systemd.ServiceDetails
|
||||||
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
err := sys.request(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName}, &result)
|
||||||
stdout, err := session.StdoutPipe()
|
return result, err
|
||||||
if err != nil {
|
}
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
stdin, stdinErr := session.StdinPipe()
|
|
||||||
if stdinErr != nil {
|
|
||||||
return false, stdinErr
|
|
||||||
}
|
|
||||||
if err := session.Shell(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req := common.HubRequest[any]{Action: common.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}}
|
|
||||||
if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
_ = stdin.Close()
|
|
||||||
|
|
||||||
var resp common.AgentResponse
|
|
||||||
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if resp.ServiceInfo == nil {
|
|
||||||
if resp.Error != "" {
|
|
||||||
return false, errors.New(resp.Error)
|
|
||||||
}
|
|
||||||
return false, errors.New("no systemd info in response")
|
|
||||||
}
|
|
||||||
result = resp.ServiceInfo
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// FetchSmartDataFromAgent fetches SMART data from the agent
|
||||||
|
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var result map[string]smart.SmartData
|
||||||
|
err := sys.request(ctx, common.GetSmartData, nil, &result)
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,6 +665,9 @@ func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session
|
|||||||
|
|
||||||
// closeSSHConnection closes the SSH connection but keeps the system in the manager
|
// closeSSHConnection closes the SSH connection but keeps the system in the manager
|
||||||
func (sys *System) closeSSHConnection() {
|
func (sys *System) closeSSHConnection() {
|
||||||
|
if sys.sshTransport != nil {
|
||||||
|
sys.sshTransport.Close()
|
||||||
|
}
|
||||||
if sys.client != nil {
|
if sys.client != nil {
|
||||||
sys.client.Close()
|
sys.client.Close()
|
||||||
sys.client = nil
|
sys.client = nil
|
||||||
|
|||||||
@@ -1,54 +1,14 @@
|
|||||||
package systems
|
package systems
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// FetchSmartDataFromAgent fetches SMART data from the agent
|
|
||||||
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
|
|
||||||
// fetch via websocket
|
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return sys.WsConn.RequestSmartData(ctx)
|
|
||||||
}
|
|
||||||
// fetch via SSH
|
|
||||||
var result map[string]smart.SmartData
|
|
||||||
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
|
||||||
stdout, err := session.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
stdin, stdinErr := session.StdinPipe()
|
|
||||||
if stdinErr != nil {
|
|
||||||
return false, stdinErr
|
|
||||||
}
|
|
||||||
if err := session.Shell(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
req := common.HubRequest[any]{Action: common.GetSmartData}
|
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
|
||||||
_ = stdin.Close()
|
|
||||||
var resp common.AgentResponse
|
|
||||||
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
result = resp.SmartData
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
||||||
func (sys *System) FetchAndSaveSmartDevices() error {
|
func (sys *System) FetchAndSaveSmartDevices() error {
|
||||||
smartData, err := sys.FetchSmartDataFromAgent()
|
smartData, err := sys.FetchSmartDataFromAgent()
|
||||||
|
|||||||
227
internal/hub/transport/ssh.go
Normal file
227
internal/hub/transport/ssh.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHTransport implements Transport over SSH connections.
|
||||||
|
type SSHTransport struct {
|
||||||
|
client *ssh.Client
|
||||||
|
config *ssh.ClientConfig
|
||||||
|
host string
|
||||||
|
port string
|
||||||
|
agentVersion semver.Version
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHTransportConfig holds configuration for creating an SSH transport.
|
||||||
|
type SSHTransportConfig struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
Config *ssh.ClientConfig
|
||||||
|
AgentVersion semver.Version
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSHTransport creates a new SSH transport with the given configuration.
|
||||||
|
func NewSSHTransport(cfg SSHTransportConfig) *SSHTransport {
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 4 * time.Second
|
||||||
|
}
|
||||||
|
return &SSHTransport{
|
||||||
|
config: cfg.Config,
|
||||||
|
host: cfg.Host,
|
||||||
|
port: cfg.Port,
|
||||||
|
agentVersion: cfg.AgentVersion,
|
||||||
|
timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClient sets the SSH client for reuse across requests.
|
||||||
|
func (t *SSHTransport) SetClient(client *ssh.Client) {
|
||||||
|
t.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAgentVersion sets the agent version (extracted from SSH handshake).
|
||||||
|
func (t *SSHTransport) SetAgentVersion(version semver.Version) {
|
||||||
|
t.agentVersion = version
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClient returns the current SSH client (for connection management).
|
||||||
|
func (t *SSHTransport) GetClient() *ssh.Client {
|
||||||
|
return t.client
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgentVersion returns the agent version.
|
||||||
|
func (t *SSHTransport) GetAgentVersion() semver.Version {
|
||||||
|
return t.agentVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request sends a request to the agent via SSH and unmarshals the response.
|
||||||
|
func (t *SSHTransport) Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error {
|
||||||
|
if t.client == nil {
|
||||||
|
if err := t.connect(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := t.createSessionWithTimeout(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
stdout, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stdin, err := session.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := session.Shell(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
hubReq := common.HubRequest[any]{Action: action, Data: req}
|
||||||
|
if err := cbor.NewEncoder(stdin).Encode(hubReq); err != nil {
|
||||||
|
return fmt.Errorf("failed to encode request: %w", err)
|
||||||
|
}
|
||||||
|
stdin.Close()
|
||||||
|
|
||||||
|
// Read response
|
||||||
|
var resp common.AgentResponse
|
||||||
|
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Error != "" {
|
||||||
|
return errors.New(resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Wait(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return UnmarshalResponse(resp, action, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected returns true if the SSH connection is active.
|
||||||
|
func (t *SSHTransport) IsConnected() bool {
|
||||||
|
return t.client != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the SSH connection.
|
||||||
|
func (t *SSHTransport) Close() {
|
||||||
|
if t.client != nil {
|
||||||
|
t.client.Close()
|
||||||
|
t.client = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect establishes a new SSH connection.
|
||||||
|
func (t *SSHTransport) connect() error {
|
||||||
|
if t.config == nil {
|
||||||
|
return errors.New("SSH config not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
network := "tcp"
|
||||||
|
host := t.host
|
||||||
|
if strings.HasPrefix(host, "/") {
|
||||||
|
network = "unix"
|
||||||
|
} else {
|
||||||
|
host = net.JoinHostPort(host, t.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := ssh.Dial(network, host, t.config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.client = client
|
||||||
|
|
||||||
|
// Extract agent version from server version string
|
||||||
|
t.agentVersion, _ = extractAgentVersion(string(client.Conn.ServerVersion()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSessionWithTimeout creates a new SSH session with a timeout.
|
||||||
|
func (t *SSHTransport) createSessionWithTimeout(ctx context.Context) (*ssh.Session, error) {
|
||||||
|
if t.client == nil {
|
||||||
|
return nil, errors.New("client not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sessionChan := make(chan *ssh.Session, 1)
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
session, err := t.client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
errChan <- err
|
||||||
|
} else {
|
||||||
|
sessionChan <- session
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case session := <-sessionChan:
|
||||||
|
return session, nil
|
||||||
|
case err := <-errChan:
|
||||||
|
return nil, err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, errors.New("timeout creating session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAgentVersion extracts the beszel version from SSH server version string.
|
||||||
|
func extractAgentVersion(versionString string) (semver.Version, error) {
|
||||||
|
_, after, _ := strings.Cut(versionString, "_")
|
||||||
|
return semver.Parse(after)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestWithRetry sends a request with automatic retry on connection failures.
|
||||||
|
func (t *SSHTransport) RequestWithRetry(ctx context.Context, action common.WebSocketAction, req any, dest any, retries int) error {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= retries; attempt++ {
|
||||||
|
err := t.Request(ctx, action, req, dest)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
|
||||||
|
// Check if it's a connection error that warrants a retry
|
||||||
|
if isConnectionError(err) && attempt < retries {
|
||||||
|
t.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// isConnectionError checks if an error indicates a connection problem.
|
||||||
|
func isConnectionError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
errStr := err.Error()
|
||||||
|
return strings.Contains(errStr, "connection") ||
|
||||||
|
strings.Contains(errStr, "EOF") ||
|
||||||
|
strings.Contains(errStr, "closed") ||
|
||||||
|
errors.Is(err, io.EOF)
|
||||||
|
}
|
||||||
112
internal/hub/transport/transport.go
Normal file
112
internal/hub/transport/transport.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// Package transport provides a unified abstraction for hub-agent communication
|
||||||
|
// over different transports (WebSocket, SSH).
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transport defines the interface for hub-agent communication.
|
||||||
|
// Both WebSocket and SSH transports implement this interface.
|
||||||
|
type Transport interface {
|
||||||
|
// Request sends a request to the agent and unmarshals the response into dest.
|
||||||
|
// The dest parameter should be a pointer to the expected response type.
|
||||||
|
Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error
|
||||||
|
// IsConnected returns true if the transport connection is active.
|
||||||
|
IsConnected() bool
|
||||||
|
// Close terminates the transport connection.
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalResponse unmarshals an AgentResponse into the destination type.
|
||||||
|
// It first checks the generic Data field (0.19+ agents), then falls back
|
||||||
|
// to legacy typed fields for backward compatibility with 0.18.0 agents.
|
||||||
|
func UnmarshalResponse(resp common.AgentResponse, action common.WebSocketAction, dest any) error {
|
||||||
|
if dest == nil {
|
||||||
|
return errors.New("nil destination")
|
||||||
|
}
|
||||||
|
// Try generic Data field first (0.19+)
|
||||||
|
if len(resp.Data) > 0 {
|
||||||
|
if err := cbor.Unmarshal(resp.Data, dest); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal generic response data: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Fall back to legacy typed fields for older agents/hubs.
|
||||||
|
return unmarshalLegacyResponse(resp, action, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshalLegacyResponse handles legacy responses that use typed fields.
|
||||||
|
func unmarshalLegacyResponse(resp common.AgentResponse, action common.WebSocketAction, dest any) error {
|
||||||
|
switch action {
|
||||||
|
case common.GetData:
|
||||||
|
d, ok := dest.(*system.CombinedData)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected dest type for GetData: %T", dest)
|
||||||
|
}
|
||||||
|
if resp.SystemData == nil {
|
||||||
|
return errors.New("no system data in response")
|
||||||
|
}
|
||||||
|
*d = *resp.SystemData
|
||||||
|
return nil
|
||||||
|
case common.CheckFingerprint:
|
||||||
|
d, ok := dest.(*common.FingerprintResponse)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected dest type for CheckFingerprint: %T", dest)
|
||||||
|
}
|
||||||
|
if resp.Fingerprint == nil {
|
||||||
|
return errors.New("no fingerprint in response")
|
||||||
|
}
|
||||||
|
*d = *resp.Fingerprint
|
||||||
|
return nil
|
||||||
|
case common.GetContainerLogs:
|
||||||
|
d, ok := dest.(*string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected dest type for GetContainerLogs: %T", dest)
|
||||||
|
}
|
||||||
|
if resp.String == nil {
|
||||||
|
return errors.New("no logs in response")
|
||||||
|
}
|
||||||
|
*d = *resp.String
|
||||||
|
return nil
|
||||||
|
case common.GetContainerInfo:
|
||||||
|
d, ok := dest.(*string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected dest type for GetContainerInfo: %T", dest)
|
||||||
|
}
|
||||||
|
if resp.String == nil {
|
||||||
|
return errors.New("no info in response")
|
||||||
|
}
|
||||||
|
*d = *resp.String
|
||||||
|
return nil
|
||||||
|
case common.GetSmartData:
|
||||||
|
d, ok := dest.(*map[string]smart.SmartData)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected dest type for GetSmartData: %T", dest)
|
||||||
|
}
|
||||||
|
if resp.SmartData == nil {
|
||||||
|
return errors.New("no SMART data in response")
|
||||||
|
}
|
||||||
|
*d = resp.SmartData
|
||||||
|
return nil
|
||||||
|
case common.GetSystemdInfo:
|
||||||
|
d, ok := dest.(*systemd.ServiceDetails)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected dest type for GetSystemdInfo: %T", dest)
|
||||||
|
}
|
||||||
|
if resp.ServiceInfo == nil {
|
||||||
|
return errors.New("no systemd info in response")
|
||||||
|
}
|
||||||
|
*d = resp.ServiceInfo
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unsupported action: %d", action)
|
||||||
|
}
|
||||||
74
internal/hub/transport/websocket.go
Normal file
74
internal/hub/transport/websocket.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrWebSocketNotConnected indicates a WebSocket transport is not currently connected.
|
||||||
|
var ErrWebSocketNotConnected = errors.New("websocket not connected")
|
||||||
|
|
||||||
|
// WebSocketTransport implements Transport over WebSocket connections.
|
||||||
|
type WebSocketTransport struct {
|
||||||
|
wsConn *ws.WsConn
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebSocketTransport creates a new WebSocket transport wrapper.
|
||||||
|
func NewWebSocketTransport(wsConn *ws.WsConn) *WebSocketTransport {
|
||||||
|
return &WebSocketTransport{wsConn: wsConn}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request sends a request to the agent via WebSocket and unmarshals the response.
|
||||||
|
func (t *WebSocketTransport) Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error {
|
||||||
|
if !t.IsConnected() {
|
||||||
|
return ErrWebSocketNotConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingReq, err := t.wsConn.SendRequest(ctx, action, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for response
|
||||||
|
select {
|
||||||
|
case message := <-pendingReq.ResponseCh:
|
||||||
|
defer message.Close()
|
||||||
|
defer pendingReq.Cancel()
|
||||||
|
|
||||||
|
// Legacy agents (< MinVersionAgentResponse) respond with a raw payload instead of an AgentResponse wrapper.
|
||||||
|
if t.wsConn.AgentVersion().LT(beszel.MinVersionAgentResponse) {
|
||||||
|
return cbor.Unmarshal(message.Data.Bytes(), dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
var agentResponse common.AgentResponse
|
||||||
|
if err := cbor.Unmarshal(message.Data.Bytes(), &agentResponse); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if agentResponse.Error != "" {
|
||||||
|
return errors.New(agentResponse.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return UnmarshalResponse(agentResponse, action, dest)
|
||||||
|
|
||||||
|
case <-pendingReq.Context.Done():
|
||||||
|
return pendingReq.Context.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected returns true if the WebSocket connection is active.
|
||||||
|
func (t *WebSocketTransport) IsConnected() bool {
|
||||||
|
return t.wsConn != nil && t.wsConn.IsConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the WebSocket connection.
|
||||||
|
func (t *WebSocketTransport) Close() {
|
||||||
|
if t.wsConn != nil {
|
||||||
|
t.wsConn.Close(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,12 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResponseHandler defines interface for handling agent responses
|
// ResponseHandler defines interface for handling agent responses.
|
||||||
|
// This is used by handleAgentRequest for legacy response handling.
|
||||||
type ResponseHandler interface {
|
type ResponseHandler interface {
|
||||||
Handle(agentResponse common.AgentResponse) error
|
Handle(agentResponse common.AgentResponse) error
|
||||||
HandleLegacy(rawData []byte) error
|
HandleLegacy(rawData []byte) error
|
||||||
@@ -27,167 +25,7 @@ func (h *BaseHandler) HandleLegacy(rawData []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
// Fingerprint handling (used for WebSocket authentication)
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// systemDataHandler implements ResponseHandler for system data requests
|
|
||||||
type systemDataHandler struct {
|
|
||||||
data *system.CombinedData
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *systemDataHandler) HandleLegacy(rawData []byte) error {
|
|
||||||
return cbor.Unmarshal(rawData, h.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *systemDataHandler) Handle(agentResponse common.AgentResponse) error {
|
|
||||||
if agentResponse.SystemData != nil {
|
|
||||||
*h.data = *agentResponse.SystemData
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestSystemData requests system metrics from the agent and unmarshals the response.
|
|
||||||
func (ws *WsConn) RequestSystemData(ctx context.Context, data *system.CombinedData, options common.DataRequestOptions) error {
|
|
||||||
if !ws.IsConnected() {
|
|
||||||
return gws.ErrConnClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := ws.requestManager.SendRequest(ctx, common.GetData, options)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := &systemDataHandler{data: data}
|
|
||||||
return ws.handleAgentRequest(req, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// stringResponseHandler is a generic handler for string responses from agents
|
|
||||||
type stringResponseHandler struct {
|
|
||||||
BaseHandler
|
|
||||||
value string
|
|
||||||
errorMsg string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *stringResponseHandler) Handle(agentResponse common.AgentResponse) error {
|
|
||||||
if agentResponse.String == nil {
|
|
||||||
return errors.New(h.errorMsg)
|
|
||||||
}
|
|
||||||
h.value = *agentResponse.String
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// requestContainerStringViaWS is a generic function to request container-related strings via WebSocket
|
|
||||||
func (ws *WsConn) requestContainerStringViaWS(ctx context.Context, action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
|
|
||||||
if !ws.IsConnected() {
|
|
||||||
return "", gws.ErrConnClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := ws.requestManager.SendRequest(ctx, action, requestData)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := &stringResponseHandler{errorMsg: errorMsg}
|
|
||||||
if err := ws.handleAgentRequest(req, handler); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return handler.value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestContainerLogs requests logs for a specific container via WebSocket.
|
|
||||||
func (ws *WsConn) RequestContainerLogs(ctx context.Context, containerID string) (string, error) {
|
|
||||||
return ws.requestContainerStringViaWS(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestContainerInfo requests information about a specific container via WebSocket.
|
|
||||||
func (ws *WsConn) RequestContainerInfo(ctx context.Context, containerID string) (string, error) {
|
|
||||||
return ws.requestContainerStringViaWS(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// RequestSystemdInfo requests detailed information about a systemd service via WebSocket.
|
|
||||||
func (ws *WsConn) RequestSystemdInfo(ctx context.Context, serviceName string) (systemd.ServiceDetails, error) {
|
|
||||||
if !ws.IsConnected() {
|
|
||||||
return nil, gws.ErrConnClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := ws.requestManager.SendRequest(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result systemd.ServiceDetails
|
|
||||||
handler := &systemdInfoHandler{result: &result}
|
|
||||||
if err := ws.handleAgentRequest(req, handler); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// systemdInfoHandler parses ServiceDetails from AgentResponse
|
|
||||||
type systemdInfoHandler struct {
|
|
||||||
BaseHandler
|
|
||||||
result *systemd.ServiceDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error {
|
|
||||||
if agentResponse.ServiceInfo == nil {
|
|
||||||
return errors.New("no systemd info in response")
|
|
||||||
}
|
|
||||||
*h.result = agentResponse.ServiceInfo
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// RequestSmartData requests SMART data via WebSocket.
|
|
||||||
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]smart.SmartData, error) {
|
|
||||||
if !ws.IsConnected() {
|
|
||||||
return nil, gws.ErrConnClosed
|
|
||||||
}
|
|
||||||
req, err := ws.requestManager.SendRequest(ctx, common.GetSmartData, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var result map[string]smart.SmartData
|
|
||||||
handler := ResponseHandler(&smartDataHandler{result: &result})
|
|
||||||
if err := ws.handleAgentRequest(req, handler); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// smartDataHandler parses SMART data map from AgentResponse
|
|
||||||
type smartDataHandler struct {
|
|
||||||
BaseHandler
|
|
||||||
result *map[string]smart.SmartData
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
|
|
||||||
if agentResponse.SmartData == nil {
|
|
||||||
return errors.New("no SMART data in response")
|
|
||||||
}
|
|
||||||
*h.result = agentResponse.SmartData
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// fingerprintHandler implements ResponseHandler for fingerprint requests
|
// fingerprintHandler implements ResponseHandler for fingerprint requests
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerSuccess(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test successful handling with valid ServiceInfo
|
|
||||||
testDetails := systemd.ServiceDetails{
|
|
||||||
"Id": "nginx.service",
|
|
||||||
"ActiveState": "active",
|
|
||||||
"SubState": "running",
|
|
||||||
"Description": "A high performance web server",
|
|
||||||
"ExecMainPID": 1234,
|
|
||||||
"MemoryCurrent": 1024000,
|
|
||||||
}
|
|
||||||
|
|
||||||
response := common.AgentResponse{
|
|
||||||
ServiceInfo: testDetails,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, testDetails, *handler.result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerError(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test error handling when ServiceInfo is nil
|
|
||||||
response := common.AgentResponse{
|
|
||||||
ServiceInfo: nil,
|
|
||||||
Error: "service not found",
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "no systemd info in response", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerEmptyResponse(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test with completely empty response
|
|
||||||
response := common.AgentResponse{}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "no systemd info in response", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerLegacyNotSupported(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that legacy format is not supported
|
|
||||||
err := handler.HandleLegacy([]byte("some data"))
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "legacy format not supported", err.Error())
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,15 @@ func NewRequestManager(conn *gws.Conn) *RequestManager {
|
|||||||
func (rm *RequestManager) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) {
|
func (rm *RequestManager) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) {
|
||||||
reqID := RequestID(rm.nextID.Add(1))
|
reqID := RequestID(rm.nextID.Add(1))
|
||||||
|
|
||||||
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
// Respect any caller-provided deadline. If none is set, apply a reasonable default
|
||||||
|
// so pending requests don't live forever if the agent never responds.
|
||||||
|
reqCtx := ctx
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
if _, hasDeadline := ctx.Deadline(); hasDeadline {
|
||||||
|
reqCtx, cancel = context.WithCancel(ctx)
|
||||||
|
} else {
|
||||||
|
reqCtx, cancel = context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
req := &PendingRequest{
|
req := &PendingRequest{
|
||||||
ID: reqID,
|
ID: reqID,
|
||||||
@@ -100,6 +108,11 @@ func (rm *RequestManager) handleResponse(message *gws.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if response.Id == nil {
|
||||||
|
rm.routeLegacyResponse(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
reqID := RequestID(*response.Id)
|
reqID := RequestID(*response.Id)
|
||||||
|
|
||||||
rm.RLock()
|
rm.RLock()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
"weak"
|
"weak"
|
||||||
@@ -161,3 +162,14 @@ func (ws *WsConn) handleAgentRequest(req *PendingRequest, handler ResponseHandle
|
|||||||
func (ws *WsConn) IsConnected() bool {
|
func (ws *WsConn) IsConnected() bool {
|
||||||
return ws.conn != nil
|
return ws.conn != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentVersion returns the connected agent's version (as reported during handshake).
|
||||||
|
func (ws *WsConn) AgentVersion() semver.Version {
|
||||||
|
return ws.agentVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRequest sends a request to the agent and returns a pending request handle.
|
||||||
|
// This is used by the transport layer to send requests.
|
||||||
|
func (ws *WsConn) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) {
|
||||||
|
return ws.requestManager.SendRequest(ctx, action, data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -184,14 +184,18 @@ func TestCommonActions(t *testing.T) {
|
|||||||
assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2")
|
assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogsHandler(t *testing.T) {
|
func TestFingerprintHandler(t *testing.T) {
|
||||||
h := &stringResponseHandler{errorMsg: "no logs in response"}
|
var result common.FingerprintResponse
|
||||||
|
h := &fingerprintHandler{result: &result}
|
||||||
|
|
||||||
logValue := "test logs"
|
resp := common.AgentResponse{Fingerprint: &common.FingerprintResponse{
|
||||||
resp := common.AgentResponse{String: &logValue}
|
Fingerprint: "test-fingerprint",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}}
|
||||||
err := h.Handle(resp)
|
err := h.Handle(resp)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, logValue, h.value)
|
assert.Equal(t, "test-fingerprint", result.Fingerprint)
|
||||||
|
assert.Equal(t, "test-host", result.Hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandler tests that we can create a Handler
|
// TestHandler tests that we can create a Handler
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.0-beta.1",
|
"version": "0.18.0-beta.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
|
|||||||
@@ -16,7 +16,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, SystemStatus, Unit } from "@/lib/enums"
|
||||||
import { batteryStateTranslations } from "@/lib/i18n"
|
import { batteryStateTranslations } from "@/lib/i18n"
|
||||||
import {
|
import {
|
||||||
$allSystemsById,
|
$allSystemsById,
|
||||||
@@ -222,7 +222,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
}, [system.id])
|
}, [system.id])
|
||||||
|
|
||||||
// subscribe to realtime metrics if chart time is 1m
|
// subscribe to realtime metrics if chart time is 1m
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsub = () => {}
|
let unsub = () => {}
|
||||||
if (!system.id || chartTime !== "1m") {
|
if (!system.id || chartTime !== "1m") {
|
||||||
@@ -260,7 +259,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
}
|
}
|
||||||
}, [chartTime, system.id])
|
}, [chartTime, system.id])
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
|
||||||
const chartData: ChartData = useMemo(() => {
|
const chartData: ChartData = useMemo(() => {
|
||||||
const lastCreated = Math.max(
|
const lastCreated = Math.max(
|
||||||
(systemStats.at(-1)?.created as number) ?? 0,
|
(systemStats.at(-1)?.created as number) ?? 0,
|
||||||
@@ -300,7 +298,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// get stats
|
// get stats
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!system.id || !chartTime || chartTime === "1m") {
|
if (!system.id || !chartTime || chartTime === "1m") {
|
||||||
return
|
return
|
||||||
@@ -407,7 +404,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const hasGpuData = lastGpuVals.length > 0
|
const hasGpuData = lastGpuVals.length > 0
|
||||||
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined)
|
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined)
|
||||||
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined)
|
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined)
|
||||||
const isLinux = (details?.os ?? system.info?.os) === Os.Linux
|
const isLinux = !(details?.os ?? system.info?.os)
|
||||||
const isPodman = details?.podman ?? system.info?.p ?? false
|
const isPodman = details?.podman ?? system.info?.p ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -287,12 +287,12 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconColor = pct < 10 ? "text-red-500" : pct < 25 ? "text-yellow-500" : "text-muted-foreground"
|
|
||||||
|
|
||||||
let Icon = PlugChargingIcon
|
let Icon = PlugChargingIcon
|
||||||
|
let iconColor = "text-muted-foreground"
|
||||||
|
|
||||||
if (state !== BatteryState.Charging) {
|
if (state !== BatteryState.Charging) {
|
||||||
if (pct < 25) {
|
if (pct < 25) {
|
||||||
|
iconColor = pct < 11 ? "text-red-500" : "text-yellow-500"
|
||||||
Icon = BatteryLowIcon
|
Icon = BatteryLowIcon
|
||||||
} else if (pct < 75) {
|
} else if (pct < 75) {
|
||||||
Icon = BatteryMediumIcon
|
Icon = BatteryMediumIcon
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function BatteryMediumIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
export function BatteryLowIcon(props: SVGProps<SVGSVGElement>) {
|
export function BatteryLowIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
|
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
|
||||||
<path d="M16 20H8V6h8m.67-2H15V2H9v2H7.33C6.6 4 6 4.6 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34c.74 0 1.33-.59 1.33-1.33V5.33C18 4.6 17.4 4 16.67 4M15 16H9v3h6zm0-4.5H9v3h6z" />
|
<path d="M16 17H8V6h8m.7-2H15V2H9v2H7.3A1.3 1.3 0 0 0 6 5.3v15.4q.1 1.2 1.3 1.3h9.4a1.3 1.3 0 0 0 1.3-1.3V5.3q-.1-1.2-1.3-1.3" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user