Browse Source

Add custom upload endpoints and config polling settings

Features:
- Add cfg_polling_timeout setting to control server config polling interval (default: 30s)
- Add custom upload_endpoint for BLE and WiFi scanners (both Cloud and LAN modes)
- Add config fetch logging: [config] Fetched from server (polling interval: Xs)
- Add green highlighting for success messages in dashboard logs (uploaded, config, flushed, registered, connected)

Fixes:
- Fix SSH key path from /etc/beacon/ to /opt/mybeacon/etc/

Documentation:
- Update API_DOCUMENTATION.md with all new fields and detailed descriptions
- Fix ServerConfig and Config structures documentation

Testing:
- Tested on device 192.168.5.244
- Config polling working every 30s
- Custom endpoints configurable via server API
- Dashboard shows green logs for successful operations

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
root 4 weeks ago
parent
commit
372e1a573c

+ 132 - 34
API_DOCUMENTATION.md

@@ -158,19 +158,42 @@ Host: 192.168.5.244
 ```json
 {
   "mode": "cloud",
-  "api_base": "http://server:8000/api/v1",
+  "api_base": "http://192.168.5.4:8000/api/v1",
+  "cfg_polling_timeout": 30,
   "ble": {
     "enabled": true,
-    "batch_interval_ms": 2500
+    "batch_interval_ms": 2500,
+    "upload_endpoint": ""
   },
   "wifi": {
     "monitor_enabled": true,
     "client_enabled": false,
-    "ssid": "",
-    "batch_interval_ms": 10000
+    "ssid": "MyNetwork",
+    "psk": "",
+    "batch_interval_ms": 10000,
+    "upload_endpoint": ""
+  },
+  "ssh_tunnel": {
+    "enabled": false,
+    "server": "",
+    "port": 22,
+    "user": "",
+    "key_path": "",
+    "remote_port": 0,
+    "keepalive_interval": 30,
+    "reconnect_delay": 5
+  },
+  "dashboard_tunnel": {
+    "enabled": false,
+    "server": "",
+    "port": 22,
+    "user": "",
+    "remote_port": 0,
+    "keepalive_interval": 30,
+    "reconnect_delay": 5
   },
   "network": {
-    "ntp_servers": ["pool.ntp.org"],
+    "ntp_servers": ["pool.ntp.org", "time.google.com"],
     "eth0": {
       "static": false,
       "address": "",
@@ -178,20 +201,21 @@ Host: 192.168.5.244
       "dns": ""
     }
   },
+  "ap_fallback": {
+    "password": "mybeacon"
+  },
   "dashboard": {
     "enabled": true
   },
-  "ssh_tunnel": {
-    "enabled": false,
-    "server": "",
-    "port": 22,
-    "user": "tunnel",
-    "remote_port": 0
-  }
+  "zmq_addr_ble": "tcp://127.0.0.1:5555",
+  "zmq_addr_wifi": "tcp://127.0.0.1:5556",
+  "spool_dir": "/var/spool/mybeacon",
+  "wifi_iface": "wlan0",
+  "debug": false
 }
 ```
 
-**Note:** Sensitive fields (passwords, keys) are omitted from response.
+**Note:** Sensitive fields (`wifi.psk`, `ssh_tunnel.key_path`) are returned as empty strings. Device ID and token are omitted.
 
 ---
 
