From 5ed03e67493fc4b84e218ef44c062b576abb52bd Mon Sep 17 00:00:00 2001 From: Juan Denis <13461850+jhd3197@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:06:11 -0500 Subject: [PATCH 01/18] Migrate agent to Socket.IO; update endpoints Replace raw WebSocket handling with Socket.IO/Engine.IO protocol support in agent/internal/ws: build Socket.IO URL, perform Engine.IO OPEN handshake, connect to namespace, emit/parse Socket.IO events for auth, adapt read/write loops, add ping loop and reconnection handling, and map events to protocol messages. Update agent registration endpoint to /api/v1/servers/register. Add config file handling in cmd/agent (respect --config flag, set key file relative to config path) and import filepath. Update install scripts to new download API paths for Windows and *nix. Adjust frontend servers page copy to show registration tokens expire in 24 hours. Also add a Contributors section to the create-pr skill guidance. --- .claude/skills/create-pr/SKILL.md | 7 + agent/cmd/agent/main.go | 16 +- agent/internal/agent/registration.go | 2 +- agent/internal/ws/client.go | 415 +++++++++++++++++++++++---- agent/scripts/install.ps1 | 2 +- agent/scripts/install.sh | 2 +- frontend/src/pages/Servers.jsx | 2 +- 7 files changed, 378 insertions(+), 68 deletions(-) diff --git a/.claude/skills/create-pr/SKILL.md b/.claude/skills/create-pr/SKILL.md index bec9f74..2a6a5ae 100644 --- a/.claude/skills/create-pr/SKILL.md +++ b/.claude/skills/create-pr/SKILL.md @@ -103,6 +103,13 @@ Omit the Highlights section entirely for internal-only PRs — don't force it. - Bullets should describe the mechanism, not just the intent. "Race condition in `get_or_create_chat` fixed by moving creation inside the lookup session" is good. "Fix database issues" is not. - Group related changes together (all typing fixes, all security hardening, all API changes, etc.) +#### Contributors +- If the PR includes commits from multiple authors (not just the repo owner), add a **Contributors** section after the summary and before Highlights. +- Use `git log main..HEAD --format='%aN <%aE>' | sort -u` to find unique commit authors. +- Exclude bot accounts (e.g., `github-actions[bot]`). +- Format: `@username` if their GitHub handle is available (check the ARGUMENTS or commit metadata), otherwise use their name. Add a brief note about what they contributed if it's clear from the commits. +- Keep it short — one line per contributor, no need for a full changelog. + #### General - **No test plan section.** Do not include "Test plan" or "Testing". - **No mention of tests.** Do not reference test files, test results, or testing. diff --git a/agent/cmd/agent/main.go b/agent/cmd/agent/main.go index 2129e0e..bca3c36 100644 --- a/agent/cmd/agent/main.go +++ b/agent/cmd/agent/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "path/filepath" "syscall" "github.com/serverkit/agent/internal/agent" @@ -309,8 +310,19 @@ func runRegister(token, serverURL, name string) error { cfg.Auth.APIKey = result.APIKey cfg.Auth.APISecret = result.APISecret - // Save config - if err := cfg.Save(config.DefaultConfigPath()); err != nil { + // Determine config path (use --config flag if set, otherwise default) + configPath := cfgFile + if configPath == "" { + configPath = config.DefaultConfigPath() + } + + // Update key file path to be relative to config directory if using custom path + if cfgFile != "" { + cfg.Auth.KeyFile = filepath.Join(filepath.Dir(configPath), "agent.key") + } + + // Save config (key_file path must be set before saving) + if err := cfg.Save(configPath); err != nil { return fmt.Errorf("failed to save config: %w", err) } diff --git a/agent/internal/agent/registration.go b/agent/internal/agent/registration.go index 36358d4..e187476 100644 --- a/agent/internal/agent/registration.go +++ b/agent/internal/agent/registration.go @@ -103,7 +103,7 @@ func (r *Registration) Register(serverURL, token, name string) (*RegistrationRes } // Make registration request - registrationURL := serverURL + "/api/v1/agents/register" + registrationURL := serverURL + "/api/v1/servers/register" r.log.Info("Sending registration request", "url", registrationURL) req, err := http.NewRequestWithContext(ctx, "POST", registrationURL, bytes.NewReader(bodyBytes)) diff --git a/agent/internal/ws/client.go b/agent/internal/ws/client.go index 9bc7981..621da67 100644 --- a/agent/internal/ws/client.go +++ b/agent/internal/ws/client.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "strings" "sync" "time" @@ -19,7 +21,7 @@ import ( // MessageHandler is called when a message is received type MessageHandler func(msgType protocol.MessageType, data []byte) -// Client is a WebSocket client with auto-reconnect +// Client is a Socket.IO client with auto-reconnect type Client struct { cfg config.ServerConfig auth *auth.Authenticator @@ -36,16 +38,23 @@ type Client struct { doneCh chan struct{} reconnectCount int + + // Socket.IO namespace + namespace string + // Engine.IO ping interval from server + pingInterval time.Duration + pingTimeout time.Duration } // NewClient creates a new WebSocket client func NewClient(cfg config.ServerConfig, authenticator *auth.Authenticator, log *logger.Logger) *Client { return &Client{ - cfg: cfg, - auth: authenticator, - log: log.WithComponent("websocket"), - sendCh: make(chan []byte, 100), - doneCh: make(chan struct{}), + cfg: cfg, + auth: authenticator, + log: log.WithComponent("websocket"), + sendCh: make(chan []byte, 100), + doneCh: make(chan struct{}), + namespace: "/agent", } } @@ -54,7 +63,34 @@ func (c *Client) SetHandler(handler MessageHandler) { c.handler = handler } -// Connect establishes a WebSocket connection +// buildSocketIOURL converts the configured server URL to a Socket.IO WebSocket URL. +// Input examples: +// - "wss://server.example.com/agent" +// - "ws://localhost:5000/agent" +// +// Output: "wss://server.example.com/socket.io/?EIO=4&transport=websocket" +func (c *Client) buildSocketIOURL() (string, error) { + rawURL := c.cfg.URL + if rawURL == "" { + return "", fmt.Errorf("server URL is empty") + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("invalid server URL: %w", err) + } + + // Keep the scheme (ws/wss), strip the path (e.g. /agent) + parsed.Path = "/socket.io/" + q := url.Values{} + q.Set("EIO", "4") + q.Set("transport", "websocket") + parsed.RawQuery = q.Encode() + + return parsed.String(), nil +} + +// Connect establishes a Socket.IO connection over WebSocket func (c *Client) Connect(ctx context.Context) error { c.mu.Lock() if c.connected { @@ -63,6 +99,11 @@ func (c *Client) Connect(ctx context.Context) error { } c.mu.Unlock() + sioURL, err := c.buildSocketIOURL() + if err != nil { + return err + } + dialer := websocket.Dialer{ HandshakeTimeout: 10 * time.Second, } @@ -76,10 +117,11 @@ func (c *Client) Connect(ctx context.Context) error { headers := http.Header{} headers.Set("X-Agent-ID", c.auth.AgentID()) headers.Set("X-API-Key-Prefix", c.auth.GetAPIKeyPrefix()) + headers.Set("User-Agent", fmt.Sprintf("ServerKit-Agent/%s", "dev")) - c.log.Debug("Connecting to server", "url", c.cfg.URL) + c.log.Debug("Connecting to Socket.IO", "url", sioURL) - conn, resp, err := dialer.DialContext(ctx, c.cfg.URL, headers) + conn, resp, err := dialer.DialContext(ctx, sioURL, headers) if err != nil { if resp != nil { c.log.Error("Connection failed", @@ -92,14 +134,29 @@ func (c *Client) Connect(ctx context.Context) error { c.mu.Lock() c.conn = conn + c.mu.Unlock() + + // Step 1: Read Engine.IO OPEN packet + if err := c.handleEngineIOOpen(); err != nil { + conn.Close() + return fmt.Errorf("engine.io handshake failed: %w", err) + } + + // Step 2: Connect to Socket.IO namespace + if err := c.connectNamespace(); err != nil { + conn.Close() + return fmt.Errorf("namespace connect failed: %w", err) + } + + c.mu.Lock() c.connected = true c.reconnecting = false c.reconnectCount = 0 c.mu.Unlock() - c.log.Info("Connected to server") + c.log.Info("Connected to server via Socket.IO") - // Authenticate + // Step 3: Authenticate via Socket.IO event if err := c.authenticate(); err != nil { c.Close() return fmt.Errorf("authentication failed: %w", err) @@ -108,65 +165,213 @@ func (c *Client) Connect(ctx context.Context) error { return nil } -// authenticate sends authentication message and waits for response +// handleEngineIOOpen reads and processes the Engine.IO OPEN packet (type 0) +func (c *Client) handleEngineIOOpen() error { + c.conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + _, msg, err := c.conn.ReadMessage() + if err != nil { + return fmt.Errorf("failed to read OPEN packet: %w", err) + } + c.conn.SetReadDeadline(time.Time{}) + + msgStr := string(msg) + c.log.Debug("Received Engine.IO packet", "raw", msgStr) + + // Engine.IO OPEN packet starts with '0' + if len(msgStr) < 2 || msgStr[0] != '0' { + return fmt.Errorf("expected OPEN packet (0), got: %s", msgStr) + } + + // Parse the JSON payload + var openData struct { + SID string `json:"sid"` + Upgrades []string `json:"upgrades"` + PingInterval int `json:"pingInterval"` + PingTimeout int `json:"pingTimeout"` + } + if err := json.Unmarshal([]byte(msgStr[1:]), &openData); err != nil { + return fmt.Errorf("failed to parse OPEN data: %w", err) + } + + c.pingInterval = time.Duration(openData.PingInterval) * time.Millisecond + c.pingTimeout = time.Duration(openData.PingTimeout) * time.Millisecond + + c.log.Debug("Engine.IO handshake complete", + "sid", openData.SID, + "pingInterval", c.pingInterval, + "pingTimeout", c.pingTimeout, + ) + + return nil +} + +// connectNamespace sends a Socket.IO CONNECT packet to the /agent namespace +func (c *Client) connectNamespace() error { + // Socket.IO CONNECT: packet type 4 (MESSAGE) + message type 0 (CONNECT) + namespace + // Wire format: "40/agent," + connectMsg := fmt.Sprintf("40%s,", c.namespace) + c.log.Debug("Connecting to namespace", "namespace", c.namespace, "packet", connectMsg) + + if err := c.conn.WriteMessage(websocket.TextMessage, []byte(connectMsg)); err != nil { + return fmt.Errorf("failed to send CONNECT: %w", err) + } + + // Read namespace CONNECT ack + c.conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + _, msg, err := c.conn.ReadMessage() + if err != nil { + return fmt.Errorf("failed to read CONNECT ack: %w", err) + } + c.conn.SetReadDeadline(time.Time{}) + + msgStr := string(msg) + c.log.Debug("Received namespace response", "raw", msgStr) + + // Expected: "40/agent,{\"sid\":\"...\"}" + // Or error: "44/agent,{\"message\":\"...\"}" + prefix := fmt.Sprintf("40%s,", c.namespace) + errorPrefix := fmt.Sprintf("44%s,", c.namespace) + + if strings.HasPrefix(msgStr, errorPrefix) { + return fmt.Errorf("namespace connection rejected: %s", msgStr) + } + + if !strings.HasPrefix(msgStr, prefix) { + return fmt.Errorf("unexpected namespace response: %s", msgStr) + } + + c.log.Info("Connected to namespace", "namespace", c.namespace) + return nil +} + +// authenticate sends an "auth" Socket.IO event and waits for response func (c *Client) authenticate() error { timestamp := time.Now().UnixMilli() nonce := auth.GenerateNonce() - // Sign with nonce for replay protection signature := c.auth.SignMessageWithNonce(timestamp, nonce) - authMsg := protocol.AuthMessage{ - Message: protocol.NewMessage(protocol.TypeAuth, nonce), - AgentID: c.auth.AgentID(), - APIKeyPrefix: c.auth.GetAPIKeyPrefix(), - Nonce: nonce, + authData := map[string]interface{}{ + "type": "auth", + "agent_id": c.auth.AgentID(), + "api_key_prefix": c.auth.GetAPIKeyPrefix(), + "nonce": nonce, + "timestamp": timestamp, + "signature": signature, } - authMsg.Timestamp = timestamp - authMsg.Signature = signature - data, err := json.Marshal(authMsg) - if err != nil { - return fmt.Errorf("failed to marshal auth message: %w", err) + c.log.Debug("Sending authentication event") + + if err := c.emitEvent("auth", authData); err != nil { + return fmt.Errorf("failed to send auth event: %w", err) } - c.log.Debug("Sending authentication message") + // Wait for auth response event + c.conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + defer c.conn.SetReadDeadline(time.Time{}) + + for { + _, msg, err := c.conn.ReadMessage() + if err != nil { + return fmt.Errorf("failed to read auth response: %w", err) + } + + msgStr := string(msg) + + // Handle Engine.IO ping during auth + if msgStr == "2" { + c.conn.WriteMessage(websocket.TextMessage, []byte("3")) + continue + } + + eventName, eventData, err := c.parseEvent(msgStr) + if err != nil { + c.log.Debug("Ignoring non-event message during auth", "raw", msgStr) + continue + } - if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { - return fmt.Errorf("failed to send auth message: %w", err) + switch eventName { + case "auth_ok": + var response struct { + Type string `json:"type"` + SessionToken string `json:"session_token"` + Expires int64 `json:"expires"` + ServerID string `json:"server_id"` + } + if err := json.Unmarshal(eventData, &response); err != nil { + return fmt.Errorf("failed to parse auth_ok: %w", err) + } + + c.session = &auth.SessionToken{ + Token: response.SessionToken, + ExpiresAt: time.UnixMilli(response.Expires), + } + + c.log.Info("Authentication successful", + "expires_in", time.Until(c.session.ExpiresAt).Round(time.Second), + ) + return nil + + case "auth_fail": + var response struct { + Type string `json:"type"` + Error string `json:"error"` + } + json.Unmarshal(eventData, &response) + return fmt.Errorf("authentication rejected: %s", response.Error) + + default: + c.log.Debug("Ignoring event during auth", "event", eventName) + } } +} - // Wait for auth response - c.conn.SetReadDeadline(time.Now().Add(10 * time.Second)) - _, msg, err := c.conn.ReadMessage() +// emitEvent sends a Socket.IO EVENT packet +// Wire format: 42/agent,["event_name",{data}] +func (c *Client) emitEvent(event string, data interface{}) error { + dataBytes, err := json.Marshal(data) if err != nil { - return fmt.Errorf("failed to read auth response: %w", err) + return fmt.Errorf("failed to marshal event data: %w", err) } - c.conn.SetReadDeadline(time.Time{}) - var response protocol.AuthResponse - if err := json.Unmarshal(msg, &response); err != nil { - return fmt.Errorf("failed to parse auth response: %w", err) + // Build Socket.IO EVENT: 42/namespace,["event",data] + eventJSON := fmt.Sprintf(`42%s,["%s",%s]`, c.namespace, event, string(dataBytes)) + + return c.conn.WriteMessage(websocket.TextMessage, []byte(eventJSON)) +} + +// parseEvent parses a Socket.IO EVENT packet and returns event name + data +// Expected format: 42/agent,["event_name",{data}] +func (c *Client) parseEvent(msg string) (string, json.RawMessage, error) { + prefix := fmt.Sprintf("42%s,", c.namespace) + if !strings.HasPrefix(msg, prefix) { + return "", nil, fmt.Errorf("not a Socket.IO EVENT for namespace %s", c.namespace) } - if response.Type == protocol.TypeAuthFail { - return fmt.Errorf("authentication rejected: %s", response.Error) + payload := msg[len(prefix):] + + // Parse as JSON array: ["event_name", data] + var arr []json.RawMessage + if err := json.Unmarshal([]byte(payload), &arr); err != nil { + return "", nil, fmt.Errorf("failed to parse event array: %w", err) } - if response.Type != protocol.TypeAuthOK { - return fmt.Errorf("unexpected response type: %s", response.Type) + if len(arr) < 1 { + return "", nil, fmt.Errorf("empty event array") } - // Store session token - c.session = &auth.SessionToken{ - Token: response.SessionToken, - ExpiresAt: time.UnixMilli(response.Expires), + // Extract event name (first element is a string) + var eventName string + if err := json.Unmarshal(arr[0], &eventName); err != nil { + return "", nil, fmt.Errorf("failed to parse event name: %w", err) } - c.log.Info("Authentication successful", - "expires_in", time.Until(c.session.ExpiresAt).Round(time.Second), - ) + // Data is the second element (may be absent) + var eventData json.RawMessage + if len(arr) > 1 { + eventData = arr[1] + } - return nil + return eventName, eventData, nil } // Run starts the read/write loops and handles reconnection @@ -186,13 +391,14 @@ func (c *Client) Run(ctx context.Context) error { if !connected { if err := c.Connect(ctx); err != nil { + c.log.Warn("Connection failed", "error", err) c.handleReconnect(ctx) continue } } - // Start read/write loops - errCh := make(chan error, 2) + // Start read/write/ping loops + errCh := make(chan error, 3) go func() { errCh <- c.readLoop(ctx) @@ -202,6 +408,10 @@ func (c *Client) Run(ctx context.Context) error { errCh <- c.writeLoop(ctx) }() + go func() { + errCh <- c.pingLoop(ctx) + }() + // Wait for error err := <-errCh c.log.Warn("Connection loop ended", "error", err) @@ -226,7 +436,7 @@ func (c *Client) Run(ctx context.Context) error { } } -// readLoop reads messages from the WebSocket +// readLoop reads Socket.IO messages from the WebSocket func (c *Client) readLoop(ctx context.Context) error { for { select { @@ -240,40 +450,121 @@ func (c *Client) readLoop(ctx context.Context) error { return fmt.Errorf("read error: %w", err) } - // Parse message type - var base protocol.Message - if err := json.Unmarshal(msg, &base); err != nil { - c.log.Warn("Failed to parse message", "error", err) + msgStr := string(msg) + + // Handle Engine.IO PING (server sends "2", we respond "3") + if msgStr == "2" { + if err := c.conn.WriteMessage(websocket.TextMessage, []byte("3")); err != nil { + return fmt.Errorf("failed to send pong: %w", err) + } + c.log.Debug("Responded to Engine.IO ping") continue } - // Handle heartbeat ack internally - if base.Type == protocol.TypeHeartbeatAck { - c.log.Debug("Received heartbeat ack") - continue + // Handle Engine.IO CLOSE + if msgStr == "1" { + return fmt.Errorf("server sent Engine.IO CLOSE") + } + + // Handle Socket.IO DISCONNECT for our namespace + disconnectPrefix := fmt.Sprintf("41%s,", c.namespace) + if strings.HasPrefix(msgStr, disconnectPrefix) || msgStr == "41" { + return fmt.Errorf("server disconnected namespace %s", c.namespace) } - // Pass to handler - if c.handler != nil { - c.handler(base.Type, msg) + // Parse Socket.IO EVENT + eventName, eventData, err := c.parseEvent(msgStr) + if err != nil { + c.log.Debug("Ignoring unrecognized message", "raw", msgStr) + continue } + + // Map event name to protocol message type and dispatch + c.dispatchEvent(eventName, eventData) } } -// writeLoop writes messages from the send channel +// dispatchEvent maps a Socket.IO event to the agent's message handler +func (c *Client) dispatchEvent(eventName string, data json.RawMessage) { + c.log.Debug("Received event", "event", eventName) + + // Map Socket.IO event names to protocol message types + msgType := protocol.MessageType(eventName) + + // Handle heartbeat ack internally + if msgType == protocol.TypeHeartbeatAck { + c.log.Debug("Received heartbeat ack") + return + } + + // For events that carry data, we need to reconstruct the full message + // so the agent handler can unmarshal it as expected + if c.handler != nil && len(data) > 0 { + c.handler(msgType, data) + } +} + +// writeLoop writes queued messages via Socket.IO events func (c *Client) writeLoop(ctx context.Context) error { for { select { case <-ctx.Done(): return ctx.Err() case msg := <-c.sendCh: - if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + // Parse the message to extract the type for the event name + var base protocol.Message + if err := json.Unmarshal(msg, &base); err != nil { + c.log.Warn("Failed to parse outgoing message", "error", err) + continue + } + + eventName := string(base.Type) + eventJSON := fmt.Sprintf(`42%s,["%s",%s]`, c.namespace, eventName, string(msg)) + + if err := c.conn.WriteMessage(websocket.TextMessage, []byte(eventJSON)); err != nil { return fmt.Errorf("write error: %w", err) } } } } +// pingLoop sends Engine.IO PING packets to keep the connection alive +func (c *Client) pingLoop(ctx context.Context) error { + if c.pingInterval <= 0 { + c.pingInterval = 25 * time.Second + } + + ticker := time.NewTicker(c.pingInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.mu.RLock() + connected := c.connected + c.mu.RUnlock() + + if !connected { + return fmt.Errorf("not connected") + } + + // Engine.IO PING from client side (type 3 = PONG in EIO4 client-initiated) + // Actually in EIO4, server sends PINGs (2) and client responds with PONGs (3) + // Client doesn't need to send its own pings, just respond to server's + // But we'll use this loop to detect stale connections via WriteControl + if err := c.conn.WriteControl( + websocket.PingMessage, + nil, + time.Now().Add(5*time.Second), + ); err != nil { + return fmt.Errorf("websocket ping failed: %w", err) + } + } + } +} + // handleReconnect implements exponential backoff reconnection func (c *Client) handleReconnect(ctx context.Context) { c.mu.Lock() diff --git a/agent/scripts/install.ps1 b/agent/scripts/install.ps1 index 36c6f55..9bdba71 100644 --- a/agent/scripts/install.ps1 +++ b/agent/scripts/install.ps1 @@ -59,7 +59,7 @@ function Install-ServerKitAgent { # Construct download URL if ([string]::IsNullOrEmpty($DownloadUrl)) { - $DownloadUrl = "$Server/downloads/agent/serverkit-agent-windows-$Arch.exe" + $DownloadUrl = "$Server/api/v1/servers/agent/download/windows/$Arch" } # Download agent diff --git a/agent/scripts/install.sh b/agent/scripts/install.sh index ea1eec8..872b1b1 100644 --- a/agent/scripts/install.sh +++ b/agent/scripts/install.sh @@ -156,7 +156,7 @@ create_user() { download_agent() { if [ -z "$DOWNLOAD_URL" ]; then # Construct download URL from server - DOWNLOAD_URL="${SERVER_URL}/downloads/agent/serverkit-agent-${OS}-${ARCH}" + DOWNLOAD_URL="${SERVER_URL}/api/v1/servers/agent/download/${OS}/${ARCH}" fi log_info "Downloading agent from $DOWNLOAD_URL..." diff --git a/frontend/src/pages/Servers.jsx b/frontend/src/pages/Servers.jsx index 2c64eef..3103071 100644 --- a/frontend/src/pages/Servers.jsx +++ b/frontend/src/pages/Servers.jsx @@ -503,7 +503,7 @@ Install-ServerKitAgent -Server "${window.location.origin}" -Token "${registratio
  • Once connected, the server will appear as "Online"
  • - The registration token expires in 1 hour. After that, you'll need to generate a new one. + The registration token expires in 24 hours. After that, you'll need to generate a new one.

    From ed74df08ee25d63636eb7b9267d79cfd140bb33b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Mar 2026 01:06:21 +0000 Subject: [PATCH 02/18] chore: bump version to 1.3.2 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 3a3cd8c..1892b92 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.1 +1.3.2 From 2f618b983f32ad899cc81fc9139822b35306272e Mon Sep 17 00:00:00 2001 From: Juan Denis <13461850+jhd3197@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:32:26 -0500 Subject: [PATCH 03/18] Enhance Server detail UI, token flow & metrics Add backup dirs in Dockerfiles and adjust ownership. Change server routes to use a dynamic tab param and update components to support tabbed ServerDetail pages. Improve metrics handling by adding serverId support, more resilient field parsing, and server-specific metrics API calls. Revamp ServerDetail: introduce token modal and AgentRegistrationSection, reorganize Metrics, Settings and Security areas (IP allowlist, API key rotation, security alerts), move delete/generate token flows and wire up navigation. Update Servers and Docker pages to handle different API response shapes (Array checks) and tweak install modal copy/UX. Rename backend API call for registration token to /regenerate-token. Add many UI/Icon/CSS adjustments to support the new layouts and interactions, and fix various small bugs and data-shape assumptions. --- Dockerfile | 4 +- backend/Dockerfile | 5 +- frontend/src/App.jsx | 2 +- frontend/src/components/MetricsGraph.jsx | 14 +- frontend/src/pages/Docker.jsx | 2 +- frontend/src/pages/ServerDetail.jsx | 705 +++++++++++++---------- frontend/src/pages/Servers.jsx | 62 +- frontend/src/services/api.js | 2 +- frontend/src/styles/pages/_servers.less | 450 ++++++++++++++- 9 files changed, 868 insertions(+), 378 deletions(-) diff --git a/Dockerfile b/Dockerfile index 42e6013..eb2faa2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,8 +62,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN groupadd -r serverkit && useradd -r -g serverkit serverkit # Create necessary directories -RUN mkdir -p /etc/serverkit /var/log/serverkit /var/quarantine \ - && chown -R serverkit:serverkit /etc/serverkit /var/log/serverkit /var/quarantine +RUN mkdir -p /etc/serverkit /var/log/serverkit /var/quarantine /var/backups/serverkit \ + && chown -R serverkit:serverkit /etc/serverkit /var/log/serverkit /var/quarantine /var/backups/serverkit # Set working directory WORKDIR /app diff --git a/backend/Dockerfile b/backend/Dockerfile index 3866867..fc3339a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -30,7 +30,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get clean # Create directories for ServerKit -RUN mkdir -p /etc/serverkit /app/logs /var/quarantine /app/instance +RUN mkdir -p /etc/serverkit /app/logs /var/quarantine /app/instance /var/backups/serverkit # Copy requirements first for caching COPY requirements.txt . @@ -47,7 +47,8 @@ RUN useradd --create-home appuser \ && chown -R appuser:appuser /app \ && chown -R appuser:appuser /app/instance \ && chown -R appuser:appuser /etc/serverkit \ - && chown -R appuser:appuser /var/quarantine + && chown -R appuser:appuser /var/quarantine \ + && chown -R appuser:appuser /var/backups/serverkit USER appuser diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 85ed8ef..511f0e2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -173,7 +173,7 @@ function AppRoutes() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/MetricsGraph.jsx b/frontend/src/components/MetricsGraph.jsx index f2beceb..a23ba3c 100644 --- a/frontend/src/components/MetricsGraph.jsx +++ b/frontend/src/components/MetricsGraph.jsx @@ -13,7 +13,7 @@ const CHART_COLORS = { disk: '#f59e0b' // Amber/Orange (Disk) }; -const MetricsGraph = ({ compact = false, timezone }) => { +const MetricsGraph = ({ compact = false, timezone, serverId }) => { const [data, setData] = useState(null); const [period, setPeriod] = useState('1h'); const [loading, setLoading] = useState(true); @@ -35,12 +35,14 @@ const MetricsGraph = ({ compact = false, timezone }) => { useEffect(() => { loadHistory(); - }, [period]); + }, [period, serverId]); async function loadHistory() { try { setLoading(true); - const response = await api.getMetricsHistory(period); + const response = serverId + ? await api.getServerMetricsHistory(serverId, period) + : await api.getMetricsHistory(period); setData(response); setError(null); } catch (err) { @@ -64,9 +66,9 @@ const MetricsGraph = ({ compact = false, timezone }) => { const chartData = data?.data?.map(point => ({ time: formatTimestamp(point.timestamp), - cpu: point.cpu.percent, - memory: point.memory.percent, - disk: point.disk.percent + cpu: point.cpu?.percent ?? point.cpu_percent ?? 0, + memory: point.memory?.percent ?? point.memory_percent ?? 0, + disk: point.disk?.percent ?? point.disk_percent ?? 0 })) || []; // Auto-zoom: compute Y-axis ceiling from visible metrics diff --git a/frontend/src/pages/Docker.jsx b/frontend/src/pages/Docker.jsx index 8814278..7d4ae1a 100644 --- a/frontend/src/pages/Docker.jsx +++ b/frontend/src/pages/Docker.jsx @@ -30,7 +30,7 @@ const Docker = () => { async function loadServers() { try { const data = await api.getAvailableServers(); - setServers(data.servers || []); + setServers(Array.isArray(data) ? data : []); } catch (err) { console.error('Failed to load servers:', err); // Default to just local diff --git a/frontend/src/pages/ServerDetail.jsx b/frontend/src/pages/ServerDetail.jsx index de76541..60ecffb 100644 --- a/frontend/src/pages/ServerDetail.jsx +++ b/frontend/src/pages/ServerDetail.jsx @@ -2,22 +2,26 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import api from '../services/api'; import { useToast } from '../contexts/ToastContext'; +import MetricsGraph from '../components/MetricsGraph'; const ServerDetail = () => { - const { id } = useParams(); + const { id, tab } = useParams(); const navigate = useNavigate(); const [server, setServer] = useState(null); const [metrics, setMetrics] = useState(null); const [systemInfo, setSystemInfo] = useState(null); - const [activeTab, setActiveTab] = useState('overview'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [showTokenModal, setShowTokenModal] = useState(false); const toast = useToast(); + const validTabs = ['overview', 'docker', 'metrics', 'settings']; + const activeTab = validTabs.includes(tab) ? tab : 'overview'; + const loadServer = useCallback(async () => { try { const data = await api.getServer(id); - setServer(data.server); + setServer(data); setError(null); } catch (err) { setError(err.message); @@ -95,7 +99,25 @@ const ServerDetail = () => { try { const result = await api.generateRegistrationToken(id); toast.success('New registration token generated'); - setServer(prev => ({ ...prev, registration_token: result.token })); + setServer(prev => ({ + ...prev, + registration_token: result.registration_token, + registration_expires: result.registration_expires + })); + } catch (err) { + toast.error(err.message || 'Failed to generate token'); + } + } + + async function handleGenerateToken() { + try { + const result = await api.generateRegistrationToken(id); + setServer(prev => ({ + ...prev, + registration_token: result.registration_token, + registration_expires: result.registration_expires + })); + setShowTokenModal(true); } catch (err) { toast.error(err.message || 'Failed to generate token'); } @@ -165,20 +187,20 @@ const ServerDetail = () => { -
    - {tabs.map(tab => ( + {tabs.map(t => ( ))}
    @@ -202,9 +224,17 @@ const ServerDetail = () => { server={server} onUpdate={loadServer} onRegenerateToken={handleRegenerateToken} + onDelete={handleDeleteServer} /> )} + + {showTokenModal && server && ( + setShowTokenModal(false)} + /> + )} ); }; @@ -559,127 +589,131 @@ const DockerTab = ({ serverId, serverStatus }) => { }; const MetricsTab = ({ serverId, metrics }) => { - if (!metrics) { - return ( -
    - -

    No Metrics Available

    -

    Metrics are only available when the server is online.

    -
    - ); - } + const formatBytes = (bytes) => { + if (!bytes) return 'N/A'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; + }; return (
    -
    -
    -

    CPU Usage

    -
    - -
    -
    -
    - Load Average - {metrics.load_avg?.join(', ') || 'N/A'} + + + {metrics && ( +
    +
    +

    Current Snapshot

    +
    +
    + CPU + {(metrics.cpu_percent || 0).toFixed(1)}% +
    +
    + Memory + {(metrics.memory_percent || 0).toFixed(1)}% +
    +
    + Disk + {(metrics.disk_percent || 0).toFixed(1)}% +
    +
    + Net TX + {formatBytes(metrics.network_sent)}/s +
    +
    + Net RX + {formatBytes(metrics.network_recv)}/s +
    +
    + Containers + {metrics.container_running || 0} / {metrics.container_count || 0} +
    + )} +
    + ); +}; -
    -

    Memory Usage

    -
    - -
    -
    -
    - Used - {formatBytes(metrics.memory_used)} -
    -
    - Total - {formatBytes(metrics.memory_total)} -
    -
    -
    -
    -

    Disk Usage

    -
    - -
    -
    -
    - Used - {formatBytes(metrics.disk_used)} -
    -
    - Total - {formatBytes(metrics.disk_total)} -
    +const AgentRegistrationSection = ({ server, onRegenerateToken }) => { + const [copied, setCopied] = useState(false); + const toast = useToast(); + + const token = server.registration_token; + const expires = server.registration_expires; + const isExpired = expires && new Date(expires) < new Date(); + + const linuxScript = token ? `curl -fsSL ${window.location.origin}/api/v1/servers/install.sh | sudo bash -s -- \\ + --server "${window.location.origin}" \\ + --token "${token}"` : ''; + + const windowsScript = token ? `irm ${window.location.origin}/api/v1/servers/install.ps1 | iex +Install-ServerKitAgent -Server "${window.location.origin}" -Token "${token}"` : ''; + + function copyToClipboard(text) { + navigator.clipboard.writeText(text); + setCopied(true); + toast.success('Copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + } + + return ( +
    +

    Agent Installation

    + + {token && !isExpired ? ( +
    +
    + + Token active — expires {new Date(expires).toLocaleString()}
    -
    -
    -

    Network I/O

    -
    -
    - - Upload - {formatBytes(metrics.network_sent)}/s -
    -
    - - Download - {formatBytes(metrics.network_recv)}/s +
    +
    + + Linux / macOS +
    +
    {linuxScript}
    -
    -
    -

    Docker

    -
    -
    - Containers - {metrics.container_count || 0} -
    -
    - Running - {metrics.container_running || 0} +
    +
    + + Windows (PowerShell) +
    +
    {windowsScript}
    + +
    -
    + ) : ( +
    +

    + {isExpired + ? 'The registration token has expired. Generate a new one to install or reinstall the agent.' + : 'Generate a registration token to install the agent on your server.'} +

    + +
    + )}
    ); }; -const CircularGauge = ({ value, color }) => { - const safeValue = Math.min(Math.max(value || 0, 0), 100); - const circumference = 2 * Math.PI * 45; - const offset = circumference - (safeValue / 100) * circumference; - - return ( - - - 80 ? '#EF4444' : color - }} - /> - - {safeValue.toFixed(0)}% - - - ); -}; - -const SettingsTab = ({ server, onUpdate, onRegenerateToken }) => { +const SettingsTab = ({ server, onUpdate, onRegenerateToken, onDelete }) => { const [formData, setFormData] = useState({ name: server.name || '', description: server.description || '', @@ -704,7 +738,7 @@ const SettingsTab = ({ server, onUpdate, onRegenerateToken }) => { async function loadGroups() { try { const data = await api.getServerGroups(); - setGroups(data.groups || []); + setGroups(Array.isArray(data) ? data : []); } catch (err) { console.error('Failed to load groups:', err); } @@ -808,215 +842,216 @@ const SettingsTab = ({ server, onUpdate, onRegenerateToken }) => { return (
    -
    -
    -

    Basic Information

    - -
    - - -
    - -
    - -