@@ -350,11 +374,16 @@ Content-Type: application/json
 {
   "password": "82680499",
   "settings": {
-    "mode": "cloud",
+    "mode": "lan",
     "wifi_client_enabled": true,
     "wifi_ssid": "MyNetwork",
     "wifi_psk": "password123",
-    "wifi_monitor_enabled": false,
+    "wifi_monitor_enabled": true,
+    "wifi_monitor_endpoint": "http://192.168.1.100:8080/wifi",
+    "wifi_monitor_batch_interval_ms": 10000,
+    "ble_enabled": true,
+    "ble_batch_interval_ms": 2500,
+    "ble_endpoint": "http://192.168.1.100:8080/ble",
     "eth0_mode": "dhcp",
     "ntp_servers": "pool.ntp.org,time.google.com"
   }
@@ -362,19 +391,33 @@ Content-Type: application/json
 ```
 
 **Settings Fields:**
-- `mode`: `"cloud"` or `"lan"`
-- `wifi_client_enabled`: Enable WiFi client
-- `wifi_ssid`: WiFi network name
-- `wifi_psk`: WiFi password
-- `wifi_monitor_enabled`: Enable WiFi scanner (LAN mode only)
-- `eth0_mode`: `"dhcp"` or `"static"`
-- `eth0_ip`: Static IP (when `eth0_mode="static"`)
-- `eth0_gateway`: Gateway (static mode)
-- `eth0_dns`: DNS server (static mode)
-- `ntp_servers`: Comma-separated NTP servers
-- `ble_enabled`: Enable BLE scanner (LAN mode only)
-- `ble_batch_interval_ms`: BLE batch interval
-- `wifi_monitor_batch_interval_ms`: WiFi batch interval
+
+**Mode:**
+- `mode` (string): `"cloud"` or `"lan"`. In Cloud mode, server config has priority. In LAN mode, local settings have priority.
+
+**BLE Scanner (LAN mode only):**
+- `ble_enabled` (bool): Enable BLE scanner
+- `ble_batch_interval_ms` (int): BLE batch upload interval in milliseconds
+- `ble_endpoint` (string, optional): Custom BLE upload endpoint URL (full URL, e.g., "http://192.168.1.100:8080/ble")
+
+**WiFi Scanner (LAN mode only):**
+- `wifi_monitor_enabled` (bool): Enable WiFi monitor/scanner mode
+- `wifi_monitor_batch_interval_ms` (int): WiFi batch upload interval in milliseconds
+- `wifi_monitor_endpoint` (string, optional): Custom WiFi upload endpoint URL (full URL, e.g., "http://192.168.1.100:8080/wifi")
+
+**WiFi Client (both modes):**
+- `wifi_client_enabled` (bool): Enable WiFi client mode (connect to AP)
+- `wifi_ssid` (string): WiFi network SSID
+- `wifi_psk` (string): WiFi password (WPA/WPA2 pre-shared key)
+
+**Ethernet (always local):**
+- `eth0_mode` (string): `"dhcp"` or `"static"`
+- `eth0_ip` (string): Static IP address with CIDR (e.g., "192.168.1.100/24") when `eth0_mode="static"`
+- `eth0_gateway` (string): Gateway IP when `eth0_mode="static"`
+- `eth0_dns` (string): DNS server IP when `eth0_mode="static"`
+
+**NTP:**
+- `ntp_servers` (string): Comma-separated list of NTP server addresses (e.g., "pool.ntp.org,time.google.com")
 
 **Response:** `200 OK`
 ```json
@@ -483,7 +526,7 @@ Content-Type: application/json
 
 ### GET /api/v1/config
 
-Fetches device configuration from server (Cloud mode only, polled every 30 seconds).
+Fetches device configuration from server. Device polls this endpoint at interval specified by `cfg_polling_timeout` (default: 30 seconds).
 
 **Request:**
 ```http
@@ -496,17 +539,20 @@ Authorization: Bearer gRMRUKnqO9KXikBzoKhs0aE3uDYUvTxHYuU4/uatyrc=
 ```json
 {
   "force_cloud": false,
+  "cfg_polling_timeout": 30,
   "ble": {
     "enabled": true,
     "batch_interval_ms": 2500,
-    "uuid_filter_hex": ""
+    "uuid_filter_hex": "f7826da64fa24e988024bc5b71e0893e",
+    "upload_endpoint": "http://custom.example.com:8080/ble"
   },
   "wifi": {
-    "client_enabled": false,
-    "ssid": "",
-    "psk": "",
     "monitor_enabled": true,
-    "batch_interval_ms": 10000
+    "client_enabled": true,
+    "ssid": "MyNetwork",
+    "psk": "mypassword123",
+    "batch_interval_ms": 10000,
+    "upload_endpoint": "http://custom.example.com:8080/wifi"
   },
   "ssh_tunnel": {
     "enabled": true,
@@ -542,6 +588,58 @@ Authorization: Bearer gRMRUKnqO9KXikBzoKhs0aE3uDYUvTxHYuU4/uatyrc=
 - **Always from server:** SSH tunnel, Dashboard tunnel, Dashboard enable/disable
 - **Always local:** eth0 network settings
 
+**Configuration Fields:**
+
+**Top-Level:**
+- `force_cloud` (bool): Override local mode setting to force Cloud mode. Used for remote support/management. When true, device switches from LAN to Cloud mode even if locally configured otherwise.
+- `cfg_polling_timeout` (int): Configuration polling interval in seconds (default: 30). Device fetches configuration from server at this interval. Minimum: 5 seconds, recommended: 30-300 seconds.
+- `debug` (bool): Enable debug logging
+
+**BLE Settings (`ble`):**
+- `enabled` (bool): Enable/disable BLE scanner
+- `batch_interval_ms` (int): Batch upload interval in milliseconds (default: 2500)
+- `uuid_filter_hex` (string, optional): Filter beacons by specific UUID. Only beacons matching this UUID will be reported. Format: 32 hex characters without dashes (e.g., "f7826da64fa24e988024bc5b71e0893e")
+- `upload_endpoint` (string, optional): Custom upload endpoint URL. If set, BLE events will be sent to this URL instead of `{api_base}/ble`. Must be full URL (e.g., "http://custom.example.com:8080/ble")
+
+**WiFi Settings (`wifi`):**
+- `monitor_enabled` (bool): Enable WiFi scanner (AP detection/monitor mode)
+- `client_enabled` (bool): Enable WiFi client mode (connect to AP)
+- `ssid` (string): WiFi network SSID to connect to (when client_enabled=true)
+- `psk` (string): WiFi password (WPA/WPA2 pre-shared key)
+- `batch_interval_ms` (int): Batch upload interval in milliseconds (default: 10000)
+- `upload_endpoint` (string, optional): Custom upload endpoint URL. If set, WiFi events will be sent to this URL instead of `{api_base}/wifi`. Must be full URL (e.g., "http://custom.example.com:8080/wifi")
+
+**SSH Tunnel (`ssh_tunnel`):**
+- `enabled` (bool): Enable SSH reverse tunnel for remote terminal access
+- `server` (string): SSH server hostname/IP
+- `port` (int): SSH server port (default: 22)
+- `user` (string): SSH username
+- `remote_port` (int): Remote port assigned for this device's tunnel
+- `keepalive_interval` (int): SSH keepalive interval in seconds (default: 30)
+
+**Dashboard Tunnel (`dashboard_tunnel`):**
+- `enabled` (bool): Enable SSH reverse tunnel for remote dashboard access
+- `server` (string): SSH server hostname/IP
+- `port` (int): SSH server port (default: 22)
+- `user` (string): SSH username
+- `remote_port` (int): Remote port assigned for dashboard tunnel
+- `keepalive_interval` (int): SSH keepalive interval in seconds (default: 30)
+
+**Dashboard (`dashboard`):**
+- `enabled` (bool): Enable/disable HTTP dashboard on port 80
+
+**Network (`net`):**
+- `ntp.servers` ([]string): NTP server addresses (e.g., ["pool.ntp.org", "time.google.com"])
+
+**Custom Upload Endpoints:**
+When `upload_endpoint` is set for BLE or WiFi, the device will send event batches to the specified URL instead of the default API base URL. This allows:
+- Directing data to different collection servers per device
+- Using separate data pipelines for BLE and WiFi
+- Custom data processing without modifying the main API
+- Multi-tenant setups where each client has their own endpoint
+
+The custom endpoint receives the same gzip-compressed JSON payload format as described in `/api/v1/ble` and `/api/v1/wifi` endpoints below.
+
 **Error Response:** `401 Unauthorized`
 ```
 Invalid or missing token

+ 6 - 4
cmd/beacon-daemon/api.go

@@ -429,16 +429,18 @@ func (s *APIServer) handleSettings(w http.ResponseWriter, r *http.Request) {
 		if req.Settings.BLEBatchInterval > 0 {
 			s.daemon.cfg.BLE.BatchIntervalMs = req.Settings.BLEBatchInterval
 		}
-		// TODO: Store BLE endpoint for LAN mode
-		log.Printf("[api] BLE scanner (LAN): enabled=%v, interval=%d", req.Settings.BLEEnabled, req.Settings.BLEBatchInterval)
+		// Store custom BLE upload endpoint for LAN mode
+		s.daemon.cfg.BLE.UploadEndpoint = req.Settings.BLEEndpoint
+		log.Printf("[api] BLE scanner (LAN): enabled=%v, interval=%d, endpoint=%s", req.Settings.BLEEnabled, req.Settings.BLEBatchInterval, req.Settings.BLEEndpoint)
 
 		// WiFi Scanner
 		s.daemon.cfg.WiFi.MonitorEnabled = req.Settings.WiFiMonitorEnabled
 		if req.Settings.WiFiMonitorBatchInterval > 0 {
 			s.daemon.cfg.WiFi.BatchIntervalMs = req.Settings.WiFiMonitorBatchInterval
 		}
-		// TODO: Store WiFi endpoint for LAN mode
-		log.Printf("[api] WiFi scanner (LAN): enabled=%v, interval=%d", req.Settings.WiFiMonitorEnabled, req.Settings.WiFiMonitorBatchInterval)
+		// Store custom WiFi upload endpoint for LAN mode
+		s.daemon.cfg.WiFi.UploadEndpoint = req.Settings.WiFiMonitorEndpoint
+		log.Printf("[api] WiFi scanner (LAN): enabled=%v, interval=%d, endpoint=%s", req.Settings.WiFiMonitorEnabled, req.Settings.WiFiMonitorBatchInterval, req.Settings.WiFiMonitorEndpoint)
 
 		// WiFi Client enabled flag
 		s.daemon.cfg.WiFi.ClientEnabled = req.Settings.WiFiClientEnabled

+ 15 - 1
cmd/beacon-daemon/client.go

@@ -94,10 +94,14 @@ type ServerConfig struct {
 	// ForceCloud overrides local mode setting - for remote support
 	ForceCloud bool `json:"force_cloud"`
 
+	// ConfigPollingInterval in seconds (default: 30)
+	ConfigPollingInterval int `json:"cfg_polling_timeout"`
+
 	BLE struct {
 		Enabled         bool   `json:"enabled"`
 		BatchIntervalMs int    `json:"batch_interval_ms"`
 		UUIDFilterHex   string `json:"uuid_filter_hex,omitempty"`
+		UploadEndpoint  string `json:"upload_endpoint,omitempty"`
 	} `json:"ble"`
 	WiFi struct {
 		MonitorEnabled  bool   `json:"monitor_enabled"`
@@ -105,6 +109,7 @@ type ServerConfig struct {
 		SSID            string `json:"ssid"`
 		PSK             string `json:"psk"`
 		BatchIntervalMs int    `json:"batch_interval_ms"`
+		UploadEndpoint  string `json:"upload_endpoint,omitempty"`
 	} `json:"wifi"`
 	SSHTunnel struct {
 		Enabled           bool   `json:"enabled"`
@@ -192,7 +197,16 @@ func (c *APIClient) UploadEvents(endpoint string, batch *EventBatch) error {
 
 	// Store compressed data for retries
 	compressedData := buf.Bytes()
-	url := c.baseURL + endpoint
+
+	// Determine URL - use custom endpoint if it's a full URL, otherwise prepend baseURL
+	var url string
+	if len(endpoint) >= 7 && endpoint[:7] == "http://" {
+		url = endpoint
+	} else if len(endpoint) >= 8 && endpoint[:8] == "https://" {
+		url = endpoint
+	} else {
+		url = c.baseURL + endpoint
+	}
 
 	// Send with retries
 	var lastErr error

+ 12 - 8
cmd/beacon-daemon/config.go

@@ -12,7 +12,8 @@ type Config struct {
 	Mode string `json:"mode"`
 
 	// Server settings
-	APIBase string `json:"api_base"`
+	APIBase               string `json:"api_base"`
+	ConfigPollingInterval int    `json:"cfg_polling_timeout"` // in seconds, default: 30
 
 	// Device identity (persisted)
 	DeviceID    string `json:"device_id,omitempty"`
@@ -20,8 +21,9 @@ type Config struct {
 
 	// BLE settings
 	BLE struct {
-		Enabled         bool `json:"enabled"`
-		BatchIntervalMs int  `json:"batch_interval_ms"`
+		Enabled         bool   `json:"enabled"`
+		BatchIntervalMs int    `json:"batch_interval_ms"`
+		UploadEndpoint  string `json:"upload_endpoint,omitempty"`
 	} `json:"ble"`
 
 	// WiFi settings
@@ -31,6 +33,7 @@ type Config struct {
 		SSID            string `json:"ssid"`
 		PSK             string `json:"psk"`
 		BatchIntervalMs int    `json:"batch_interval_ms"`
+		UploadEndpoint  string `json:"upload_endpoint,omitempty"`
 	} `json:"wifi"`
 
 	// SSH Tunnel settings (for terminal access)
@@ -88,11 +91,12 @@ type Config struct {
 // DefaultConfig returns a configuration with default values
 func DefaultConfig() *Config {
 	cfg := &Config{
-		Mode:        "cloud",
-		APIBase:     "http://localhost:5000/api/v1",
-		ZMQAddrBLE:  "tcp://127.0.0.1:5555",
-		ZMQAddrWiFi: "tcp://127.0.0.1:5556",
-		SpoolDir:    "/var/spool/mybeacon",
+		Mode:                  "cloud",
+		APIBase:               "http://localhost:5000/api/v1",
+		ConfigPollingInterval: 30,
+		ZMQAddrBLE:            "tcp://127.0.0.1:5555",
+		ZMQAddrWiFi:           "tcp://127.0.0.1:5556",
+		SpoolDir:              "/var/spool/mybeacon",
 	}
 	cfg.BLE.Enabled = true
 	cfg.BLE.BatchIntervalMs = 2500

+ 67 - 10
cmd/beacon-daemon/main.go

@@ -225,8 +225,8 @@ func main() {
 	go daemon.subscribeLoop("wifi", cfg.ZMQAddrWiFi)
 
 	// Start batch upload loops
-	go daemon.uploadLoop("ble", "/ble", cfg.BLE.BatchIntervalMs)
-	go daemon.uploadLoop("wifi", "/wifi", cfg.WiFi.BatchIntervalMs)
+	go daemon.uploadLoop("ble", cfg.BLE.BatchIntervalMs)
+	go daemon.uploadLoop("wifi", cfg.WiFi.BatchIntervalMs)
 
 	// Start spool flush loop
 	go daemon.spoolFlushLoop()
@@ -252,7 +252,7 @@ func (d *Daemon) registrationLoop() {
 		log.Println("Attempting device registration...")
 
 		// Generate or load SSH key pair
-		sshKeyPath := "/etc/beacon/ssh_tunnel_ed25519"
+		sshKeyPath := "/opt/mybeacon/etc/ssh_tunnel_ed25519"
 		sshPubKey, err := GenerateOrLoadSSHKey(sshKeyPath)
 		if err != nil {
 			log.Printf("Failed to generate/load SSH key: %v", err)
@@ -297,7 +297,15 @@ func (d *Daemon) configLoop() {
 	// Initial fetch immediately
 	d.fetchAndApplyConfig()
 
-	ticker := time.NewTicker(30 * time.Second)
+	// Get initial polling interval (default 30 seconds)
+	d.mu.Lock()
+	interval := d.cfg.ConfigPollingInterval
+	if interval <= 0 {
+		interval = 30
+	}
+	d.mu.Unlock()
+
+	ticker := time.NewTicker(time.Duration(interval) * time.Second)
 	defer ticker.Stop()
 
 	for {
@@ -308,6 +316,21 @@ func (d *Daemon) configLoop() {
 		}
 
 		d.fetchAndApplyConfig()
+
+		// Check if interval changed and reset ticker if needed
+		d.mu.Lock()
+		newInterval := d.cfg.ConfigPollingInterval
+		if newInterval <= 0 {
+			newInterval = 30
+		}
+		if newInterval != interval {
+			interval = newInterval
+			ticker.Reset(time.Duration(interval) * time.Second)
+			if d.cfg.Debug {
+				log.Printf("[config] Polling interval changed to %d seconds", interval)
+			}
+		}
+		d.mu.Unlock()
 	}
 }
 
@@ -323,12 +346,12 @@ func (d *Daemon) fetchAndApplyConfig() {
 			// Silent - use local config
 			return
 		}
-		if d.cfg.Debug {
-			log.Printf("Config fetch failed: %v", err)
-		}
+		log.Printf("[config] Fetch failed: %v", err)
 		return
 	}
 
+	log.Printf("[config] Fetched from server (polling interval: %ds)", d.cfg.ConfigPollingInterval)
+
 	// Determine effective mode: force_cloud overrides local mode setting
 	effectiveMode := d.cfg.Mode
 	if effectiveMode == "" {
@@ -358,13 +381,20 @@ func (d *Daemon) fetchAndApplyConfig() {
 
 		d.cfg.BLE.Enabled = serverCfg.BLE.Enabled
 		d.cfg.BLE.BatchIntervalMs = serverCfg.BLE.BatchIntervalMs
+		d.cfg.BLE.UploadEndpoint = serverCfg.BLE.UploadEndpoint
 		d.cfg.WiFi.MonitorEnabled = serverCfg.WiFi.MonitorEnabled
 		d.cfg.WiFi.ClientEnabled = serverCfg.WiFi.ClientEnabled
 		d.cfg.WiFi.SSID = serverCfg.WiFi.SSID
 		d.cfg.WiFi.PSK = serverCfg.WiFi.PSK
 		d.cfg.WiFi.BatchIntervalMs = serverCfg.WiFi.BatchIntervalMs
+		d.cfg.WiFi.UploadEndpoint = serverCfg.WiFi.UploadEndpoint
 		d.cfg.Debug = serverCfg.Debug
 
+		// Config polling interval from server
+		if serverCfg.ConfigPollingInterval > 0 {
+			d.cfg.ConfigPollingInterval = serverCfg.ConfigPollingInterval
+		}
+
 		// NTP from server in cloud mode
 		if len(serverCfg.Net.NTP.Servers) > 0 {
 			d.cfg.Network.NTPServers = serverCfg.Net.NTP.Servers
@@ -599,7 +629,7 @@ func (d *Daemon) runSubscriber(name string, addr string) error {
 	}
 }
 
-func (d *Daemon) uploadLoop(name string, endpoint string, intervalMs int) {
+func (d *Daemon) uploadLoop(name string, intervalMs int) {
 	if intervalMs <= 0 {
 		intervalMs = 2500
 	}
@@ -621,12 +651,25 @@ func (d *Daemon) uploadLoop(name string, endpoint string, intervalMs int) {
 
 		d.mu.Lock()
 		var events []interface{}
+		var endpoint string
 		if name == "ble" {
 			events = d.bleEvents
 			d.bleEvents = nil
+			// Use custom endpoint if set, otherwise default to /ble
+			if d.cfg.BLE.UploadEndpoint != "" {
+				endpoint = d.cfg.BLE.UploadEndpoint
+			} else {
+				endpoint = "/ble"
+			}
 		} else {
 			events = d.wifiEvents
 			d.wifiEvents = nil
+			// Use custom endpoint if set, otherwise default to /wifi
+			if d.cfg.WiFi.UploadEndpoint != "" {
+				endpoint = d.cfg.WiFi.UploadEndpoint
+			} else {
+				endpoint = "/wifi"
+			}
 		}
 		d.mu.Unlock()
 
@@ -704,10 +747,24 @@ func (d *Daemon) spoolFlushLoop() {
 			if ev, ok := batch.Events[0].(map[string]interface{}); ok {
 				if t, ok := ev["type"].(string); ok {
 					if strings.HasPrefix(t, "wifi") {
-						endpoint = "/wifi"
+						// Use custom endpoint if set, otherwise default to /wifi
+						d.mu.Lock()
+						if d.cfg.WiFi.UploadEndpoint != "" {
+							endpoint = d.cfg.WiFi.UploadEndpoint
+						} else {
+							endpoint = "/wifi"
+						}
+						d.mu.Unlock()
 						eventType = "wifi"
 					} else {
-						endpoint = "/ble"
+						// Use custom endpoint if set, otherwise default to /ble
+						d.mu.Lock()
+						if d.cfg.BLE.UploadEndpoint != "" {
+							endpoint = d.cfg.BLE.UploadEndpoint
+						} else {
+							endpoint = "/ble"
+						}
+						d.mu.Unlock()
 						eventType = "ble"
 					}
 				}

+ 1 - 1
cmd/beacon-daemon/ssh_tunnel.go

@@ -53,7 +53,7 @@ func (t *SSHTunnel) Start() error {
 		return nil
 	}
 
-	keyPath := "/etc/beacon/ssh_tunnel_ed25519"
+	keyPath := "/opt/mybeacon/etc/ssh_tunnel_ed25519"
 
 	// Verify key exists
 	if _, err := os.Stat(keyPath); os.IsNotExist(err) {

+ 3 - 0
dashboard/src/components/LogTab.vue

@@ -52,6 +52,8 @@ function getLogLevel(line) {
   const l = line.toLowerCase()
   if (l.includes('error') || l.includes('failed') || l.includes('fatal')) return 'error'
   if (l.includes('warn')) return 'warn'
+  if (l.includes('[config]') || l.includes('uploaded') || l.includes('flushed') ||
+      l.includes('success') || l.includes('registered') || l.includes('connected')) return 'success'
   if (l.includes('debug')) return 'debug'
   return ''
 }
@@ -118,6 +120,7 @@ function getLogLevel(line) {
 
 .log-line.error { color: #ef4444; }
 .log-line.warn { color: #f59e0b; }
+.log-line.success { color: #4ade80; }
 .log-line.debug { color: #6e7681; }
 
 .no-logs {