Add CDAP gateway & redesign devices UI

Introduce full CDAP subsystem and devices UI overhaul. Adds a new CDAP WebSocket gateway (cdap/gateway.go) with auth, connection lifecycle, message loop, heartbeat monitor and APIs (cdap/api.go, cdap/auth.go, cdap/handler.go, cdap/manifest.go, cdap/messages.go). Wire CDAP into the server (api/server.go + handlers in api/cdap_handlers.go) exposing REST endpoints for status, device list, info, manifest, state and sending commands. Enhance peer handling: CDAP-connected overlay in peer list/get, device revocation/cascade support in handleDeletePeer (blocklist, connection teardown, events + audit), and new audit action ActionPeerRevoked. Frontend updates include CDAP device page, widgets, commands, styles and services; major devices page UI redesign (responsive folder chips, toolbar, slim table, kebab menu) plus related CSS/JS/views, translations, docs and assets. Overall adds CDAP features, revocation workflow, and a responsive devices UI.
This commit is contained in:
UNITRONIX
2026-03-20 02:01:48 +01:00
parent 98209249f3
commit a957f3fe2a
47 changed files with 10054 additions and 1019 deletions
+31 -1
View File
@@ -541,6 +541,36 @@ sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git
156. [x] **Relay diagnostic logging**: Added `log.Printf` with UUID and relay server in `handleRequestRelayTCP` and `handleRequestRelay` (UDP) return paths for better relay pairing diagnostics.
157. [x] **Docker GHCR "denied" error (Issue #67)**: Pre-built images on `ghcr.io/unitronix/betterdesk-*:latest` not available — workflow never triggered or packages are private. Added troubleshooting section to `DOCKER_QUICKSTART.md` (3 solutions: build locally, trigger workflow, authenticate). Added fallback comment to `docker-compose.quick.yml`. Added package visibility reminder to CI workflow summary step.
#### CDAP v0.2.0 — Device Revocation & Schema (Phase 28) ✅ COMPLETED 2026-03-20
158. [x] **CDAP schema columns**: Added `device_type TEXT DEFAULT ''` and `linked_peer_id TEXT DEFAULT ''` to `peers` table in both SQLite (`db/sqlite.go`) and PostgreSQL (`db/postgres.go`) via automatic column migration (v2.5.0). Updated all SELECT/Scan queries (GetPeer, ListPeers, ListPeersByTag, ChangePeerID) and `UpdatePeerFields` allowed keys.
159. [x] **GetLinkedPeers**: New `GetLinkedPeers(id string) ([]*Peer, error)` method on Database interface + both implementations. Queries peers where `linked_peer_id = id`.
160. [x] **Device revocation endpoint**: Enhanced `DELETE /api/peers/{id}` with `?revoke=true` (auto BlockID + disconnect active connections) and `?cascade=true` (delete all linked devices). Publishes `EventPeerRevoked` event and logs `ActionPeerRevoked` audit action.
161. [x] **Connection teardown on Remove**: `peer.Entry.CloseConnections()` method closes TCP and WebSocket connections. Called from `peer.Map.Remove()` and `CleanExpired()` — revoked devices are disconnected immediately.
162. [x] **Panel revocation UI**: Delete modal in `devices.js` includes "Revoke device" checkbox with hint text. Routes through `devices.routes.js``serverBackend.js``betterdeskApi.js` with `revoke`/`cascade` query params.
163. [x] **i18n keys**: Added `revoke_option`, `revoke_hint`, `revoke_success` to EN, PL, ZH translation files.
164. [x] **Deployed & verified**: Binary deployed to production server (PostgreSQL backend). Automatic migration confirmed — `device_type` and `linked_peer_id` columns present. API returns peers correctly. 5 devices online, 53 total.
#### CDAP v0.3.0 — Panel Widget Rendering (Phase 29) ✅ COMPLETED 2026-03-20
165. [x] **cdap/api.go**: REST-helper methods on Gateway — `GetDeviceInfo()`, `GetDeviceManifest()`, `GetDeviceWidgetState()`, `IsConnected()`, `SendCommandJSON()`, `ListConnectedDevices()`. New `DeviceInfo` struct for REST responses.
166. [x] **api/cdap_handlers.go**: 6 HTTP handlers — `handleCDAPStatus`, `handleCDAPListDevices`, `handleCDAPDeviceInfo`, `handleCDAPDeviceManifest`, `handleCDAPDeviceState`, `handleCDAPSendCommand`. Uses `commandCounter atomic.Int64` for unique command IDs. Returns 503 when CDAP disabled.
167. [x] **api/server.go CDAP integration**: Added `cdapGw` field, `SetCDAPGateway()` method, 6 CDAP mux routes. `CDAPConnected` bool in `peerResponse` for both `handleListPeers` and `handleGetPeer`. CDAP overlay: if device connected via CDAP but not signal, shown as online.
168. [x] **main.go CDAP wiring**: Gateway created before API server, `SetCDAPGateway()` called before `Start()`, gateway started after API.
169. [x] **betterdeskApi.js CDAP methods**: 6 async methods — `getCDAPStatus`, `getCDAPDevices`, `getCDAPDeviceInfo`, `getCDAPDeviceManifest`, `getCDAPDeviceState`, `sendCDAPCommand`.
170. [x] **cdap.routes.js**: Page route `GET /cdap/devices/:id` + 6 API proxy routes. Uses `requireAuth` + `requireRole('operator')` for command sending.
171. [x] **routes/index.js**: Registered `cdapRoutes` as `router.use('/', cdapRoutes)`.
172. [x] **cdap-device.ejs**: Device detail page with header (name, type, version, uptime, status), offline banner, widget grid, empty state, command log panel.
173. [x] **cdap-widgets.js**: Widget renderer supporting 8 types (toggle, gauge, button, led, text, slider, select, chart). State polling every 3s. Info polling every 10s. User interaction guard (`_userInteracting` flag) prevents state overwrite during input. Grouped by category.
174. [x] **cdap-commands.js**: Command sender with per-widget cooldown (1s), confirmation dialog integration, command log (max 50 entries), toast notifications.
175. [x] **cdap.css**: Full widget styling — grid layout, toggle switch, gauge bar with danger/warning thresholds, LED indicator, slider with range labels, select dropdown, chart bars, command log panel. Responsive breakpoints. Dark theme CSS variables.
176. [x] **i18n keys**: 22 CDAP keys added to EN, PL, ZH translation files (device_detail, loading, connected, disconnected, widgets, commands, etc.).
177. [x] **Deployed & verified**: Go binary + Node.js files deployed. Server running, console active. CDAP routes return 302 (auth redirect) for unauthenticated, 401 for API without key — both correct.
#### Devices Page UI Redesign (Phase 30) ✅ COMPLETED 2026-03-20
178. [x] **devices.ejs rewrite**: Removed 280px sidebar layout. New single-column layout with horizontal scrollable folder chips (`.folder-chip` buttons), unified toolbar (search + segmented filter pills + column visibility toggle), slim table with 7 columns (id, hostname, device_type, platform, last_online, status, actions), kebab menu (`more_vert` icon) replacing 5 inline action buttons, mobile bottom sheet overlay for phone kebab menu.
179. [x] **devices.css rewrite**: ~780 lines. 4 responsive breakpoints: ≤1024px (hide device_type), ≤768px (hide platform+last_online, full-width search, icon-only buttons), ≤600px (card-style rows via CSS grid 2-col, hidden thead, fixed bottom sheet kebab with overlay), ≤400px (chip labels hidden, compact filters). Folder chip styles with hover-reveal edit/delete actions. Kebab dropdown with color-coded menu items.
180. [x] **devices.js updates**: `renderDevices()` outputs new HTML template with `.device-status-dot`, `.kebab-wrapper`/`.kebab-btn`/`.kebab-menu`. `renderFolders()` changed from `.folder-item` divs to `.folder-chip` buttons with `.chip-action` edit/delete. `attachRowEventListeners()` handles kebab toggle + menu item actions. Added `initKebabGlobalClose()` + `closeAllKebabMenus()`. Updated all selectors: `.folder-item``.folder-chip` in `selectFolder()`, `updateFolderCounts()`, `initFolders()`, `attachFolderDropEvents()`. Double-click guard updated from `.action-btn`/`.drag-handle` to `.kebab-wrapper`.
181. [x] **Deployed & verified**: All 3 files deployed to production server. Console returns 302 (service running). Responsive layout active.
---
## 🔄 System Statusu v3.0
@@ -810,4 +840,4 @@ All code changes MUST include a security review as part of the implementation pr
---
*Ostatnia aktualizacja: 2026-03-19 (Go Server — ForceRelay UUID Fix & Docker GHCR — Phase 27) przez GitHub Copilot*
*Ostatnia aktualizacja: 2026-03-20 (Devices Page UI Redesign — Phase 30) przez GitHub Copilot*
+1
View File
@@ -177,3 +177,4 @@ tasks/todo.md
docs/SECURITY_AUDIT_FULL_2026-03-19.md
.github/copilot-instructions.md
.github/copilot-instructions.md
.github/copilot-instructions.md
+2 -2
View File
@@ -370,7 +370,7 @@ The web console (`web-nodejs/`) is an Express.js application providing a full-fe
### Features
- **Dashboard** — Real-time statistics cards (total, active, inactive, banned devices)
- **Device management** — Search, filter, sort, add notes, ban/unban, change ID
- **Device management** — Responsive devices page with horizontal folder chips, unified toolbar with segmented filters, slim table, and kebab context menu (⋮). Four breakpoints: desktop, tablet (≤768px), phone card layout (≤600px), small phone (≤400px)
- **Device details** — Hardware tab (sysinfo), metrics tab (live CPU/RAM/disk bars + history charts)
- **TOTP 2FA** — Two-factor authentication with `otplib`
- **RBAC** — Admin, Operator, Viewer roles with permission enforcement
@@ -1290,7 +1290,7 @@ RustDesk clients support end-to-end encryption for remote desktop sessions. Bett
| Component | Technology |
|-----------|-----------|
| **UI** | HTML5, CSS3 (glassmorphism), JavaScript ES6+ |
| **UI** | HTML5, CSS3 (glassmorphism, responsive breakpoints), JavaScript ES6+ |
| **Icons** | Material Icons (offline) |
| **Charts** | Live metric bars + history charts |
| **i18n** | JSON-based translations |
+198
View File
@@ -0,0 +1,198 @@
package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"sync/atomic"
"github.com/unitronix/betterdesk-server/cdap"
)
// commandCounter generates unique command IDs for CDAP commands.
var commandCounter atomic.Int64
// cdapDeviceIDRegexp validates CDAP device ID format: "CDAP-" + 6-16 hex chars, or standard peer IDs.
var cdapDeviceIDRegexp = regexp.MustCompile(`^(CDAP-[A-Fa-f0-9]{6,16}|[A-Za-z0-9_-]{6,16})$`)
// handleCDAPDeviceInfo returns full device info for a connected CDAP device.
// GET /api/cdap/devices/{id}
func (s *Server) handleCDAPDeviceInfo(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !cdapDeviceIDRegexp.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"})
return
}
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
info := s.cdapGw.GetDeviceInfo(id)
if info == nil {
// Device not connected via CDAP — check if manifest exists in DB
manifest, ok := s.cdapGw.GetDeviceManifest(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Device not found or not a CDAP device"})
return
}
writeJSON(w, http.StatusOK, &cdap.DeviceInfo{
ID: id,
Connected: false,
Manifest: manifest,
})
return
}
writeJSON(w, http.StatusOK, info)
}
// handleCDAPDeviceManifest returns the manifest for a CDAP device.
// GET /api/cdap/devices/{id}/manifest
func (s *Server) handleCDAPDeviceManifest(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !cdapDeviceIDRegexp.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"})
return
}
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
manifest, ok := s.cdapGw.GetDeviceManifest(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "No manifest found for device"})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(manifest)
}
// handleCDAPDeviceState returns current widget values for a connected CDAP device.
// GET /api/cdap/devices/{id}/state
func (s *Server) handleCDAPDeviceState(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !cdapDeviceIDRegexp.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"})
return
}
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
state, ok := s.cdapGw.GetDeviceWidgetState(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Device not connected"})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"device_id": id,
"widget_state": state,
"connected": true,
})
}
// handleCDAPSendCommand sends a command to a connected CDAP device.
// POST /api/cdap/devices/{id}/command
func (s *Server) handleCDAPSendCommand(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !cdapDeviceIDRegexp.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"})
return
}
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
var body struct {
WidgetID string `json:"widget_id"`
Action string `json:"action"`
Value any `json:"value"`
Reason string `json:"reason,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
return
}
if body.WidgetID == "" || body.Action == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "widget_id and action are required"})
return
}
// Validate action
validActions := map[string]bool{"set": true, "trigger": true, "execute": true, "reset": true, "query": true}
if !validActions[body.Action] {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid action. Must be: set, trigger, execute, reset, query"})
return
}
operator := getUsernameFromCtx(r)
commandID := fmt.Sprintf("cmd_%s_%d", id, commandCounter.Add(1))
if err := s.cdapGw.SendCommandJSON(r.Context(), id, commandID, body.WidgetID, body.Action, body.Value, operator, body.Reason); err != nil {
log.Printf("[cdap-api] SendCommand to %s failed: %v", id, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "Device not connected or command failed"})
return
}
writeJSON(w, http.StatusAccepted, map[string]string{
"status": "sent",
"command_id": commandID,
"device_id": id,
})
}
// handleCDAPListDevices returns all connected CDAP devices with their info.
// GET /api/cdap/devices
func (s *Server) handleCDAPListDevices(w http.ResponseWriter, r *http.Request) {
if s.cdapGw == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"})
return
}
ids := s.cdapGw.ListConnectedDevices()
devices := make([]any, 0, len(ids))
for _, id := range ids {
if info := s.cdapGw.GetDeviceInfo(id); info != nil {
devices = append(devices, info)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"devices": devices,
"total": len(devices),
})
}
// handleCDAPStatus returns CDAP gateway status.
// GET /api/cdap/status
func (s *Server) handleCDAPStatus(w http.ResponseWriter, r *http.Request) {
if s.cdapGw == nil {
writeJSON(w, http.StatusOK, map[string]any{
"enabled": false,
"connected": 0,
"port": 0,
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": true,
"connected": s.cdapGw.ActiveConnections(),
"port": s.cfg.CDAPPort,
"tls": s.cfg.CDAPTLSEnabled(),
})
}
+124 -18
View File
@@ -19,6 +19,7 @@ import (
"github.com/unitronix/betterdesk-server/audit"
"github.com/unitronix/betterdesk-server/auth"
"github.com/unitronix/betterdesk-server/cdap"
"github.com/unitronix/betterdesk-server/config"
"github.com/unitronix/betterdesk-server/crypto"
"github.com/unitronix/betterdesk-server/db"
@@ -54,6 +55,7 @@ type Server struct {
jwtManager *auth.JWTManager
loginLimiter *ratelimit.IPLimiter
keyPair *crypto.KeyPair // Ed25519 keypair for signing
cdapGw *cdap.Gateway // CDAP gateway (nil if CDAP disabled)
clientTFASessions *tfaSessionStore
httpSrv *http.Server
wg sync.WaitGroup
@@ -108,6 +110,11 @@ func (s *Server) SetKeyPair(kp *crypto.KeyPair) {
s.keyPair = kp
}
// SetCDAPGateway sets the CDAP gateway for serving CDAP REST endpoints.
func (s *Server) SetCDAPGateway(gw *cdap.Gateway) {
s.cdapGw = gw
}
// Start launches the HTTP API server.
func (s *Server) Start(ctx context.Context) error {
mux := http.NewServeMux()
@@ -201,6 +208,14 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("GET /api/enrollment/mode", s.requireRole(auth.RoleAdmin, s.handleGetEnrollmentMode))
mux.HandleFunc("PUT /api/enrollment/mode", s.requireRole(auth.RoleAdmin, s.handleSetEnrollmentMode))
// CDAP device management (requires CDAP gateway to be enabled)
mux.HandleFunc("GET /api/cdap/status", s.handleCDAPStatus)
mux.HandleFunc("GET /api/cdap/devices", s.handleCDAPListDevices)
mux.HandleFunc("GET /api/cdap/devices/{id}", s.handleCDAPDeviceInfo)
mux.HandleFunc("GET /api/cdap/devices/{id}/manifest", s.handleCDAPDeviceManifest)
mux.HandleFunc("GET /api/cdap/devices/{id}/state", s.handleCDAPDeviceState)
mux.HandleFunc("POST /api/cdap/devices/{id}/command", s.requireRole(auth.RoleOperator, s.handleCDAPSendCommand))
// Prometheus metrics (public, no API key required)
mux.HandleFunc("GET /metrics", s.handleMetrics)
@@ -352,9 +367,10 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
// Enrich with live online status and status tier from memory map
type peerResponse struct {
*db.Peer
LiveOnline bool `json:"live_online"`
LiveStatus peer.Status `json:"live_status"`
Platform string `json:"platform"`
LiveOnline bool `json:"live_online"`
LiveStatus peer.Status `json:"live_status"`
Platform string `json:"platform"`
CDAPConnected bool `json:"cdap_connected"`
}
result := make([]peerResponse, len(peers))
@@ -364,11 +380,20 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
if snap, ok := s.peers.GetSnapshot(p.ID, config.DegradedThreshold, config.CriticalThreshold); ok {
liveStatus = snap.Status
}
// CDAP overlay: device connected via CDAP gateway is online
cdapConnected := s.cdapGw != nil && s.cdapGw.IsConnected(p.ID)
if cdapConnected && !liveOnline {
liveOnline = true
liveStatus = peer.StatusOnline
}
result[i] = peerResponse{
Peer: p,
LiveOnline: liveOnline,
LiveStatus: liveStatus,
Platform: p.OS,
Peer: p,
LiveOnline: liveOnline,
LiveStatus: liveStatus,
Platform: p.OS,
CDAPConnected: cdapConnected,
}
}
@@ -394,18 +419,27 @@ func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
liveStatus = snap.Status
}
// CDAP overlay
cdapConnected := s.cdapGw != nil && s.cdapGw.IsConnected(p.ID)
if cdapConnected && !liveOnline {
liveOnline = true
liveStatus = peer.StatusOnline
}
type singlePeerResponse struct {
*db.Peer
LiveOnline bool `json:"live_online"`
LiveStatus peer.Status `json:"live_status"`
Platform string `json:"platform"`
LiveOnline bool `json:"live_online"`
LiveStatus peer.Status `json:"live_status"`
Platform string `json:"platform"`
CDAPConnected bool `json:"cdap_connected"`
}
writeJSON(w, http.StatusOK, singlePeerResponse{
Peer: p,
LiveOnline: liveOnline,
LiveStatus: liveStatus,
Platform: p.OS,
Peer: p,
LiveOnline: liveOnline,
LiveStatus: liveStatus,
Platform: p.OS,
CDAPConnected: cdapConnected,
})
}
@@ -455,6 +489,18 @@ func (s *Server) handleUpdatePeerFields(w http.ResponseWriter, r *http.Request)
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
hard := r.URL.Query().Get("hard") == "true"
revoke := r.URL.Query().Get("revoke") == "true"
cascade := r.URL.Query().Get("cascade") == "true"
// Collect linked peers before deletion (for cascade and response).
var linkedIDs []string
if revoke || cascade {
if linked, err := s.db.GetLinkedPeers(id); err == nil {
for _, lp := range linked {
linkedIDs = append(linkedIDs, lp.ID)
}
}
}
var err error
if hard {
@@ -467,14 +513,74 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
return
}
// Also remove from memory
// Remove from memory (closes TCP/WS connections — Phase 3.9).
s.peers.Remove(id)
if s.auditLog != nil {
s.auditLog.Log(audit.ActionPeerDeleted, s.remoteIP(r), id, map[string]string{"hard": fmt.Sprintf("%v", hard)})
// Revocation: add device ID to blocklist to prevent re-registration.
if revoke && s.blocklist != nil {
s.blocklist.BlockID(id, "revoked via panel")
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
// Cascade: revoke linked devices (e.g., paired mobile→desktop).
var cascadedIDs []string
if cascade && len(linkedIDs) > 0 {
for _, lid := range linkedIDs {
if hard {
s.db.HardDeletePeer(lid)
} else {
s.db.DeletePeer(lid)
}
s.peers.Remove(lid)
if revoke && s.blocklist != nil {
s.blocklist.BlockID(lid, "revoked via cascade")
}
cascadedIDs = append(cascadedIDs, lid)
}
}
// Publish event.
if s.eventBus != nil {
action := eventsModule.EventPeerDeleted
if revoke {
action = eventsModule.EventPeerRevoked
}
s.eventBus.Publish(eventsModule.Event{
Type: action,
Data: map[string]string{
"id": id,
"hard": fmt.Sprintf("%v", hard),
"revoke": fmt.Sprintf("%v", revoke),
"cascade": fmt.Sprintf("%v", cascadedIDs),
},
})
}
// Audit log.
if s.auditLog != nil {
action := audit.ActionPeerDeleted
if revoke {
action = audit.ActionPeerRevoked
}
details := map[string]string{
"hard": fmt.Sprintf("%v", hard),
"revoke": fmt.Sprintf("%v", revoke),
"cascade": fmt.Sprintf("%v", cascade),
}
if len(cascadedIDs) > 0 {
details["cascaded_ids"] = fmt.Sprintf("%v", cascadedIDs)
}
s.auditLog.Log(action, s.remoteIP(r), id, details)
}
resp := map[string]interface{}{
"status": "deleted",
"id": id,
"revoked": revoke,
}
if len(cascadedIDs) > 0 {
resp["cascaded"] = cascadedIDs
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleBanPeer(w http.ResponseWriter, r *http.Request) {
+1
View File
@@ -19,6 +19,7 @@ const (
ActionPeerBanned Action = "peer_banned"
ActionPeerUnbanned Action = "peer_unbanned"
ActionPeerDeleted Action = "peer_deleted"
ActionPeerRevoked Action = "peer_revoked"
ActionPeerUpdated Action = "peer_updated"
ActionPeerIDChanged Action = "peer_id_changed"
ActionPeerTagsUpdated Action = "peer_tags_updated"
+156
View File
@@ -0,0 +1,156 @@
// Package cdap provides REST-friendly accessors for the CDAP gateway state.
// These methods are consumed by the API server to serve panel requests.
package cdap
import (
"context"
"encoding/json"
"fmt"
"time"
)
// DeviceInfo is a REST-friendly snapshot of a CDAP device's state.
type DeviceInfo struct {
ID string `json:"id"`
Connected bool `json:"connected"`
Manifest json.RawMessage `json:"manifest,omitempty"`
WidgetState map[string]any `json:"widget_state,omitempty"`
ConnectedAt *time.Time `json:"connected_at,omitempty"`
LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"`
SessionID string `json:"session_id,omitempty"`
HeartbeatCount int64 `json:"heartbeat_count"`
CommandCount int64 `json:"command_count"`
HeartbeatInterval int `json:"heartbeat_interval"`
Username string `json:"username,omitempty"`
Role string `json:"role,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
}
// GetDeviceInfo returns a full snapshot of a connected CDAP device.
// Returns nil if the device is not connected via CDAP.
func (g *Gateway) GetDeviceInfo(id string) *DeviceInfo {
dc := g.GetDeviceConn(id)
if dc == nil {
return nil
}
info := &DeviceInfo{
ID: dc.ID,
Connected: true,
SessionID: dc.SessionID,
HeartbeatCount: dc.HeartbeatCount.Load(),
CommandCount: dc.CommandCount.Load(),
HeartbeatInterval: dc.HeartbeatInterval,
Username: dc.Username,
Role: dc.Role,
ClientIP: dc.ClientIP,
}
connAt := dc.ConnectedAt
info.ConnectedAt = &connAt
lastHB := dc.LastHeartbeat
if !lastHB.IsZero() {
info.LastHeartbeat = &lastHB
}
if dc.Manifest != nil {
data, err := json.Marshal(dc.Manifest)
if err == nil {
info.Manifest = data
}
}
// Collect widget state
state := make(map[string]any)
dc.widgetState.Range(func(key, value any) bool {
state[key.(string)] = value
return true
})
if len(state) > 0 {
info.WidgetState = state
}
return info
}
// GetDeviceManifest returns the manifest JSON for a device.
// Checks in-memory first (connected device), then falls back to DB.
func (g *Gateway) GetDeviceManifest(id string) (json.RawMessage, bool) {
// In-memory (connected device)
if dc := g.GetDeviceConn(id); dc != nil && dc.Manifest != nil {
data, err := json.Marshal(dc.Manifest)
if err == nil {
return data, true
}
}
// Fall back to DB (stored on registration)
val, err := g.db.GetConfig("cdap_manifest_" + id)
if err != nil || val == "" {
return nil, false
}
return json.RawMessage(val), true
}
// GetDeviceWidgetState returns current widget values for a connected device.
func (g *Gateway) GetDeviceWidgetState(id string) (map[string]any, bool) {
dc := g.GetDeviceConn(id)
if dc == nil {
return nil, false
}
state := make(map[string]any)
dc.widgetState.Range(func(key, value any) bool {
state[key.(string)] = value
return true
})
return state, true
}
// IsConnected returns true if the device has an active CDAP connection.
func (g *Gateway) IsConnected(id string) bool {
return g.GetDeviceConn(id) != nil
}
// SendCommandJSON builds and sends a command to a connected CDAP device.
func (g *Gateway) SendCommandJSON(ctx context.Context, deviceID, commandID, widgetID, action string, value any, operator, reason string) error {
payload := CommandPayload{
CommandID: commandID,
WidgetID: widgetID,
Action: action,
Value: value,
Operator: operator,
Reason: reason,
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal command: %w", err)
}
cmd := &CommandMessage{
ID: commandID,
Payload: data,
}
if err := g.SendCommand(ctx, deviceID, cmd); err != nil {
return err
}
g.auditAction("cdap_command_sent", deviceID, map[string]string{
"command_id": commandID,
"widget_id": widgetID,
"action": action,
"operator": operator,
})
return nil
}
// ListConnectedDevices returns IDs of all connected CDAP devices.
func (g *Gateway) ListConnectedDevices() []string {
var ids []string
g.devices.Range(func(key, value any) bool {
ids = append(ids, key.(string))
return true
})
return ids
}
+236
View File
@@ -0,0 +1,236 @@
package cdap
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"time"
"github.com/coder/websocket"
"github.com/unitronix/betterdesk-server/audit"
"github.com/unitronix/betterdesk-server/auth"
)
// handleAuth reads the initial "auth" message from the client, validates
// credentials, and returns a DeviceConn on success.
func (g *Gateway) handleAuth(ctx context.Context, conn *websocket.Conn, clientIP string) (*DeviceConn, error) {
msg, err := readMessage(ctx, conn)
if err != nil {
return nil, fmt.Errorf("read auth message: %w", err)
}
if msg.Type != "auth" {
return nil, fmt.Errorf("expected 'auth' message, got '%s'", msg.Type)
}
var payload AuthPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
return nil, fmt.Errorf("invalid auth payload: %w", err)
}
// Rate limit check for this IP
if g.limiter != nil && !g.limiter.Allow(clientIP) {
g.auditAction("cdap_auth_ratelimited", clientIP, map[string]string{
"method": payload.Method,
})
return nil, fmt.Errorf("rate limit exceeded")
}
var username, role string
switch payload.Method {
case "user_password":
u, r, authErr := g.authUserPassword(payload, clientIP)
if authErr != nil {
return nil, authErr
}
username, role = u, r
case "api_key":
u, r, authErr := g.authAPIKey(payload, clientIP)
if authErr != nil {
return nil, authErr
}
username, role = u, r
case "device_token":
u, r, authErr := g.authDeviceToken(payload, clientIP)
if authErr != nil {
return nil, authErr
}
username, role = u, r
default:
return nil, fmt.Errorf("unsupported auth method: %s", payload.Method)
}
// Generate session token
sessionID, _ := auth.GenerateRandomString(16)
// Generate JWT for the device
token, err := g.jwt.Generate(username, role)
if err != nil {
return nil, fmt.Errorf("generate token: %w", err)
}
dc := &DeviceConn{
ID: payload.DeviceID,
Username: username,
Role: role,
ClientIP: clientIP,
conn: conn,
Token: token,
TokenExpiry: time.Now().Add(g.jwt.Expiry()),
SessionID: sessionID,
ConnectedAt: time.Now(),
LastHeartbeat: time.Now(),
HeartbeatInterval: 15, // default, updated after manifest registration
}
// Send auth_result
result := AuthResult{
Success: true,
Token: token,
Role: role,
DeviceID: payload.DeviceID,
SessionToken: sessionID,
}
if err := sendMessage(ctx, conn, "auth_result", result); err != nil {
return nil, fmt.Errorf("send auth_result: %w", err)
}
g.auditAction("cdap_auth_success", clientIP, map[string]string{
"device_id": payload.DeviceID,
"username": username,
"role": role,
"method": payload.Method,
})
log.Printf("[cdap] Authenticated %s (user=%s, role=%s, method=%s, ip=%s)",
payload.DeviceID, username, role, payload.Method, clientIP)
return dc, nil
}
// authUserPassword verifies username/password credentials.
func (g *Gateway) authUserPassword(p AuthPayload, clientIP string) (string, string, error) {
if p.Username == "" || p.Password == "" {
return "", "", fmt.Errorf("username and password required")
}
user, err := g.db.GetUser(p.Username)
if err != nil || user == nil {
// Timing-safe: always call VerifyPassword even for non-existent users
auth.VerifyPassword("dummy:hash", p.Password)
g.auditAction("cdap_auth_failed", clientIP, map[string]string{
"username": p.Username,
"reason": "invalid credentials",
})
return "", "", fmt.Errorf("invalid credentials")
}
if !auth.VerifyPassword(user.PasswordHash, p.Password) {
g.auditAction("cdap_auth_failed", clientIP, map[string]string{
"username": p.Username,
"reason": "invalid password",
})
return "", "", fmt.Errorf("invalid credentials")
}
// Check 2FA if enabled
if user.TOTPEnabled {
if p.TOTPCode == "" {
return "", "", fmt.Errorf("2fa_required")
}
if !auth.ValidateTOTP(user.TOTPSecret, p.TOTPCode) {
g.auditAction("cdap_auth_failed", clientIP, map[string]string{
"username": p.Username,
"reason": "invalid 2fa code",
})
return "", "", fmt.Errorf("invalid 2FA code")
}
}
return user.Username, user.Role, nil
}
// authAPIKey verifies an API key.
func (g *Gateway) authAPIKey(p AuthPayload, clientIP string) (string, string, error) {
if p.Key == "" {
return "", "", fmt.Errorf("api key required")
}
h := sha256.Sum256([]byte(p.Key))
keyHash := hex.EncodeToString(h[:])
apiKey, err := g.db.GetAPIKeyByHash(keyHash)
if err != nil || apiKey == nil {
g.auditAction("cdap_auth_failed", clientIP, map[string]string{
"reason": "invalid api key",
})
return "", "", fmt.Errorf("invalid API key")
}
// Touch last_used
g.db.TouchAPIKey(apiKey.ID)
return fmt.Sprintf("apikey:%s", apiKey.Name), apiKey.Role, nil
}
// authDeviceToken verifies a device enrollment token.
func (g *Gateway) authDeviceToken(p AuthPayload, clientIP string) (string, string, error) {
if p.Token == "" {
return "", "", fmt.Errorf("device token required")
}
h := sha256.Sum256([]byte(p.Token))
tokenHash := hex.EncodeToString(h[:])
dt, err := g.db.ValidateToken(tokenHash)
if err != nil || dt == nil {
g.auditAction("cdap_auth_failed", clientIP, map[string]string{
"reason": "invalid device token",
})
return "", "", fmt.Errorf("invalid or expired device token")
}
// Bind token to device if not already bound
if dt.PeerID == "" && p.DeviceID != "" {
g.db.BindTokenToPeer(tokenHash, p.DeviceID)
}
// Increment usage
g.db.IncrementTokenUse(tokenHash)
return fmt.Sprintf("token:%s", dt.Name), "operator", nil
}
// auditAction logs a CDAP action to the audit log.
func (g *Gateway) auditAction(action, target string, details map[string]string) {
if g.auditLog == nil {
return
}
if details == nil {
details = make(map[string]string)
}
details["source"] = "cdap"
g.auditLog.Log(audit.Action(action), "cdap", target, details)
}
// readMessage reads a single CDAP protocol message from a bare websocket connection.
func readMessage(ctx context.Context, conn *websocket.Conn) (*Message, error) {
typ, data, err := conn.Read(ctx)
if err != nil {
return nil, err
}
if typ != websocket.MessageText {
return nil, fmt.Errorf("expected text frame, got %v", typ)
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
return &msg, nil
}
+395
View File
@@ -0,0 +1,395 @@
// Package cdap implements the Custom Device Automation Protocol (CDAP) gateway.
// CDAP enables non-RustDesk devices (SCADA, IoT, OS agents, custom hardware) to
// connect to BetterDesk via a WebSocket-based JSON protocol and appear as
// manageable devices in the admin panel alongside standard RustDesk peers.
package cdap
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
"github.com/unitronix/betterdesk-server/audit"
"github.com/unitronix/betterdesk-server/auth"
"github.com/unitronix/betterdesk-server/config"
"github.com/unitronix/betterdesk-server/db"
"github.com/unitronix/betterdesk-server/events"
"github.com/unitronix/betterdesk-server/peer"
"github.com/unitronix/betterdesk-server/ratelimit"
"github.com/unitronix/betterdesk-server/security"
)
// Gateway is the CDAP WebSocket server.
type Gateway struct {
cfg *config.Config
db db.Database
peerMap *peer.Map
eventBus *events.Bus
auditLog *audit.Logger
blocklist *security.Blocklist
jwt *auth.JWTManager
limiter *ratelimit.IPLimiter
httpSrv *http.Server
ln net.Listener
// devices holds all authenticated CDAP connections keyed by device ID.
devices sync.Map // map[string]*DeviceConn
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
totalConns atomic.Int64
activeConns atomic.Int64
version string
}
// New creates a new CDAP gateway.
func New(cfg *config.Config, database db.Database, peerMap *peer.Map, eventBus *events.Bus) *Gateway {
return &Gateway{
cfg: cfg,
db: database,
peerMap: peerMap,
eventBus: eventBus,
limiter: ratelimit.NewIPLimiter(10, 1*time.Minute, 5*time.Minute),
}
}
// SetBlocklist sets the blocklist.
func (g *Gateway) SetBlocklist(bl *security.Blocklist) { g.blocklist = bl }
// SetAuditLogger sets the audit logger.
func (g *Gateway) SetAuditLogger(al *audit.Logger) { g.auditLog = al }
// SetJWTManager sets the JWT manager.
func (g *Gateway) SetJWTManager(jm *auth.JWTManager) { g.jwt = jm }
// SetRateLimiter overrides the default rate limiter.
func (g *Gateway) SetRateLimiter(l *ratelimit.IPLimiter) { g.limiter = l }
// SetVersion sets the version string for startup log.
func (g *Gateway) SetVersion(v string) { g.version = v }
// Start binds the WebSocket listener and begins accepting connections.
func (g *Gateway) Start(ctx context.Context) error {
g.ctx, g.cancel = context.WithCancel(ctx)
addr := fmt.Sprintf(":%d", g.cfg.CDAPPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("cdap: listen %s: %w", addr, err)
}
// Wrap with TLS auto-detect if enabled
if g.cfg.CDAPTLSEnabled() {
tlsCfg, tlsErr := config.LoadTLSConfig(g.cfg.TLSCertFile, g.cfg.TLSKeyFile)
if tlsErr != nil {
ln.Close()
return fmt.Errorf("cdap: tls config: %w", tlsErr)
}
ln = config.NewDualModeListener(ln, tlsCfg)
log.Printf("[cdap] TLS enabled (dual-mode: plain + TLS auto-detect)")
}
g.ln = ln
mux := http.NewServeMux()
mux.HandleFunc("/cdap", g.handleWebSocket)
mux.HandleFunc("/cdap/health", g.handleHealth)
g.httpSrv = &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
g.wg.Add(1)
go func() {
defer g.wg.Done()
if err := g.httpSrv.Serve(g.ln); err != nil && err != http.ErrServerClosed {
log.Printf("[cdap] Server error: %v", err)
}
}()
// Heartbeat monitor: detect stale CDAP connections
g.wg.Add(1)
go g.heartbeatMonitor()
scheme := "ws"
if g.cfg.CDAPTLSEnabled() {
scheme = "wss"
}
log.Printf("[cdap] Gateway started on %s://0.0.0.0:%d/cdap", scheme, g.cfg.CDAPPort)
return nil
}
// Stop gracefully shuts down the gateway.
func (g *Gateway) Stop() {
log.Printf("[cdap] Shutting down gateway...")
g.cancel()
// Close all device connections
g.devices.Range(func(key, value any) bool {
if dc, ok := value.(*DeviceConn); ok {
dc.Close(websocket.StatusGoingAway, "server shutdown")
}
return true
})
// Graceful HTTP shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if g.httpSrv != nil {
g.httpSrv.Shutdown(shutdownCtx)
}
g.wg.Wait()
log.Printf("[cdap] Gateway stopped (total connections served: %d)", g.totalConns.Load())
}
// ActiveConnections returns the current number of active CDAP connections.
func (g *Gateway) ActiveConnections() int64 {
return g.activeConns.Load()
}
// handleHealth serves the /cdap/health endpoint for monitoring.
func (g *Gateway) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"connections": g.activeConns.Load(),
"total": g.totalConns.Load(),
"version": g.version,
})
}
// handleWebSocket upgrades the HTTP connection to WebSocket and runs the
// CDAP protocol state machine.
func (g *Gateway) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// Extract client IP for rate limiting and logging
clientIP := extractIP(r)
// Rate limit
if g.limiter != nil && !g.limiter.Allow(clientIP) {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
// Blocklist check
if g.blocklist != nil {
if g.blocklist.IsIPBlocked(clientIP) {
http.Error(w, "blocked", http.StatusForbidden)
return
}
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"cdap-v1"},
})
if err != nil {
log.Printf("[cdap] WebSocket upgrade failed from %s: %v", clientIP, err)
return
}
g.totalConns.Add(1)
g.activeConns.Add(1)
defer g.activeConns.Add(-1)
// Run the connection state machine
g.runConnection(r.Context(), conn, clientIP)
}
// runConnection drives the CDAP protocol:
//
// → auth → auth_result
// → register (with manifest) → registered
// → heartbeat / state_update / command_response / ...
func (g *Gateway) runConnection(baseCtx context.Context, conn *websocket.Conn, clientIP string) {
// Create a context bound to both the HTTP request and our gateway's lifecycle
ctx, cancel := context.WithCancel(baseCtx)
defer cancel()
go func() {
select {
case <-g.ctx.Done():
cancel()
case <-ctx.Done():
}
}()
// Phase 1: Authentication (30-second deadline)
authCtx, authCancel := context.WithTimeout(ctx, 30*time.Second)
dc, authErr := g.handleAuth(authCtx, conn, clientIP)
authCancel()
if authErr != nil {
sendError(ctx, conn, 1001, authErr.Error())
conn.Close(websocket.StatusPolicyViolation, "auth failed")
return
}
defer func() {
g.removeDevice(dc)
conn.Close(websocket.StatusNormalClosure, "")
}()
// Phase 2: Registration (30-second deadline)
regCtx, regCancel := context.WithTimeout(ctx, 30*time.Second)
regErr := g.handleRegister(regCtx, dc)
regCancel()
if regErr != nil {
sendError(ctx, conn, 2001, regErr.Error())
return
}
// Phase 3: Main message loop
g.messageLoop(ctx, dc)
}
// messageLoop reads messages until the connection closes or context is cancelled.
func (g *Gateway) messageLoop(ctx context.Context, dc *DeviceConn) {
for {
msg, err := dc.ReadMessage(ctx)
if err != nil {
if ctx.Err() == nil {
log.Printf("[cdap] %s: read error: %v", dc.ID, err)
}
return
}
switch msg.Type {
case "heartbeat":
g.handleHeartbeat(ctx, dc, msg)
case "state_update":
g.handleStateUpdate(ctx, dc, msg)
case "bulk_update":
g.handleBulkUpdate(ctx, dc, msg)
case "command_response":
g.handleCommandResponse(ctx, dc, msg)
case "event":
g.handleEvent(ctx, dc, msg)
case "log":
g.handleLog(ctx, dc, msg)
case "unregister":
g.handleUnregister(ctx, dc, msg)
return
case "token_refresh":
g.handleTokenRefresh(ctx, dc, msg)
default:
sendError(ctx, dc.conn, 1006, fmt.Sprintf("unknown message type: %s", msg.Type))
}
}
}
// heartbeatMonitor periodically checks for stale CDAP connections.
func (g *Gateway) heartbeatMonitor() {
defer g.wg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-g.ctx.Done():
return
case <-ticker.C:
now := time.Now()
g.devices.Range(func(key, value any) bool {
dc, ok := value.(*DeviceConn)
if !ok {
return true
}
// 3x heartbeat interval = stale
maxIdle := time.Duration(dc.HeartbeatInterval) * time.Second * 3
if maxIdle < 60*time.Second {
maxIdle = 60 * time.Second
}
if now.Sub(dc.LastHeartbeat) > maxIdle {
log.Printf("[cdap] %s: heartbeat timeout (last: %s ago)", dc.ID, now.Sub(dc.LastHeartbeat).Round(time.Second))
dc.Close(websocket.StatusPolicyViolation, "heartbeat timeout")
g.removeDevice(dc)
}
return true
})
}
}
}
// removeDevice cleans up a device connection from the registry.
func (g *Gateway) removeDevice(dc *DeviceConn) {
if dc == nil || dc.ID == "" {
return
}
g.devices.Delete(dc.ID)
// Update peer status to OFFLINE
if err := g.db.UpdatePeerStatus(dc.ID, "OFFLINE", dc.ClientIP); err != nil {
log.Printf("[cdap] %s: failed to set offline: %v", dc.ID, err)
}
// Publish disconnect event
if g.eventBus != nil {
g.eventBus.Publish(events.Event{
Type: "cdap_disconnect",
Data: map[string]string{
"peer_id": dc.ID,
"reason": "disconnected",
},
})
}
log.Printf("[cdap] %s: disconnected (session: %s)", dc.ID, time.Since(dc.ConnectedAt).Round(time.Second))
}
// SendCommand sends a command to a connected CDAP device.
// Returns error if the device is not connected.
func (g *Gateway) SendCommand(ctx context.Context, deviceID string, cmd *CommandMessage) error {
val, ok := g.devices.Load(deviceID)
if !ok {
return fmt.Errorf("device %s not connected", deviceID)
}
dc := val.(*DeviceConn)
dc.CommandCount.Add(1)
return dc.WriteMessage(ctx, &Message{
Type: "command",
ID: cmd.ID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: cmd.Payload,
})
}
// GetDeviceConn returns the connection for a device, or nil if not connected.
func (g *Gateway) GetDeviceConn(deviceID string) *DeviceConn {
val, ok := g.devices.Load(deviceID)
if !ok {
return nil
}
return val.(*DeviceConn)
}
// extractIP extracts the client IP from the request, respecting X-Forwarded-For
// only when trust-proxy is configured (handled upstream in the HTTP handler).
func extractIP(r *http.Request) string {
// Try X-Real-IP first (set by nginx)
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
// Try X-Forwarded-For
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.SplitN(xff, ",", 2)
return strings.TrimSpace(parts[0])
}
// Fall back to remote addr
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
+356
View File
@@ -0,0 +1,356 @@
package cdap
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"github.com/unitronix/betterdesk-server/db"
"github.com/unitronix/betterdesk-server/events"
)
// handleRegister processes the "register" message after authentication.
// It parses the manifest, creates/updates the peer in the database, and
// registers the device connection in the gateway's in-memory map.
func (g *Gateway) handleRegister(ctx context.Context, dc *DeviceConn) error {
msg, err := dc.ReadMessage(ctx)
if err != nil {
return fmt.Errorf("read register message: %w", err)
}
if msg.Type != "register" {
return fmt.Errorf("expected 'register' message, got '%s'", msg.Type)
}
var rp RegisterPayload
if err := json.Unmarshal(msg.Payload, &rp); err != nil {
return fmt.Errorf("invalid register payload: %w", err)
}
if rp.Manifest == nil {
return fmt.Errorf("manifest is required")
}
// Validate manifest
if err := ValidateManifest(rp.Manifest); err != nil {
return fmt.Errorf("invalid manifest: %w", err)
}
dc.Manifest = rp.Manifest
dc.HeartbeatInterval = rp.Manifest.HeartbeatInterval
// Validate device ID format (CDAP-XXXXXXXX or 6-16 alphanumeric)
if dc.ID == "" {
return fmt.Errorf("device_id is required (set in auth payload)")
}
// Check if device is banned
banned, _ := g.db.IsPeerBanned(dc.ID)
if banned {
return fmt.Errorf("device is banned")
}
// Check if device is soft-deleted
deleted, _ := g.db.IsPeerSoftDeleted(dc.ID)
if deleted {
return fmt.Errorf("device has been deleted")
}
// Upsert the peer in the database
tags := strings.Join(rp.Manifest.Device.Tags, ",")
peer := &db.Peer{
ID: dc.ID,
Hostname: rp.Manifest.Device.Name,
Status: "ONLINE",
IP: dc.ClientIP,
DeviceType: rp.Manifest.Device.Type,
Tags: tags,
User: dc.Username,
LastOnline: time.Now(),
OS: rp.Manifest.Bridge.Protocol,
Version: rp.Manifest.Bridge.Version,
}
if err := g.db.UpsertPeer(peer); err != nil {
return fmt.Errorf("save peer: %w", err)
}
// Store manifest JSON in config (device-specific key)
manifestJSON, _ := json.Marshal(rp.Manifest)
g.db.SetConfig(fmt.Sprintf("cdap_manifest_%s", dc.ID), string(manifestJSON))
// Check for existing connection with same ID (force disconnect old)
if old, loaded := g.devices.LoadAndDelete(dc.ID); loaded {
if oldDC, ok := old.(*DeviceConn); ok {
log.Printf("[cdap] %s: replacing existing connection from %s", dc.ID, oldDC.ClientIP)
oldDC.Close(4001, "replaced by new connection")
}
}
// Register in gateway's device map
g.devices.Store(dc.ID, dc)
// Update peer status to ONLINE
g.db.UpdatePeerStatus(dc.ID, "ONLINE", dc.ClientIP)
// Send registration confirmation
result := map[string]any{
"device_id": dc.ID,
"server_time": time.Now().UTC().Format(time.RFC3339),
}
if err := sendMessage(ctx, dc.conn, "registered", result); err != nil {
return fmt.Errorf("send registered: %w", err)
}
// Publish connect event
if g.eventBus != nil {
g.eventBus.Publish(events.Event{
Type: "cdap_connect",
Data: map[string]string{
"peer_id": dc.ID,
"device_type": rp.Manifest.Device.Type,
"device_name": rp.Manifest.Device.Name,
"username": dc.Username,
},
})
}
g.auditAction("cdap_register", dc.ID, map[string]string{
"device_name": rp.Manifest.Device.Name,
"device_type": rp.Manifest.Device.Type,
"widgets": fmt.Sprintf("%d", len(rp.Manifest.Widgets)),
"ip": dc.ClientIP,
})
log.Printf("[cdap] %s: registered (type=%s, name=%s, widgets=%d, heartbeat=%ds)",
dc.ID, rp.Manifest.Device.Type, rp.Manifest.Device.Name,
len(rp.Manifest.Widgets), rp.Manifest.HeartbeatInterval)
return nil
}
// handleHeartbeat processes periodic heartbeat messages.
func (g *Gateway) handleHeartbeat(ctx context.Context, dc *DeviceConn, msg *Message) {
dc.LastHeartbeat = time.Now()
dc.HeartbeatCount.Add(1)
var payload HeartbeatPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
sendError(ctx, dc.conn, 3001, "invalid heartbeat payload")
return
}
// Save system metrics if provided
if payload.Metrics != nil {
m := payload.Metrics
if m.CPU > 0 || m.Memory > 0 || m.Disk > 0 {
g.db.SavePeerMetric(dc.ID, m.CPU, m.Memory, m.Disk)
}
}
// Update widget values
if payload.WidgetValues != nil {
for widgetID, value := range payload.WidgetValues {
dc.widgetState.Store(widgetID, value)
}
// Publish widget state update event
if g.eventBus != nil {
valuesJSON, _ := json.Marshal(payload.WidgetValues)
g.eventBus.Publish(events.Event{
Type: "cdap_widget_update",
Data: map[string]string{
"peer_id": dc.ID,
"values": string(valuesJSON),
},
})
}
}
// Keep peer ONLINE
g.db.UpdatePeerStatus(dc.ID, "ONLINE", dc.ClientIP)
// Respond with server ping
sendMessage(ctx, dc.conn, "ping", map[string]any{
"server_time": time.Now().UTC().Format(time.RFC3339),
})
}
// handleStateUpdate processes a single widget state update.
func (g *Gateway) handleStateUpdate(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload StateUpdatePayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
sendError(ctx, dc.conn, 3002, "invalid state_update payload")
return
}
if payload.WidgetID == "" {
sendError(ctx, dc.conn, 3003, "widget_id is required")
return
}
// Update cached state
dc.widgetState.Store(payload.WidgetID, payload.Value)
// Publish to event bus for real-time panel updates
if g.eventBus != nil {
valueJSON, _ := json.Marshal(payload.Value)
g.eventBus.Publish(events.Event{
Type: "cdap_state_update",
Data: map[string]string{
"peer_id": dc.ID,
"widget_id": payload.WidgetID,
"value": string(valueJSON),
},
})
}
}
// handleBulkUpdate processes multiple widget state updates at once.
func (g *Gateway) handleBulkUpdate(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload BulkUpdatePayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
sendError(ctx, dc.conn, 3004, "invalid bulk_update payload")
return
}
updates := make(map[string]any, len(payload.Updates))
for _, u := range payload.Updates {
if u.WidgetID != "" {
dc.widgetState.Store(u.WidgetID, u.Value)
updates[u.WidgetID] = u.Value
}
}
if g.eventBus != nil && len(updates) > 0 {
valuesJSON, _ := json.Marshal(updates)
g.eventBus.Publish(events.Event{
Type: "cdap_widget_update",
Data: map[string]string{
"peer_id": dc.ID,
"values": string(valuesJSON),
},
})
}
}
// handleCommandResponse processes a device's response to a command.
func (g *Gateway) handleCommandResponse(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload CommandResponsePayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
sendError(ctx, dc.conn, 3005, "invalid command_response payload")
return
}
// Publish to event bus so the panel can display the result
if g.eventBus != nil {
resultJSON, _ := json.Marshal(payload)
g.eventBus.Publish(events.Event{
Type: "cdap_command_response",
Data: map[string]string{
"peer_id": dc.ID,
"command_id": payload.CommandID,
"status": payload.Status,
"result": string(resultJSON),
},
})
}
g.auditAction("cdap_command_response", dc.ID, map[string]string{
"command_id": payload.CommandID,
"status": payload.Status,
"ip": dc.ClientIP,
})
}
// handleEvent processes custom events from the device.
func (g *Gateway) handleEvent(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload EventPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
sendError(ctx, dc.conn, 3006, "invalid event payload")
return
}
if g.eventBus != nil {
dataJSON, _ := json.Marshal(payload.Data)
g.eventBus.Publish(events.Event{
Type: "cdap_event",
Data: map[string]string{
"peer_id": dc.ID,
"event_type": payload.EventType,
"data": string(dataJSON),
},
})
}
}
// handleLog processes log entries from the device.
func (g *Gateway) handleLog(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload LogPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
sendError(ctx, dc.conn, 3007, "invalid log payload")
return
}
// Log at appropriate level
level := strings.ToLower(payload.Level)
if level == "error" || level == "critical" {
log.Printf("[cdap] %s [%s]: %s", dc.ID, level, payload.Message)
}
// Publish to event bus
if g.eventBus != nil {
g.eventBus.Publish(events.Event{
Type: "cdap_log",
Data: map[string]string{
"peer_id": dc.ID,
"level": payload.Level,
"message": payload.Message,
},
})
}
}
// handleUnregister processes a graceful disconnect from the device.
func (g *Gateway) handleUnregister(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload UnregisterPayload
json.Unmarshal(msg.Payload, &payload) // best-effort parse
log.Printf("[cdap] %s: unregistered (reason: %s)", dc.ID, payload.Reason)
g.auditAction("cdap_unregister", dc.ID, map[string]string{
"reason": payload.Reason,
"ip": dc.ClientIP,
})
}
// handleTokenRefresh refreshes the device's JWT token.
func (g *Gateway) handleTokenRefresh(ctx context.Context, dc *DeviceConn, msg *Message) {
var payload TokenRefreshPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
sendError(ctx, dc.conn, 3008, "invalid token_refresh payload")
return
}
// Validate the existing token
claims, err := g.jwt.Validate(dc.Token)
if err != nil {
sendError(ctx, dc.conn, 1003, "current token invalid")
return
}
// Generate new token
newToken, err := g.jwt.Generate(claims.Sub, dc.Role)
if err != nil {
sendError(ctx, dc.conn, 5001, "failed to generate token")
return
}
dc.Token = newToken
dc.TokenExpiry = time.Now().Add(g.jwt.Expiry())
sendMessage(ctx, dc.conn, "token_refreshed", map[string]any{
"token": newToken,
"expires_at": dc.TokenExpiry.UTC().Format(time.RFC3339),
})
}
+272
View File
@@ -0,0 +1,272 @@
package cdap
import (
"encoding/json"
"fmt"
"strings"
)
// Manifest describes a CDAP device's capabilities, identity, and widget definitions.
type Manifest struct {
ManifestVersion string `json:"manifest_version"` // "1.0"
Device ManifestDevice `json:"device"`
Bridge ManifestBridge `json:"bridge,omitempty"`
Capabilities []string `json:"capabilities"` // telemetry, commands, alerts, logs, ...
HeartbeatInterval int `json:"heartbeat_interval"` // seconds (default 15, max 300)
Widgets []Widget `json:"widgets,omitempty"`
Alerts []AlertDef `json:"alerts,omitempty"`
}
// ManifestDevice describes the physical/virtual device identity.
type ManifestDevice struct {
Name string `json:"name"`
Type string `json:"type"` // scada, iot, os_agent, network, camera, desktop, custom
Vendor string `json:"vendor,omitempty"`
Model string `json:"model,omitempty"`
Firmware string `json:"firmware,omitempty"`
Serial string `json:"serial,omitempty"`
Location string `json:"location,omitempty"`
Tags []string `json:"tags,omitempty"`
Icon string `json:"icon,omitempty"`
Description string `json:"description,omitempty"`
}
// ManifestBridge describes the bridge software connecting the device to CDAP.
type ManifestBridge struct {
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
Protocol string `json:"protocol,omitempty"`
TargetHost string `json:"target_host,omitempty"`
TargetPort int `json:"target_port,omitempty"`
}
// Widget represents a single control/display element on the device.
type Widget struct {
Type string `json:"type"` // toggle, gauge, button, led, chart, select, slider, text, table, terminal, desktop, video_stream, file_browser
ID string `json:"id"`
Label string `json:"label"`
Group string `json:"group,omitempty"` // collapsible group name
Value any `json:"value,omitempty"` // initial value
Readonly bool `json:"readonly,omitempty"`
// Gauge/Slider fields
Unit string `json:"unit,omitempty"`
Min float64 `json:"min,omitempty"`
Max float64 `json:"max,omitempty"`
Step float64 `json:"step,omitempty"`
Precision int `json:"precision,omitempty"`
WarningLow float64 `json:"warning_low,omitempty"`
WarningHigh float64 `json:"warning_high,omitempty"`
// Button fields
Confirm bool `json:"confirm,omitempty"`
ConfirmMessage string `json:"confirm_message,omitempty"`
Style string `json:"style,omitempty"` // primary, danger, etc.
Icon string `json:"icon,omitempty"`
Cooldown int `json:"cooldown,omitempty"` // seconds
// Select fields
Options []WidgetOption `json:"options,omitempty"`
// Chart fields
ChartType string `json:"chart_type,omitempty"` // line, bar, area
Points int `json:"points,omitempty"`
Series []ChartSeries `json:"series,omitempty"`
Retention string `json:"retention,omitempty"` // 24h, 7d, etc.
// Table fields
Columns []TableColumn `json:"columns,omitempty"`
MaxRows int `json:"max_rows,omitempty"`
Sortable bool `json:"sortable,omitempty"`
}
// WidgetOption for select widgets.
type WidgetOption struct {
Label string `json:"label"`
Value any `json:"value"`
}
// ChartSeries for chart widgets.
type ChartSeries struct {
ID string `json:"id"`
Label string `json:"label"`
Color string `json:"color,omitempty"`
Unit string `json:"unit,omitempty"`
}
// TableColumn for table widgets.
type TableColumn struct {
ID string `json:"id"`
Label string `json:"label"`
Type string `json:"type,omitempty"` // string, number, boolean, date
}
// AlertDef defines a threshold-based alert.
type AlertDef struct {
ID string `json:"id"`
Label string `json:"label"`
Severity string `json:"severity"` // critical, warning, info
Condition string `json:"condition"` // expression string
Message string `json:"message"`
}
// Allowed device types.
var allowedDeviceTypes = map[string]bool{
"scada": true,
"iot": true,
"os_agent": true,
"network": true,
"camera": true,
"desktop": true,
"custom": true,
}
// Allowed widget types.
var allowedWidgetTypes = map[string]bool{
"toggle": true,
"gauge": true,
"button": true,
"led": true,
"chart": true,
"select": true,
"slider": true,
"text": true,
"table": true,
"terminal": true,
"desktop": true,
"video_stream": true,
"file_browser": true,
}
// Allowed capabilities.
var allowedCapabilities = map[string]bool{
"telemetry": true,
"commands": true,
"alerts": true,
"logs": true,
"remote_desktop": true,
"video_stream": true,
"audio": true,
"clipboard": true,
"file_transfer": true,
"input_control": true,
}
// maxWidgets is the hard limit on widget count per device.
const maxWidgets = 200
// ParseManifest parses and validates a CDAP device manifest from raw JSON.
func ParseManifest(data json.RawMessage) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("parse manifest: %w", err)
}
if err := ValidateManifest(&m); err != nil {
return nil, err
}
return &m, nil
}
// ValidateManifest checks that a manifest is well-formed.
func ValidateManifest(m *Manifest) error {
if m.ManifestVersion == "" {
m.ManifestVersion = "1.0"
}
if m.ManifestVersion != "1.0" {
return fmt.Errorf("unsupported manifest version: %s", m.ManifestVersion)
}
// Device name required
m.Device.Name = strings.TrimSpace(m.Device.Name)
if m.Device.Name == "" {
return fmt.Errorf("device name is required")
}
if len(m.Device.Name) > 128 {
return fmt.Errorf("device name too long (max 128 chars)")
}
// Device type validation
m.Device.Type = strings.ToLower(strings.TrimSpace(m.Device.Type))
if m.Device.Type == "" {
m.Device.Type = "custom"
}
if !allowedDeviceTypes[m.Device.Type] {
return fmt.Errorf("invalid device type: %s", m.Device.Type)
}
// Heartbeat interval bounds
if m.HeartbeatInterval <= 0 {
m.HeartbeatInterval = 15
}
if m.HeartbeatInterval > 300 {
m.HeartbeatInterval = 300
}
// Capabilities validation
for _, cap := range m.Capabilities {
if !allowedCapabilities[strings.ToLower(cap)] {
return fmt.Errorf("unknown capability: %s", cap)
}
}
// Widget validation
if len(m.Widgets) > maxWidgets {
return fmt.Errorf("too many widgets (%d, max %d)", len(m.Widgets), maxWidgets)
}
widgetIDs := make(map[string]bool, len(m.Widgets))
for i := range m.Widgets {
w := &m.Widgets[i]
w.Type = strings.ToLower(strings.TrimSpace(w.Type))
if !allowedWidgetTypes[w.Type] {
return fmt.Errorf("widget %d: invalid type: %s", i, w.Type)
}
w.ID = strings.TrimSpace(w.ID)
if w.ID == "" {
return fmt.Errorf("widget %d: id is required", i)
}
if len(w.ID) > 64 {
return fmt.Errorf("widget %d: id too long (max 64 chars)", i)
}
if widgetIDs[w.ID] {
return fmt.Errorf("widget %d: duplicate id: %s", i, w.ID)
}
widgetIDs[w.ID] = true
w.Label = strings.TrimSpace(w.Label)
if w.Label == "" {
w.Label = w.ID
}
}
// Alert validation
alertIDs := make(map[string]bool, len(m.Alerts))
for i := range m.Alerts {
a := &m.Alerts[i]
a.ID = strings.TrimSpace(a.ID)
if a.ID == "" {
return fmt.Errorf("alert %d: id is required", i)
}
if alertIDs[a.ID] {
return fmt.Errorf("alert %d: duplicate id: %s", i, a.ID)
}
alertIDs[a.ID] = true
a.Severity = strings.ToLower(strings.TrimSpace(a.Severity))
if a.Severity != "critical" && a.Severity != "warning" && a.Severity != "info" {
return fmt.Errorf("alert %d: invalid severity: %s", i, a.Severity)
}
}
// Tags: max 20 tags, max 64 chars each
if len(m.Device.Tags) > 20 {
return fmt.Errorf("too many device tags (%d, max 20)", len(m.Device.Tags))
}
for i, tag := range m.Device.Tags {
if len(tag) > 64 {
return fmt.Errorf("device tag %d too long (max 64 chars)", i)
}
}
return nil
}
+224
View File
@@ -0,0 +1,224 @@
package cdap
import (
"context"
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// Message is the top-level CDAP protocol envelope.
type Message struct {
Type string `json:"type"`
ID string `json:"id,omitempty"` // command correlation ID
Timestamp string `json:"timestamp,omitempty"` // ISO-8601
Payload json.RawMessage `json:"payload"`
}
// AuthPayload is sent by client in the "auth" message.
type AuthPayload struct {
Method string `json:"method"` // user_password, api_key, device_token
Username string `json:"username,omitempty"` // for user_password
Password string `json:"password,omitempty"` // for user_password
TOTPCode string `json:"totp_code,omitempty"` // optional 2FA code
Key string `json:"key,omitempty"` // for api_key
Token string `json:"token,omitempty"` // for device_token
DeviceID string `json:"device_id,omitempty"` // requested device ID
ClientVersion string `json:"client_version,omitempty"`
}
// AuthResult is sent by server in the "auth_result" message.
type AuthResult struct {
Success bool `json:"success"`
Token string `json:"token,omitempty"` // JWT 24h
Role string `json:"role,omitempty"`
DeviceID string `json:"device_id,omitempty"`
SessionToken string `json:"session_token,omitempty"`
Requires2FA bool `json:"requires_2fa,omitempty"`
TFAType string `json:"tfa_type,omitempty"`
PartialToken string `json:"partial_token,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterPayload is sent by client in the "register" message.
type RegisterPayload struct {
Manifest *Manifest `json:"manifest"`
}
// HeartbeatPayload is sent by client in the "heartbeat" message.
type HeartbeatPayload struct {
Metrics *MetricsData `json:"metrics,omitempty"`
WidgetValues map[string]any `json:"widget_values,omitempty"`
}
// MetricsData holds standard system metrics.
type MetricsData struct {
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Disk float64 `json:"disk"`
}
// StateUpdatePayload is sent by client in the "state_update" message.
type StateUpdatePayload struct {
WidgetID string `json:"widget_id"`
Value any `json:"value"`
Timestamp string `json:"timestamp,omitempty"`
}
// BulkUpdatePayload is sent by client in the "bulk_update" message.
type BulkUpdatePayload struct {
Updates []StateUpdatePayload `json:"updates"`
}
// CommandPayload is sent by server in the "command" message.
type CommandPayload struct {
CommandID string `json:"command_id"`
WidgetID string `json:"widget_id"`
Action string `json:"action"` // set, trigger, execute, reset, query
Value any `json:"value,omitempty"`
Operator string `json:"operator,omitempty"`
Reason string `json:"reason,omitempty"`
}
// CommandMessage wraps a command to be sent to a device.
type CommandMessage struct {
ID string `json:"id"`
Payload json.RawMessage `json:"payload"`
}
// CommandResponsePayload is sent by client in response to a command.
type CommandResponsePayload struct {
CommandID string `json:"command_id"`
Status string `json:"status"` // ok, error, timeout, rejected, queued, unauthorized
ExecTimeMs int `json:"execution_time_ms,omitempty"`
Result any `json:"result,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// EventPayload is sent by client for custom events.
type EventPayload struct {
EventType string `json:"event_type"`
Data any `json:"data,omitempty"`
}
// LogPayload is sent by client for log entries.
type LogPayload struct {
Level string `json:"level"` // debug, info, warning, error, critical
Message string `json:"message"`
Context any `json:"context,omitempty"`
}
// UnregisterPayload is sent by client to disconnect.
type UnregisterPayload struct {
Reason string `json:"reason,omitempty"`
}
// TokenRefreshPayload is sent by client to refresh JWT.
type TokenRefreshPayload struct {
Token string `json:"token"`
}
// ErrorPayload is sent by server for protocol errors.
type ErrorPayload struct {
Code int `json:"code"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
// DeviceConn represents an authenticated CDAP device connection.
type DeviceConn struct {
ID string // Device ID (CDAP-XXXXXXXX or custom)
Username string // Authenticated user
Role string // admin, operator, viewer
ClientIP string
conn *websocket.Conn
mu sync.Mutex // serialise writes
// Device metadata
Manifest *Manifest
HeartbeatInterval int // seconds (from manifest or default 15)
// Session
Token string
TokenExpiry time.Time
SessionID string
// Timestamps
ConnectedAt time.Time
LastHeartbeat time.Time
// Counters
HeartbeatCount atomic.Int64
CommandCount atomic.Int64
// Widget state cache (widget_id → last value)
widgetState sync.Map // map[string]any
}
// ReadMessage reads and decodes the next CDAP JSON message from the WebSocket.
func (dc *DeviceConn) ReadMessage(ctx context.Context) (*Message, error) {
typ, data, err := dc.conn.Read(ctx)
if err != nil {
return nil, err
}
if typ != websocket.MessageText {
return nil, fmt.Errorf("expected text frame, got %v", typ)
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
return &msg, nil
}
// WriteMessage encodes and sends a CDAP JSON message on the WebSocket.
func (dc *DeviceConn) WriteMessage(ctx context.Context, msg *Message) error {
data, err := json.Marshal(msg)
if err != nil {
return err
}
dc.mu.Lock()
defer dc.mu.Unlock()
return dc.conn.Write(ctx, websocket.MessageText, data)
}
// Close closes the underlying WebSocket connection.
func (dc *DeviceConn) Close(code websocket.StatusCode, reason string) {
dc.conn.Close(code, reason)
}
// sendError sends a protocol error message to the client.
func sendError(ctx context.Context, conn *websocket.Conn, code int, message string) {
payload, _ := json.Marshal(ErrorPayload{Code: code, Message: message})
msg := Message{
Type: "error",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
}
data, _ := json.Marshal(msg)
conn.Write(ctx, websocket.MessageText, data)
}
// sendMessage is a convenience for sending typed payloads to a connection.
func sendMessage(ctx context.Context, conn *websocket.Conn, msgType string, payload any) error {
payloadData, err := json.Marshal(payload)
if err != nil {
return err
}
msg := Message{
Type: msgType,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: payloadData,
}
data, err := json.Marshal(msg)
if err != nil {
return err
}
return conn.Write(ctx, websocket.MessageText, data)
}
+22
View File
@@ -83,6 +83,11 @@ type Config struct {
// "managed" - New devices need to be approved or have a valid token
// "locked" - Only devices with valid tokens can register
EnrollmentMode string
// CDAP Gateway
CDAPPort int // WebSocket gateway port (default 21122)
CDAPEnabled bool // Enable CDAP gateway (default false)
CDAPTLS bool // Enable TLS on CDAP port
}
// DefaultConfig returns a Config with sensible defaults.
@@ -97,6 +102,7 @@ func DefaultConfig() *Config {
JWTExpiry: 24,
RelayMaxConnsIP: 20,
EnrollmentMode: EnrollmentModeOpen, // Backward compatible default
CDAPPort: 21122,
}
}
@@ -215,6 +221,17 @@ func (c *Config) LoadEnv() {
c.EnrollmentMode = mode
}
}
if v := os.Getenv("CDAP_PORT"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
c.CDAPPort = n
}
}
if strings.ToUpper(os.Getenv("CDAP_ENABLED")) == "Y" {
c.CDAPEnabled = true
}
if strings.ToUpper(os.Getenv("CDAP_TLS")) == "Y" {
c.CDAPTLS = true
}
}
// NATTestPort returns the NAT test port (signal port - 1).
@@ -327,3 +344,8 @@ func (c *Config) RelayTLSEnabled() bool {
func (c *Config) APITLSEnabled() bool {
return (c.TLSApi || c.ForceHTTPS) && c.HasTLSCert()
}
// CDAPTLSEnabled returns true if TLS should be used for the CDAP WebSocket gateway.
func (c *Config) CDAPTLSEnabled() bool {
return c.CDAPTLS && c.HasTLSCert()
}
+6 -1
View File
@@ -26,7 +26,9 @@ type Peer struct {
DeletedAt *time.Time `json:"deleted_at,omitempty"`
Note string `json:"note,omitempty"`
Tags string `json:"tags,omitempty"`
HeartbeatSeq int64 `json:"-"` // internal heartbeat counter
DeviceType string `json:"device_type,omitempty"` // CDAP: desktop, mobile, headless, kiosk, etc.
LinkedPeerID string `json:"linked_peer_id,omitempty"` // CDAP: paired device (e.g., mobile→desktop)
HeartbeatSeq int64 `json:"-"` // internal heartbeat counter
}
// ServerConfig stores runtime configuration in the database.
@@ -139,6 +141,9 @@ type Database interface {
ChangePeerID(oldID, newID string) error
GetIDChangeHistory(id string) ([]*IDChangeHistory, error)
// CDAP: linked device queries
GetLinkedPeers(id string) ([]*Peer, error)
// Tags
UpdatePeerTags(id, tags string) error
ListPeersByTag(tag string) ([]*Peer, error)
+36 -6
View File
@@ -195,6 +195,9 @@ func (pg *PostgresDB) Migrate() error {
`ALTER TABLE peers ADD COLUMN IF NOT EXISTS tags TEXT NOT NULL DEFAULT ''`,
// peers: heartbeat_seq (added in v2.3.0)
`ALTER TABLE peers ADD COLUMN IF NOT EXISTS heartbeat_seq BIGINT NOT NULL DEFAULT 0`,
// peers: CDAP device type and linked peer (added in v2.5.0)
`ALTER TABLE peers ADD COLUMN IF NOT EXISTS device_type TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE peers ADD COLUMN IF NOT EXISTS linked_peer_id TEXT NOT NULL DEFAULT ''`,
}
for _, ddl := range columnMigrations {
@@ -212,7 +215,8 @@ func (pg *PostgresDB) Migrate() error {
const peerColumns = `id, uuid, pk, ip, "user", hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq`
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id`
// scanPeer scans a row into a Peer struct using nullable types.
func scanPeer(row pgx.Row) (*Peer, error) {
@@ -225,6 +229,7 @@ func scanPeer(row pgx.Row) (*Peer, error) {
&lastOnline, &p.CreatedAt, &p.Disabled, &p.Banned,
&p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq,
&p.DeviceType, &p.LinkedPeerID,
)
if err != nil {
return nil, err
@@ -418,11 +423,11 @@ func (pg *PostgresDB) IsPeerSoftDeleted(id string) (bool, error) {
return deleted, err
}
// UpdatePeerFields updates specific peer fields (note, user, tags).
// UpdatePeerFields updates specific peer fields (note, user, tags, device_type, linked_peer_id).
// Only provided keys are updated; others are left unchanged.
// Allowed keys: "note", "user", "tags".
// Allowed keys: "note", "user", "tags", "device_type", "linked_peer_id".
func (pg *PostgresDB) UpdatePeerFields(id string, fields map[string]string) error {
allowed := map[string]string{"note": "note", "user": `"user"`, "tags": "tags"}
allowed := map[string]string{"note": "note", "user": `"user"`, "tags": "tags", "device_type": "device_type", "linked_peer_id": "linked_peer_id"}
setClauses := []string{}
args := []interface{}{}
idx := 1
@@ -471,11 +476,13 @@ func (pg *PostgresDB) ChangePeerID(oldID, newID string) error {
INSERT INTO peers (id, uuid, pk, ip, "user", hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq)
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id)
SELECT $1, uuid, pk, ip, "user", hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id
FROM peers WHERE id = $2 FOR UPDATE`, newID, oldID)
if err != nil {
return fmt.Errorf("db: ChangePeerID insert: %w", err)
@@ -515,6 +522,29 @@ func (pg *PostgresDB) GetIDChangeHistory(id string) ([]*IDChangeHistory, error)
return history, rows.Err()
}
// GetLinkedPeers returns all non-deleted peers that have linked_peer_id matching the given ID.
func (pg *PostgresDB) GetLinkedPeers(id string) ([]*Peer, error) {
rows, err := pg.pool.Query(pg.ctx, `
SELECT `+peerColumns+`
FROM peers
WHERE soft_deleted = FALSE AND linked_peer_id = $1
ORDER BY id`, id)
if err != nil {
return nil, fmt.Errorf("db: GetLinkedPeers: %w", err)
}
defer rows.Close()
var peers []*Peer
for rows.Next() {
p, err := scanPeer(rows)
if err != nil {
return nil, fmt.Errorf("db: GetLinkedPeers scan: %w", err)
}
peers = append(peers, p)
}
return peers, rows.Err()
}
// ── Tags ──────────────────────────────────────────────────────────────
// UpdatePeerTags updates the tags field for a peer.
+60 -8
View File
@@ -181,6 +181,9 @@ func (s *SQLiteDB) Migrate() error {
{"peers", "tags", `ALTER TABLE peers ADD COLUMN tags TEXT DEFAULT ''`},
// peers: heartbeat_seq (added in v2.3.0)
{"peers", "heartbeat_seq", `ALTER TABLE peers ADD COLUMN heartbeat_seq INTEGER DEFAULT 0`},
// peers: CDAP device type and linked peer (added in v2.5.0)
{"peers", "device_type", `ALTER TABLE peers ADD COLUMN device_type TEXT DEFAULT ''`},
{"peers", "linked_peer_id", `ALTER TABLE peers ADD COLUMN linked_peer_id TEXT DEFAULT ''`},
}
for _, m := range columnMigrations {
@@ -259,13 +262,15 @@ func (s *SQLiteDB) GetPeer(id string) (*Peer, error) {
SELECT id, uuid, pk, ip, user, hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id
FROM peers WHERE id = ?`, id).Scan(
&p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname,
&p.OS, &p.Version, &p.Status, &p.NATType,
&lastOnline, &createdAt, &p.Disabled, &p.Banned,
&p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq,
&p.DeviceType, &p.LinkedPeerID,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -360,7 +365,8 @@ func (s *SQLiteDB) ListPeers(includeDeleted bool) ([]*Peer, error) {
query := `SELECT id, uuid, pk, ip, user, hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id
FROM peers`
if !includeDeleted {
query += ` WHERE soft_deleted = 0`
@@ -383,6 +389,7 @@ func (s *SQLiteDB) ListPeers(includeDeleted bool) ([]*Peer, error) {
&lastOnline, &createdAt, &p.Disabled, &p.Banned,
&p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq,
&p.DeviceType, &p.LinkedPeerID,
); err != nil {
return nil, fmt.Errorf("db: ListPeers scan: %w", err)
}
@@ -493,14 +500,14 @@ func (s *SQLiteDB) IsPeerSoftDeleted(id string) (bool, error) {
return deleted, err
}
// UpdatePeerFields updates specific peer fields (note, user, tags).
// UpdatePeerFields updates specific peer fields (note, user, tags, device_type, linked_peer_id).
// Only provided keys are updated; others are left unchanged.
// Allowed keys: "note", "user", "tags".
// Allowed keys: "note", "user", "tags", "device_type", "linked_peer_id".
func (s *SQLiteDB) UpdatePeerFields(id string, fields map[string]string) error {
s.mu.Lock()
defer s.mu.Unlock()
allowed := map[string]bool{"note": true, "user": true, "tags": true}
allowed := map[string]bool{"note": true, "user": true, "tags": true, "device_type": true, "linked_peer_id": true}
setClauses := []string{}
args := []interface{}{}
for k, v := range fields {
@@ -544,11 +551,13 @@ func (s *SQLiteDB) ChangePeerID(oldID, newID string) error {
INSERT INTO peers (id, uuid, pk, ip, user, hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq)
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id)
SELECT ?, uuid, pk, ip, user, hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id
FROM peers WHERE id = ?`, newID, oldID)
if err != nil {
return fmt.Errorf("db: ChangePeerID insert: %w", err)
@@ -595,6 +604,47 @@ func (s *SQLiteDB) GetIDChangeHistory(id string) ([]*IDChangeHistory, error) {
return history, rows.Err()
}
// GetLinkedPeers returns all non-deleted peers that have linked_peer_id matching the given ID.
func (s *SQLiteDB) GetLinkedPeers(id string) ([]*Peer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`
SELECT id, uuid, pk, ip, user, hostname, os, version, status, nat_type,
last_online, created_at, disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id
FROM peers
WHERE soft_deleted = 0 AND linked_peer_id = ?
ORDER BY id`, id)
if err != nil {
return nil, fmt.Errorf("db: GetLinkedPeers: %w", err)
}
defer rows.Close()
var peers []*Peer
for rows.Next() {
p := &Peer{}
var lastOnline, createdAt, bannedAt, deletedAt sql.NullString
if err := rows.Scan(
&p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname,
&p.OS, &p.Version, &p.Status, &p.NATType,
&lastOnline, &createdAt, &p.Disabled, &p.Banned,
&p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq,
&p.DeviceType, &p.LinkedPeerID,
); err != nil {
return nil, fmt.Errorf("db: GetLinkedPeers scan: %w", err)
}
p.LastOnline = parseTime(lastOnline)
p.CreatedAt = parseTime(createdAt)
p.BannedAt = parseTimePtr(bannedAt)
p.DeletedAt = parseTimePtr(deletedAt)
peers = append(peers, p)
}
return peers, rows.Err()
}
// GetConfig retrieves a configuration value by key.
func (s *SQLiteDB) GetConfig(key string) (string, error) {
s.mu.RLock()
@@ -657,7 +707,8 @@ func (s *SQLiteDB) ListPeersByTag(tag string) ([]*Peer, error) {
rows, err := s.db.Query(`
SELECT id, uuid, pk, ip, user, hostname, os, version, status, nat_type,
last_online, created_at, disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id
FROM peers
WHERE soft_deleted = 0 AND tags LIKE ? ESCAPE '\'
ORDER BY id`, pattern)
@@ -676,6 +727,7 @@ func (s *SQLiteDB) ListPeersByTag(tag string) ([]*Peer, error) {
&lastOnline, &createdAt, &p.Disabled, &p.Banned,
&p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq,
&p.DeviceType, &p.LinkedPeerID,
); err != nil {
return nil, fmt.Errorf("db: ListPeersByTag scan: %w", err)
}
+1
View File
@@ -21,6 +21,7 @@ const (
EventPeerBanned EventType = "peer_banned"
EventPeerUnbanned EventType = "peer_unbanned"
EventPeerDeleted EventType = "peer_deleted"
EventPeerRevoked EventType = "peer_revoked"
EventPeerIDChanged EventType = "peer_id_changed"
EventBlocklistAdd EventType = "blocklist_add"
EventBlocklistRemove EventType = "blocklist_remove"
+23
View File
@@ -20,6 +20,7 @@ import (
"github.com/unitronix/betterdesk-server/api"
"github.com/unitronix/betterdesk-server/audit"
"github.com/unitronix/betterdesk-server/auth"
"github.com/unitronix/betterdesk-server/cdap"
"github.com/unitronix/betterdesk-server/config"
"github.com/unitronix/betterdesk-server/crypto"
"github.com/unitronix/betterdesk-server/db"
@@ -275,11 +276,30 @@ func main() {
apiSrv.SetMetrics(mc)
apiSrv.SetJWTManager(jwtManager)
apiSrv.SetKeyPair(kp)
// CDAP Gateway (optional — custom device automation protocol)
var cdapGw *cdap.Gateway
if cfg.CDAPEnabled {
cdapGw = cdap.New(cfg, database, sig.PeerMap(), sig.EventBus())
cdapGw.SetBlocklist(blocklist)
cdapGw.SetAuditLogger(auditLogger)
cdapGw.SetJWTManager(jwtManager)
cdapGw.SetVersion(Version)
apiSrv.SetCDAPGateway(cdapGw)
}
if err := apiSrv.Start(ctx); err != nil {
log.Fatalf("Failed to start API server: %v", err)
}
defer apiSrv.Stop()
if cdapGw != nil {
if err := cdapGw.Start(ctx); err != nil {
log.Fatalf("Failed to start CDAP gateway: %v", err)
}
defer cdapGw.Stop()
}
adminSrv.SetPeerMap(sig.PeerMap())
if cfg.AdminPassword != "" {
adminSrv.SetAdminPassword(cfg.AdminPassword)
@@ -467,6 +487,9 @@ func parseFlags() *config.Config {
flag.BoolVar(&cfg.TLSSignal, "tls-signal", cfg.TLSSignal, "Enable TLS on signal TCP/WS ports (requires --tls-cert and --tls-key)")
flag.BoolVar(&cfg.TLSRelay, "tls-relay", cfg.TLSRelay, "Enable TLS on relay TCP/WS ports (requires --tls-cert and --tls-key)")
flag.BoolVar(&cfg.TLSApi, "tls-api", cfg.TLSApi, "Enable TLS on HTTP API port (requires --tls-cert and --tls-key)")
flag.IntVar(&cfg.CDAPPort, "cdap-port", cfg.CDAPPort, "CDAP WebSocket gateway port (default 21122)")
flag.BoolVar(&cfg.CDAPEnabled, "cdap", cfg.CDAPEnabled, "Enable CDAP gateway for custom devices")
flag.BoolVar(&cfg.CDAPTLS, "tls-cdap", cfg.CDAPTLS, "Enable TLS on CDAP gateway port (requires --tls-cert and --tls-key)")
showVersion := flag.Bool("version", false, "Show version and exit")
flag.Parse()
+19
View File
@@ -81,6 +81,22 @@ func (e *Entry) IsExpired(timeout time.Duration) bool {
return time.Since(e.LastReg) > timeout
}
// CloseConnections closes any open TCP or WebSocket connections held by this entry.
// Safe to call multiple times; ignores nil connections and close errors.
func (e *Entry) CloseConnections() {
if e.TCPConn != nil {
e.TCPConn.Close()
e.TCPConn = nil
}
if e.WSConn != nil {
// WSConn is interface{} — attempt to close if it implements io.Closer.
if closer, ok := e.WSConn.(interface{ Close() error }); ok {
closer.Close()
}
e.WSConn = nil
}
}
// ComputeStatus computes the current status tier based on missed heartbeats.
func (e *Entry) ComputeStatus(degradedThreshold, criticalThreshold int32) Status {
if e.MissedBeats >= criticalThreshold {
@@ -246,6 +262,7 @@ func (m *Map) UpdateHeartbeat(id string, addr *net.UDPAddr, serial int32) bool {
}
// Remove deletes a peer from the map. Returns the removed entry (nil if not found).
// Closes any open TCP/WS connections to force immediate disconnect.
func (m *Map) Remove(id string) *Entry {
m.mu.Lock()
defer m.mu.Unlock()
@@ -253,6 +270,7 @@ func (m *Map) Remove(id string) *Entry {
if !ok {
return nil
}
e.CloseConnections()
delete(m.entries, id)
return e
}
@@ -322,6 +340,7 @@ func (m *Map) CleanExpired(timeout time.Duration) []string {
for id, e := range m.entries {
if time.Since(e.LastReg) > timeout {
expired = append(expired, id)
e.CloseConnections()
delete(m.entries, id)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+831
View File
@@ -0,0 +1,831 @@
# CDAP Implementation Plan
> **Version**: 1.0.0
> **Status**: Draft
> **Created**: 2026-03-19
> **Depends on**: [CUSTOM_DEVICE_API.md](CUSTOM_DEVICE_API.md) v0.2.0
---
## Table of Contents
1. [Overview](#overview)
2. [Architecture Decisions](#architecture-decisions)
3. [Authentication & 2FA Integration](#authentication--2fa-integration)
4. [RustDesk Client Synchronization](#rustdesk-client-synchronization)
5. [Implementation Phases](#implementation-phases)
6. [File-Level Change Map](#file-level-change-map)
7. [Testing Strategy](#testing-strategy)
8. [Migration & Backward Compatibility](#migration--backward-compatibility)
9. [Risk Assessment](#risk-assessment)
---
## Overview
This document translates the CDAP specification (CUSTOM_DEVICE_API.md) into a concrete implementation plan covering:
- **CDAP Gateway** — WebSocket server on port 21122 in Go server
- **Auth integration** — CDAP clients authenticate via the same user/2FA system as the Node.js panel and RustDesk clients
- **RustDesk synchronization** — CDAP native desktop clients coexist with RustDesk clients in the same peer table, address books, connection history, and panel views
- **Media channel** — Binary frame relay for remote desktop, video, audio, input
- **Native BetterDesk client** — Desktop agent that combines remote desktop + OS management
### Core Principle
> **One identity, one authority.** A user account created in the Node.js panel works for panel login, RustDesk client login, AND CDAP native client login — same credentials, same 2FA, same RBAC.
---
## Architecture Decisions
### AD-1: Auth Source of Truth
**Decision**: The Go server is the auth source of truth for all protocol-level authentication (RustDesk clients, CDAP clients, API keys). The Node.js panel is the auth source of truth for panel UI sessions (cookie-based).
**Rationale**: RustDesk clients already authenticate via Go server's `/api/login` (port 21114). CDAP clients will use the same endpoint or a CDAP-specific auth message that routes to the same Go auth backend. This avoids auth duplication and ensures 2FA state is consistent.
```
┌──────────────────────────────────────────────────────┐
│ Authentication Flow │
│ │
│ Panel User → Node.js session (cookie) │
│ └─ 2FA check → Node.js auth.db (TOTP) │
│ │
│ RustDesk Client → Go server /api/login (JWT) │
│ └─ 2FA check → Go server TOTP (5-min partial) │
│ │
│ CDAP Client → Go server /ws/cdap auth msg (JWT) │
│ └─ 2FA check → Go server TOTP (same path) │
│ │
│ Panel ↔ Go → X-API-Key header (.api_key file) │
│ │
│ ──── Shared ───────────────────────────────────── │
│ Users table: Go server db (users) │
│ TOTP secrets: Go server db (users.totp_secret) │
│ Roles/RBAC: Go server db (users.role) │
│ API keys: Go server db (api_keys) │
│ Panel sessions: Node.js auth.db (separate, UI only) │
└──────────────────────────────────────────────────────┘
```
### AD-2: Dual Auth Database Convergence
**Current state**: Node.js panel has `auth.db` (users, sessions) and Go server has `db_v2.sqlite3` (users, api_keys). Both have user tables with potentially different passwords and TOTP states.
**Decision**: Phase 1 keeps dual DBs but syncs user credentials. Phase 3+ converges to Go server as sole user store, with Node.js panel delegating auth to Go server via REST API.
| Phase | Auth Model | User Store |
|-------|-----------|------------|
| Current | Dual (Node.js auth.db + Go users table) | Both, may diverge |
| Phase 1 CDAP | CDAP uses Go auth only | Go server primary for clients |
| Phase 3 | Node.js delegates to Go for user CRUD | Go server sole source |
| Long term | Single user store in Go, Node.js is pure UI | Go server |
### AD-3: CDAP Auth Message vs HTTP Login
**Decision**: CDAP clients authenticate **within the WebSocket connection** using a JSON `auth` message (not a separate HTTP `/api/login` call). The Go server validates credentials using the same `auth.VerifyPassword()` and `auth.ValidateTOTP()` functions.
**Rationale**:
- Single connection setup (no HTTP pre-auth + WS upgrade dance)
- Works behind restrictive firewalls that only allow WS on one port
- Consistent with the "one port, one protocol" CDAP philosophy
- Server can immediately associate the WS connection with the authenticated user
```json
// Step 1: Client sends auth message
{
"type": "auth",
"payload": {
"method": "user_password",
"username": "operator1",
"password": "...",
"device_id": "CDAP-A7F3B210",
"client_version": "1.0.0"
}
}
// Step 2a: Server responds (no 2FA)
{
"type": "auth_result",
"payload": {
"success": true,
"token": "jwt_24h",
"role": "operator",
"device_id": "CDAP-A7F3B210"
}
}
// Step 2b: Server responds (2FA required)
{
"type": "auth_result",
"payload": {
"success": false,
"requires_2fa": true,
"tfa_type": "totp",
"partial_token": "jwt_5min"
}
}
// Step 3: Client sends 2FA code
{
"type": "auth_2fa",
"payload": {
"partial_token": "jwt_5min",
"code": "123456"
}
}
// Step 4: Server responds with full auth
{
"type": "auth_result",
"payload": {
"success": true,
"token": "jwt_24h",
"role": "operator",
"device_id": "CDAP-A7F3B210"
}
}
// Alternative: API key auth (for unattended bridges/agents)
{
"type": "auth",
"payload": {
"method": "api_key",
"key": "...",
"device_id": "CDAP-A7F3B210"
}
}
```
### AD-4: RustDesk Client Synchronization Strategy
**Problem**: When a BetterDesk native client (CDAP) runs on the same machine as a RustDesk client, or replaces it, the following must be synchronized:
| Resource | RustDesk Source | CDAP Source | Sync Strategy |
|----------|-----------------|-------------|---------------|
| Device ID | Numeric (e.g., `1340238749`) | `CDAP-` prefix | **Map**: Go server maintains `cdap_peer_id``rustdesk_peer_id` |
| Address book | `/api/ab` (Go server) | Same `/api/ab` | **Shared**: Same user, same AB |
| Connection history | Audit log (conn events) | Audit log (conn events) | **Shared**: Same audit table |
| User account | Go `users` table | Same | **Shared**: Same credentials |
| Peers list | `peers` table | Same `peers` table | **Shared**: `device_type` column |
| Online status | Signal server in-memory map | CDAP gateway in-memory | **Merged**: Panel queries both |
| Tags/Groups | `peers.tags` | Same | **Shared**: Same field |
### AD-5: Native Client ↔ RustDesk Interoperability
**Scenario**: User A has BetterDesk native client. User B has RustDesk client. Can User A remote-control User B's machine?
**Decision**: **Not directly** — CDAP media channel is not compatible with RustDesk's protobuf signal/relay protocol. But the server can **bridge** the connection:
| From → To | Method | Latency | Status |
|-----------|--------|---------|--------|
| RustDesk → RustDesk | Native (signal/relay) | Low | ✅ Works now |
| CDAP → CDAP | Native (CDAP media) | Low | Phase 5 |
| CDAP → RustDesk | **Server-side protocol bridge** | Medium (+50ms) | Phase 6 |
| RustDesk → CDAP | Not needed (CDAP is superset) | — | Not planned |
The server-side protocol bridge (Phase 6) is complex and optional. The primary migration path is: install BetterDesk native client → device appears as both `rustdesk` AND `desktop` types → gradually deprecate RustDesk client on that machine.
### AD-6: Unified Device Identity
When a machine has both RustDesk and BetterDesk native clients:
```
┌─────────────────────────────────────────┐
│ Machine: PC-Design-03 │
│ │
│ ┌──────────────┐ ┌─────────────────┐ │
│ │ RustDesk │ │ BetterDesk │ │
│ │ Client │ │ Native Client │ │
│ │ │ │ │ │
│ │ ID: 892734561 │ │ ID: CDAP-D2E9F4│ │
│ │ Port: 21116 │ │ Port: 21122 │ │
│ └──────┬───────┘ └───────┬────────┘ │
│ │ │ │
└─────────┼───────────────────┼───────────┘
│ │
▼ ▼
┌───────────────────────────────────────┐
│ BetterDesk Server │
│ │
│ peers table: │
│ ┌─────────────┬──────────┬─────────┐ │
│ │ 892734561 │ rustdesk │ linked │ │
│ │ CDAP-D2E9F4 │ desktop │ linked │ │
│ └─────────────┴──────────┴─────────┘ │
│ │
│ peer_links table (NEW): │
│ ┌──────────────┬───────────────────┐ │
│ │ 892734561 │ CDAP-D2E9F4 │ │
│ │ (rustdesk) │ (desktop) │ │
│ └──────────────┴───────────────────┘ │
│ │
│ Panel shows: 1 machine, 2 protocols │
│ Admin can merge or keep separate │
└───────────────────────────────────────┘
```
---
## Authentication & 2FA Integration
### CDAP Auth Flow (Detailed)
```
┌──────────────┐
│ CDAP Client │
│ (bridge/ │
│ agent) │
└──────┬───────┘
WS connect :21122
┌───────────────┐
│ CDAP Gateway │
│ (Go server) │
└───────┬───────┘
┌───────────────┼───────────────┐
│ │ │
method=api_key method=user_password method=device_token
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Validate │ │ Validate │ │ Validate │
│ API key │ │ user/pass│ │ device token │
│ (SHA256) │ │ (PBKDF2) │ │ (enrollment) │
└──────┬───┘ └──────┬───┘ └──────┬───────┘
│ │ │
│ ┌──────┴──────┐ │
│ │ TOTP check │ │
│ │ if enabled │ │
│ └──────┬──────┘ │
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────┐
│ Session established │
│ - JWT token issued (24h) │
│ - Role assigned (admin/operator/viewer) │
│ - Device ID confirmed/assigned │
│ - WS connection upgraded to authed │
│ - Audit log: auth event │
└─────────────────────────────────────────┘
```
### Auth Methods
| Method | Use Case | 2FA | Description |
|--------|----------|-----|-------------|
| `user_password` | Interactive (operator login on desktop) | Yes (if enabled) | Same credentials as panel login |
| `api_key` | Unattended (IoT bridges, SCADA, headless) | No | API key from Go server `api_keys` table |
| `device_token` | First-time enrollment | No | One-time enrollment token (becomes API key) |
### 2FA for Native Desktop Client
The BetterDesk native desktop client needs a full 2FA UI:
```
┌──────────────────────────────────────┐
│ BetterDesk - Login │
│ │
│ Server: betterdesk.example.com │
│ │
│ Username: [operator1 ] │
│ Password: [•••••••• ] │
│ │
│ [Login] │
└──────────────────────────────────────┘
(server responds: requires_2fa)
┌──────────────────────────────────────┐
│ BetterDesk - 2FA │
│ │
│ Enter authenticator code: │
│ │
│ [1] [2] [3] [4] [5] [6] │
│ │
│ ○ Use recovery code instead │
│ │
│ [Verify] [Cancel] │
└──────────────────────────────────────┘
(verified → full session)
┌──────────────────────────────────────┐
│ ✓ Connected as operator1 │
│ Server: betterdesk.example.com │
│ Device ID: CDAP-D2E9F4 │
│ │
│ This device is ready for remote │
│ connections and management. │
│ │
│ [Settings] [Minimize to tray] │
└──────────────────────────────────────┘
```
### 2FA Setup for CDAP Clients
CDAP clients do NOT set up 2FA themselves — 2FA is configured through the Node.js panel:
```
Panel (Node.js) Go Server CDAP Client
│ │ │
│── POST /api/auth/totp/setup ──►│ │
│◄── {qrCode, secret} ──────────│ │
│ │ │
│ (user scans QR in Authy) │ │
│ │ │
│── POST /api/auth/totp/enable ─►│ │
│ {code: "123456"} │── update users table ──│
│◄── {recoveryCodes} ───────────│ │
│ │ │
│ │ (next login) │
│ │◄── auth {user/pass} ───│
│ │──► auth_result │
│ │ {requires_2fa} ─────►│
│ │ │
│ │◄── auth_2fa {code} ────│
│ │──► auth_result │
│ │ {success, jwt} ─────►│
```
### Token Refresh for Long-Running Agents
Desktop agents and IoT bridges run 24/7. JWT tokens expire after 24h. CDAP supports token refresh:
```json
// Client sends before token expires
{
"type": "token_refresh",
"payload": {
"token": "current_jwt_about_to_expire"
}
}
// Server responds with new token (no re-auth needed)
{
"type": "token_refreshed",
"payload": {
"token": "new_jwt_24h",
"expires_at": "2026-03-21T14:30:00Z"
}
}
```
Rules:
- Refresh only works if current token is still valid (not expired)
- Max refresh chain: 30 days (then full re-auth required)
- Admin can revoke refresh chain from panel (force re-login)
- API key auth does not need refresh (keys don't expire by default)
---
## RustDesk Client Synchronization
### Problem Statement
A BetterDesk deployment typically has a mix of:
- **Existing RustDesk clients** — already deployed on workstations, using RustDesk protocol on port 21116
- **New BetterDesk native clients** (CDAP) — being rolled out, using CDAP on port 21122
- **IoT/SCADA bridges** (CDAP) — new devices via CDAP
These must coexist in the same panel, same device list, same address books, same permissions.
### Sync Points
#### 1. Unified Peer Table
Both RustDesk and CDAP devices live in the same `peers` table:
```sql
-- Existing columns (unchanged)
id TEXT PRIMARY KEY, -- "1340238749" (RustDesk) or "CDAP-D2E9F4" (CDAP)
uuid TEXT,
pk BLOB,
hostname TEXT,
os TEXT,
...
-- Existing CDAP columns (from Phase 1)
device_type TEXT DEFAULT 'rustdesk', -- rustdesk, scada, iot, os_agent, desktop, ...
manifest_json TEXT,
bridge_id TEXT,
cdap_session_id TEXT,
-- NEW: Linking columns
linked_peer_id TEXT, -- Cross-reference for dual-client machines
link_type TEXT, -- "auto" (hostname match) or "manual" (admin linked)
auth_user TEXT, -- Username who logged in on this device
last_auth_at TIMESTAMPTZ -- Last CDAP/RustDesk login time
```
#### 2. Address Book Convergence
RustDesk clients and CDAP clients share the same address book per user:
```
User "operator1" logs in from:
├── Node.js panel → sees all devices in panel
├── RustDesk client → GET /api/ab → sees address book
└── CDAP native client → GET /api/ab (same endpoint) → sees address book
Address book contains:
├── RustDesk device IDs (numeric)
└── CDAP device IDs (CDAP- prefix)
Both client types can:
├── Add devices to address book
├── Create tags/groups
└── Sync across devices
```
The Go server's `/api/ab` endpoint already works for RustDesk clients. CDAP clients use the **same endpoint** with the same JWT token, so address books are automatically synchronized.
#### 3. Connection History
When a CDAP client connects to another device (remote desktop):
```json
// Audit log entry (same format as RustDesk connections)
{
"action": "connection",
"details": {
"from_id": "CDAP-D2E9F4",
"from_type": "desktop",
"to_id": "892734561",
"to_type": "rustdesk",
"protocol": "cdap_media",
"initiated_by": "operator1",
"duration_sec": 1847,
"timestamp": "2026-03-19T14:30:00Z"
}
}
```
The Node.js panel's connection history view shows both RustDesk and CDAP connections in the same timeline.
#### 4. Online Status Merging
The panel needs to query both connection pools:
```
Node.js Panel
├── GET /api/peers (Go server)
│ └── Go enriches with:
│ ├── RustDesk live status (signal server in-memory map)
│ └── CDAP live status (CDAP gateway in-memory map)
└── Shows unified device list with live status from both protocols
```
Implementation: `handleListPeers` in Go server already overlays live status from signal peer map. Extended to also check CDAP gateway's active connections map.
#### 5. Linked Device View
When a machine runs both RustDesk and BetterDesk native clients:
```
Panel Device List:
┌──────────────────────────────────────────────────────────────┐
│ PC-Design-03 [Linked] │
│ ├── 🖥️ 892734561 | RustDesk | Online | Remote Desktop │
│ └── 📱 CDAP-D2E9F4 | Desktop | Online | Desktop + Mgmt │
│ │
│ srv-prod-01 │
│ └── 💻 CDAP-F9A2B1 | OS Agent | Online | System Management│
│ │
│ Boiler Room PLC │
│ └── 🏭 CDAP-A7F3B2 | SCADA | Online | Widgets │
└──────────────────────────────────────────────────────────────┘
```
Auto-linking algorithm:
1. Same hostname + same user → auto-link
2. Same IP (within 5 min window) + same hostname → suggest link
3. Admin can manually link/unlink from panel
---
## Implementation Phases
### Phase 0: Preparation (1-2 days)
| # | Task | Effort | Files |
|---|------|--------|-------|
| 0.1 | Add `device_type`, `manifest_json`, `linked_peer_id`, `auth_user` columns to peers | 0.5d | `db/sqlite.go`, `db/postgres.go` |
| 0.2 | Add `peer_links` table for device linking | 0.5d | `db/sqlite.go`, `db/postgres.go` |
| 0.3 | Update `handleListPeers` to return `device_type` field | 0.5d | `api/server.go` |
| 0.4 | Add device type filter to panel device list | 0.5d | `web-nodejs/views/`, `web-nodejs/routes/` |
**Gate**: Device list shows `device_type` column. All existing RustDesk devices show as `rustdesk`.
---
### Phase 1: CDAP Gateway Core (5-7 days)
| # | Task | Effort | Files |
|---|------|--------|-------|
| 1.1 | Create `cdap/` package with Gateway struct | 1d | `cdap/gateway.go` (NEW) |
| 1.2 | WebSocket server on port 21122 (`gorilla/websocket`) | 1d | `cdap/gateway.go` |
| 1.3 | Auth handler — `user_password` method (reuse `auth.VerifyPassword`) | 0.5d | `cdap/auth.go` (NEW) |
| 1.4 | Auth handler — `api_key` method (reuse `auth.ValidateAPIKey`) | 0.5d | `cdap/auth.go` |
| 1.5 | 2FA handler — TOTP flow within WebSocket (reuse `auth.ValidateTOTP`) | 0.5d | `cdap/auth.go` |
| 1.6 | Rate limiting on auth messages (reuse `ratelimit.IPLimiter`) | 0.5d | `cdap/auth.go` |
| 1.7 | Manifest parser + validation (JSON Schema) | 1d | `cdap/manifest.go` (NEW) |
| 1.8 | Device registration — write to `peers` table with `device_type` | 0.5d | `cdap/handler.go` (NEW) |
| 1.9 | Heartbeat handler — widget value storage + online status | 0.5d | `cdap/handler.go` |
| 1.10 | Connection lifecycle — reconnect ID persistence by serial | 0.5d | `cdap/handler.go` |
| 1.11 | Wire into `main.go` — start CDAP gateway alongside signal/relay | 0.5d | `main.go` |
**Gate**: Python bridge connects, authenticates (including 2FA), registers device, sends heartbeat. Device appears in `peers` table as `device_type=scada`.
---
### Phase 2: Panel Widget Rendering (5-7 days)
| # | Task | Effort | Files |
|---|------|--------|-------|
| 2.1 | REST endpoint: `GET /api/cdap/devices/{id}/manifest` | 0.5d | `api/server.go` or `cdap/api.go` |
| 2.2 | REST endpoint: `GET /api/cdap/devices/{id}/state` (current widget values) | 0.5d | `cdap/api.go` |
| 2.3 | REST endpoint: `POST /api/cdap/devices/{id}/command` (send command) | 0.5d | `cdap/api.go` |
| 2.4 | WebSocket push: widget state changes → panel (via existing event bus) | 1d | `cdap/handler.go`, `events/` |
| 2.5 | Panel: CDAP device detail page (EJS template) | 1d | `web-nodejs/views/cdap-device.ejs` (NEW) |
| 2.6 | Panel: Widget renderer — `toggle`, `gauge`, `button`, `led` | 2d | `web-nodejs/public/js/cdap-widgets.js` (NEW) |
| 2.7 | Panel: Command sending UI (confirm dialogs, cooldowns) | 1d | `web-nodejs/public/js/cdap-commands.js` (NEW) |
| 2.8 | Panel: Device list shows device type icons + filter | 0.5d | `web-nodejs/views/devices.ejs`, `devices.js` |
| 2.9 | i18n keys for CDAP widgets and device types (EN + PL) | 0.5d | `web-nodejs/lang/en.json`, `pl.json` |
**Gate**: Panel shows CDAP device detail with live-updating gauges, working toggle switches, functioning buttons with confirmations.
---
### Phase 3: Security Hardening + Auth Convergence + Device Revocation (6-8 days)
| # | Task | Effort | Files |
|---|------|--------|-------|
| 3.1 | TLS auto-detection on CDAP port (reuse `config.DualModeListener`) | 0.5d | `cdap/gateway.go` |
| 3.2 | RBAC per-widget permissions enforcement | 1d | `cdap/auth.go`, `cdap/handler.go` |
| 3.3 | Command audit logging (every command → audit ring buffer) | 0.5d | `cdap/handler.go`, `audit/` |
| 3.4 | Device token enrollment (one-time tokens for new devices) | 1d | `cdap/auth.go`, `db/` |
| 3.5 | Token refresh for long-running agents (30-day chain) | 0.5d | `cdap/auth.go` |
| 3.6 | Auth delegation: Node.js panel uses Go `/api/auth/` for user CRUD | 1d | `web-nodejs/services/authService.js` (NEW) |
| 3.7 | Panel: user management through Go API (password change syncs to Go) | 0.5d | `web-nodejs/routes/auth.routes.js` |
| 3.8 | **Device Revocation**: CDAP `revoke` + `suspend` message handlers in gateway | 1d | `cdap/revocation.go` (NEW), `cdap/gateway.go` |
| 3.9 | **Connection close on delete**: Close `TCPConn`/`WSConn` when peer removed from map | 0.5d | `peer/map.go`, `api/server.go` |
| 3.10 | **Blocklist on revoke**: Auto-add ID to `security.Blocklist` on `?revoke=true` | 0.5d | `api/server.go`, `security/blocklist.go` |
| 3.11 | **Cascade delete**: Revoke `linked_peer_id` when cascade option selected | 0.5d | `cdap/revocation.go`, `api/server.go` |
| 3.12 | **Panel revocation UI**: Delete dialog with wipe/blocklist/cascade checkboxes | 1d | `web-nodejs/views/`, `public/js/devices.js` |
| 3.13 | **EventPeerRevoked**: New event type on event bus for revocation audit trail | 0.25d | `events/bus.go` |
**Gate**: Widget RBAC enforced (operator can read gauge but not trigger emergency stop without permission). 2FA works end-to-end for CDAP clients. Password changed in panel reflects in CDAP login. Deleting a CDAP device sends `revoke` message → client wipes config and disconnects. Cascade delete revokes linked devices.
---
### Phase 4: Advanced Widgets + RustDesk Sync (5-7 days)
| # | Task | Effort | Files |
|---|------|--------|-------|
| 4.1 | Widget types: `chart`, `select`, `slider`, `text` | 2d | `web-nodejs/public/js/cdap-widgets.js` |
| 4.2 | Widget types: `table` (dynamic rows, sortable) | 1d | `cdap-widgets.js` |
| 4.3 | Widget type: `terminal` (WebSocket shell relay) | 2d | `cdap/terminal.go` (NEW), `cdap-widgets.js` |
| 4.4 | Device linking — auto-detect + manual link in panel | 1d | `cdap/linking.go` (NEW), panel views |
| 4.5 | Unified status overlay — CDAP status merged into `handleListPeers` | 0.5d | `api/server.go` |
| 4.6 | Address book sync — CDAP clients use same `/api/ab` endpoint | 0.5d | `cdap/api.go` (proxy or direct) |
| 4.7 | Connection history — CDAP events in same audit format | 0.5d | `cdap/handler.go`, `web-nodejs/routes/` |
**Gate**: Full 10+ widget types working. Linked devices show as one machine in panel. Address book shared between RustDesk and CDAP clients.
---
### Phase 5: Media Channel — Remote Desktop (10-15 days)
| # | Task | Effort | Files |
|---|------|--------|-------|
| 5.1 | Binary frame mux/demux in CDAP gateway | 2d | `cdap/media.go` (NEW) |
| 5.2 | Media session establishment (REST `/api/cdap/devices/{id}/connect`) | 1d | `cdap/api.go` |
| 5.3 | Binary frame relay (viewer ↔ device, E2E opaque) | 1d | `cdap/media.go` |
| 5.4 | E2E key exchange (X25519 → XSalsa20-Poly1305 via control messages) | 1d | `cdap/crypto.go` (NEW) |
| 5.5 | Video channel: codec negotiation + keyframe request handling | 1d | `cdap/media.go` |
| 5.6 | Panel: WebCodecs/Canvas desktop viewer widget | 3d | `web-nodejs/public/js/cdap-desktop.js` (NEW) |
| 5.7 | Panel: input forwarding (keyboard/mouse → binary frames) | 1d | `cdap-desktop.js` |
| 5.8 | Panel: clipboard sync widget | 1d | `cdap-desktop.js` |
| 5.9 | Panel: file transfer two-pane browser | 2d | `web-nodejs/public/js/cdap-files.js` (NEW) |
| 5.10 | Audio channel: Opus decode/play in browser | 1d | `cdap-desktop.js` |
| 5.11 | Cursor channel: custom cursor rendering | 0.5d | `cdap-desktop.js` |
| 5.12 | Adaptive quality (bitrate/fps adjustments) | 1d | `cdap/media.go` |
| 5.13 | Multi-monitor support (display index routing) | 0.5d | `cdap/media.go`, `cdap-desktop.js` |
**Gate**: Panel can open remote desktop session to a CDAP desktop device. Video, audio, input, clipboard, and file transfer all work through the binary media channel with E2E encryption.
---
### Phase 6: Native BetterDesk Desktop Agent (10-15 days)
| # | Task | Effort | Files |
|---|------|--------|-------|
| 6.1 | Agent binary scaffold (Rust or Go) — systemd/service installer | 2d | `betterdesk-agent/` (NEW repo or dir) |
| 6.2 | CDAP client library — auth + heartbeat + manifest | 2d | `betterdesk-agent/cdap/` |
| 6.3 | Screen capture — DXGI (Windows), X11/PipeWire (Linux) | 2d | `betterdesk-agent/capture/` |
| 6.4 | Video encoder — H.264/VP9 hardware-accelerated | 2d | `betterdesk-agent/encoder/` |
| 6.5 | Audio capture — WASAPI (Windows), PulseAudio (Linux) | 1d | `betterdesk-agent/audio/` |
| 6.6 | Input injection — keyboard/mouse (platform-specific) | 1d | `betterdesk-agent/input/` |
| 6.7 | Clipboard monitor — text/image sync | 1d | `betterdesk-agent/clipboard/` |
| 6.8 | File transfer — chunk read/write with resume | 1d | `betterdesk-agent/files/` |
| 6.9 | System widgets — CPU/RAM/disk/services/processes | 1d | `betterdesk-agent/sysinfo/` |
| 6.10 | Login UI — username/password + 2FA dialog | 1d | `betterdesk-agent/ui/` |
| 6.11 | Tray icon + auto-start | 0.5d | `betterdesk-agent/ui/` |
| 6.12 | Update from ALL-IN-ONE scripts (install/update support) | 1d | `betterdesk.sh`, `betterdesk.ps1` |
**Gate**: BetterDesk agent installs on Windows/Linux, logs in with 2FA, appears as `desktop` type in panel, supports remote desktop + system management widgets.
---
### Phase 7: Bridge Ecosystem + Polish (5-7 days)
| # | Task | Effort | Files |
|---|------|--------|-------|
| 7.1 | Python bridge SDK (pip-installable) | 1d | `sdks/python/` (NEW) |
| 7.2 | Reference bridge: Modbus TCP | 1d | `bridges/modbus/` (NEW) |
| 7.3 | Reference bridge: SNMP | 1d | `bridges/snmp/` (NEW) |
| 7.4 | Reference bridge: REST/HTTP (generic webhook → CDAP) | 1d | `bridges/rest/` (NEW) |
| 7.5 | Dashboard: CDAP device type counters + overview cards | 1d | Panel views |
| 7.6 | Alert system: threshold-based from manifest definitions | 1d | `cdap/alerts.go` (NEW) |
| 7.7 | Documentation site / updated README | 1d | `docs/` |
**Gate**: Three working reference bridges. Alert system notifies on threshold breach. Dashboard shows CDAP devices alongside RustDesk.
---
## File-Level Change Map
### Go Server (`betterdesk-server/`)
| File | Action | Phase | Description |
|------|--------|-------|-------------|
| `main.go` | Modify | 1 | Start CDAP gateway, pass config |
| `config/config.go` | Modify | 1 | Add `CDAPPort`, `CDAPTLSEnabled()` |
| `cdap/gateway.go` | **NEW** | 1 | WebSocket server, connection lifecycle |
| `cdap/auth.go` | **NEW** | 1 | Auth handler (user/pass, API key, 2FA, device token) |
| `cdap/handler.go` | **NEW** | 1 | Register, heartbeat, state_update, command routing |
| `cdap/manifest.go` | **NEW** | 1 | Manifest parsing + JSON validation |
| `cdap/api.go` | **NEW** | 2 | REST endpoints for panel (manifest, state, command) |
| `cdap/terminal.go` | **NEW** | 4 | Terminal widget WebSocket relay |
| `cdap/linking.go` | **NEW** | 4 | Device auto-linking logic |
| `cdap/media.go` | **NEW** | 5 | Binary frame mux/demux, media session, relay |
| `cdap/crypto.go` | **NEW** | 5 | X25519 key exchange for E2E media |
| `cdap/revocation.go` | **NEW** | 3 | Revoke/suspend message sending, cascade logic, conn close |
| `cdap/alerts.go` | **NEW** | 7 | Threshold-based alert evaluation |
| `db/database.go` | Modify | 0-1 | Add CDAP-related methods to interface |
| `db/sqlite.go` | Modify | 0-1 | Implement CDAP methods, add columns/tables |
| `db/postgres.go` | Modify | 0-1 | Same for PostgreSQL |
| `peer/map.go` | Modify | 3 | Close TCP/WS connections on `Remove()` |
| `api/server.go` | Modify | 2-4 | CDAP REST routes, status overlay, `?revoke=true` in delete |
| `events/bus.go` | Modify | 3 | Add `EventPeerRevoked` event type |
| `security/blocklist.go` | Modify | 3 | Auto-add ID on revocation |
| `auth/` (existing) | No change | — | Reused by CDAP auth (no modification needed) |
### Node.js Console (`web-nodejs/`)
| File | Action | Phase | Description |
|------|--------|-------|-------------|
| `views/cdap-device.ejs` | **NEW** | 2 | CDAP device detail page with widget panel |
| `views/devices.ejs` | Modify | 0 | Add device type column, filter, icons |
| `public/js/cdap-widgets.js` | **NEW** | 2 | Widget renderer (all 13 types) |
| `public/js/cdap-commands.js` | **NEW** | 2 | Command sending, confirmation, cooldown |
| `public/js/cdap-desktop.js` | **NEW** | 5 | Remote desktop viewer (WebCodecs + Canvas) |
| `public/js/cdap-files.js` | **NEW** | 5 | Two-pane file browser |
| `public/css/cdap.css` | **NEW** | 2 | Widget styles, device type icons |
| `routes/cdap.routes.js` | **NEW** | 2 | CDAP panel routes (proxy to Go server) |
| `services/authService.js` | **NEW** | 3 | Auth delegation to Go server |
| `lang/en.json` | Modify | 2+ | CDAP i18n keys |
| `lang/pl.json` | Modify | 2+ | CDAP i18n keys |
| `services/betterdeskApi.js` | Modify | 2 | Add CDAP API methods |
### ALL-IN-ONE Scripts
| File | Action | Phase | Description |
|------|--------|-------|-------------|
| `betterdesk.sh` | Modify | 6 | CDAP port in firewall, agent install option |
| `betterdesk.ps1` | Modify | 6 | Same for Windows |
| `betterdesk-docker.sh` | Modify | 1 | Expose port 21122 in docker-compose |
| `docker-compose.yml` | Modify | 1 | Add port 21122 mapping |
| `Dockerfile` | Modify | 1 | Expose 21122 |
---
## Testing Strategy
### Unit Tests (Go)
| Test | Phase | Validates |
|------|-------|-----------|
| `cdap/auth_test.go` | 1 | Auth message parsing, password verify, API key, 2FA flow |
| `cdap/manifest_test.go` | 1 | Manifest validation (valid, invalid, edge cases) |
| `cdap/handler_test.go` | 1 | Register, heartbeat, state_update, command routing |
| `cdap/media_test.go` | 5 | Binary frame mux/demux, session pairing |
| `cdap/linking_test.go` | 4 | Auto-link algorithm, unlink |
| `cdap/revocation_test.go` | 3 | Revoke message sent, conn closed, config wipe, cascade |
### Integration Tests
| Test | Phase | Setup | Validates |
|------|-------|-------|-----------|
| Bridge auth flow | 1 | Go server + Python bridge | WS connect → auth → register → heartbeat |
| 2FA end-to-end | 1 | Go server + test TOTP | Auth → 2FA required → code verify → session |
| Panel widget rendering | 2 | Full stack | Bridge connects → panel shows widgets → values update |
| Command round-trip | 2 | Full stack | Panel sends command → bridge receives → executes → response |
| Linked devices | 4 | Go server + RustDesk + CDAP | Both clients online → panel shows linked view |
| Device revocation | 3 | Go server + CDAP bridge | Delete → revoke msg → bridge disconnects + wipes config |
| Cascade revocation | 3 | Go server + RustDesk + CDAP | Delete CDAP → linked RustDesk also revoked + blocked |
| Media relay | 5 | Go server + 2 CDAP clients | Video frames relayed with E2E encryption |
### Load Tests
| Test | Phase | Target |
|------|-------|--------|
| 100 concurrent CDAP devices | 2 | Gateway handles 100 WS connections, 1000 msg/s |
| 10 concurrent media sessions | 5 | Binary relay at 10 Mbps aggregate without frame loss |
| 1000 widget updates/sec | 2 | Panel receives updates via WS push without lag |
---
## Migration & Backward Compatibility
### Zero Breaking Changes
- All existing RustDesk clients continue working unchanged
- All existing Node.js panel features unchanged
- All existing API endpoints unchanged
- CDAP is strictly additive — new port, new protocol, new device types
### Migration Path for Organizations
```
Step 1: Update BetterDesk server (Phase 1-4)
└── CDAP gateway starts on port 21122
└── All existing devices still work
Step 2: Deploy IoT/SCADA bridges (Phase 2+)
└── New device types appear in panel
└── RustDesk devices unaffected
Step 3: Deploy BetterDesk native agent on select machines (Phase 6)
└── Machine shows two entries (RustDesk + CDAP)
└── Admin can link them in panel
Step 4: Gradually replace RustDesk client with native agent
└── Uninstall RustDesk client
└── CDAP agent provides remote desktop + management
└── One device entry per machine
Step 5 (optional): Disable RustDesk protocol
└── Only for environments fully migrated to CDAP
└── Signal/relay servers still available for backward compat
```
### Rollback
Every phase is independent. If Phase 5 (media) has issues, Phases 1-4 (widgets, auth, sync) continue working. CDAP gateway can be disabled entirely by removing the `--cdap-port` flag — zero impact on RustDesk protocol.
---
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| WebSocket scalability at 1000+ devices | High | Medium | Connection pooling, per-device message throttle |
| Auth DB divergence (Node.js vs Go) | High | High | Phase 3 convergence, sync checks |
| Binary frame relay performance for desktop | High | Low | E2E opaque (zero decode), tested architecture |
| Native agent cross-platform complexity | High | High | Start with one platform (Linux or Windows), add second |
| Bridge SDK maintenance burden | Medium | Medium | Minimal SDKs (~200 LOC), auto-generate from spec |
| RustDesk protocol changes break existing | Medium | Low | BetterDesk server frozen at RustDesk 1.3.x compat |
| 2FA lockout (lost phone, no recovery codes) | Medium | Medium | Admin can disable 2FA from panel, recovery codes backup reminder |
| CDAP spec changes during development | Medium | Medium | Versioned manifests, backward compat |
| Revocation message not delivered (device offline) | Medium | High | Blocklist + soft-delete ensure re-registration fails regardless; `revoke` is best-effort optimization |
| Cascade delete accidental scope | High | Low | Cascade is opt-in checkbox, admin must explicitly confirm; audited |
| RustDesk client cannot be config-wiped | Medium | N/A | Protocol limitation; blocklist prevents re-registration; BetterDesk native client supports full wipe |
---
## Timeline Summary
| Phase | Duration | Cumulative | Key Deliverable |
|-------|----------|------------|-----------------|
| 0: Preparation | 1-2 days | 1-2 days | DB schema, device type column |
| 1: Gateway Core | 5-7 days | 6-9 days | CDAP auth + registration working |
| 2: Panel Widgets | 5-7 days | 11-16 days | Widgets visible and interactive in panel |
| 3: Security + Auth + Revocation | 6-8 days | 17-25 days | Full RBAC + 2FA + auth convergence + device revocation |
| 4: Advanced + Sync | 5-7 days | 22-32 days | Full widget set + RustDesk sync |
| 5: Media Channel | 10-15 days | 32-47 days | Remote desktop via CDAP |
| 6: Native Agent | 10-15 days | 42-62 days | BetterDesk desktop agent binary |
| 7: Ecosystem | 5-7 days | 47-69 days | Bridge SDKs + reference bridges |
**MVP (Phases 0-2)**: ~16 days → CDAP devices with widgets in panel
**Production (Phases 0-4)**: ~32 days → Secure, synced, full widget set + revocation
**Full Stack (Phases 0-7)**: ~69 days → Native client + bridge ecosystem
+46
View File
@@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.4.1] - 2026-03-20
### 🎨 Devices Page UI Redesign
Complete UI overhaul of the Devices page — fully responsive, space-efficient, and optimized for phone, tablet, and desktop.
#### Layout Changes
- **Sidebar removed** — 280px folder sidebar replaced with horizontal scrollable **folder chips** (pill buttons)
- **Unified toolbar** — Search, segmented filter buttons (All/Online/Offline/Banned), and column visibility toggle in a single row
- **Slim table** — Reduced cell padding for higher information density
- **Kebab menu** (⋮) — Replaced 5 inline action buttons per row with a compact dropdown menu (connect, connect-desktop, details, ban/unban, delete)
- **Status dot** — Colored dot (green/gray/red) inline with device ID for at-a-glance status
- **Mobile bottom sheet** — On phones, kebab menu appears as a full-width bottom sheet with backdrop overlay
#### Responsive Breakpoints
- **≤1024px** — Hides `device_type` column
- **≤768px** — Hides `platform` + `last_online` columns; search goes full-width; button labels hidden (icons only)
- **≤600px** — Card-style rows (CSS grid 2-column); `<thead>` hidden; kebab becomes fixed bottom sheet with overlay backdrop
- **≤400px** — Folder chip labels hidden (icons only); smaller filter buttons
#### Files Changed
- `views/devices.ejs` — Complete template rewrite
- `public/css/devices.css` — Complete stylesheet rewrite (~780 lines)
- `public/js/devices.js` — Updated rendering (kebab menu, folder chips, status dots, global close handler)
---
## [2.4.0] - 2026-03-01
### ✨ PostgreSQL Support, Migration Tool, CDAP, TLS Everywhere & More
See `.github/copilot-instructions.md` for full details on Phases 429.
#### Highlights
- PostgreSQL database backend (`pgx/v5`, connection pooling, `LISTEN/NOTIFY`)
- SQLite ↔ PostgreSQL migration tool (`tools/migrate/`)
- TLS for TCP signal, relay, and WebSocket (auto-detect plain/TLS on same port)
- E2E encryption fixes (relay UUID, SignIdPk NaCl format, PunchHoleResponse)
- CDAP protocol v0.3.0 (device widgets, commands, panel rendering)
- Sysinfo/heartbeat endpoints for hostname/platform display
- Address book sync in Go server
- Docker single-container + GHCR publishing
- 30+ bug fixes across Go server, Node.js console, and installer scripts
---
## [2.3.0] - 2026-02-22
### 🔒 Security Audit & Fixes
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -13,10 +13,10 @@ For the README.md, please add the following screenshots:
- Search functionality
2. **devices-list.png** - Device management view showing:
- Complete device table with multiple entries
- Mix of online/offline devices
- Device notes
- Action buttons (connect, info, edit, delete)
- Horizontal folder chips bar (scrollable pills)
- Unified toolbar with search, segmented filters, column toggle
- Slim table with status dot, device ID, hostname, platform
- Kebab context menu (⋮) per row
3. **device-details.png** - Device details modal showing:
- Device ID
@@ -27,10 +27,11 @@ For the README.md, please add the following screenshots:
- Created timestamp
- Additional metadata
4. **mobile-view.png** - Mobile responsive view showing:
- Adapted layout for mobile screens
- Hamburger menu (if applicable)
- Touch-friendly controls
4. **mobile-view.png** - Mobile responsive view (≤600px) showing:
- Card-style device rows (CSS grid 2-column layout)
- Bottom sheet kebab menu with backdrop overlay
- Compact folder chips (icon-only at ≤400px)
- Touch-friendly controls with larger tap targets
## How to Create Screenshots
+43 -1
View File
@@ -122,6 +122,9 @@
"delete_warning": "You are about to permanently delete this device:",
"delete_permanent": "This action cannot be undone. All data associated with this device will be permanently removed from the database.",
"delete_success": "Device deleted successfully",
"revoke_option": "Revoke device (disconnect + block re-registration)",
"revoke_hint": "The device will be immediately disconnected and permanently blocked from reconnecting to this server.",
"revoke_success": "Device revoked and blocklisted successfully",
"details": "Device Details",
"ban_title": "Ban Device",
"ban_confirm": "Are you sure you want to ban device {id}?",
@@ -147,7 +150,14 @@
"invalid_id": "Invalid device ID (6-16 characters required)",
"invalid_id_format": "Invalid ID format (letters, numbers, dashes, underscores only)",
"id_exists": "Device ID already exists",
"no_selection": "No devices selected"
"no_selection": "No devices selected",
"device_type": "Type",
"filter_type_all": "All Types",
"filter_type_rustdesk": "RustDesk",
"filter_type_desktop": "Desktop",
"filter_type_scada": "SCADA",
"filter_type_iot": "IoT",
"filter_type_agent": "Agent"
},
"keys": {
"title": "Server Keys",
@@ -1060,5 +1070,37 @@
"no_file": "No backup file selected",
"invalid_json": "Invalid backup file: not valid JSON",
"invalid_format": "Invalid backup file: not a BetterDesk backup"
},
"cdap": {
"device_detail": "CDAP Device",
"loading": "Loading...",
"loading_widgets": "Loading device widgets...",
"load_error": "Failed to load device data",
"connected": "Connected",
"disconnected": "Disconnected",
"device_offline_msg": "Device is currently offline. Widget values may be stale.",
"no_widgets": "No widgets available",
"no_widgets_desc": "This device has not registered a CDAP manifest with widget definitions.",
"command_log": "Command Log",
"clear_log": "Clear log",
"confirm_command": "Confirm Command",
"select_option": "Select",
"cdap_status": "CDAP Status",
"cdap_enabled": "CDAP Enabled",
"cdap_disabled": "CDAP Disabled",
"cdap_devices": "CDAP Devices",
"cdap_connections": "Active Connections",
"send_command": "Send Command",
"command_sent": "Command sent successfully",
"command_failed": "Failed to send command"
},
"desktop": {
"switch_mode": "Desktop Mode",
"console_mode": "Console Mode",
"loading": "Loading...",
"minimize": "Minimize",
"maximize": "Maximize",
"restore": "Restore",
"close": "Close"
}
}
+43 -1
View File
@@ -122,6 +122,9 @@
"delete_warning": "Za chwilę trwale usuniesz to urządzenie:",
"delete_permanent": "Ta operacja jest nieodwracalna. Wszystkie dane powiązane z tym urządzeniem zostaną trwale usunięte z bazy danych.",
"delete_success": "Urządzenie usunięte",
"revoke_option": "Odwołaj urządzenie (rozłącz + zablokuj ponowną rejestrację)",
"revoke_hint": "Urządzenie zostanie natychmiast rozłączone i trwale zablokowane przed ponownym połączeniem z tym serwerem.",
"revoke_success": "Urządzenie odwołane i dodane do czarnej listy",
"details": "Szczegóły urządzenia",
"ban_title": "Zablokuj urządzenie",
"ban_confirm": "Czy na pewno chcesz zablokować urządzenie {id}?",
@@ -147,7 +150,14 @@
"invalid_id": "Nieprawidłowe ID urządzenia (wymagane 6-16 znaków)",
"invalid_id_format": "Nieprawidłowy format ID (tylko litery, cyfry, myślniki i podkreślenia)",
"id_exists": "Urządzenie o tym ID już istnieje",
"no_selection": "Nie wybrano żadnych urządzeń"
"no_selection": "Nie wybrano żadnych urządzeń",
"device_type": "Typ",
"filter_type_all": "Wszystkie typy",
"filter_type_rustdesk": "RustDesk",
"filter_type_desktop": "Desktop",
"filter_type_scada": "SCADA",
"filter_type_iot": "IoT",
"filter_type_agent": "Agent"
},
"keys": {
"title": "Klucze serwera",
@@ -1060,5 +1070,37 @@
"no_file": "Nie wybrano pliku kopii zapasowej",
"invalid_json": "Nieprawidłowy plik kopii: nie jest poprawnym JSON",
"invalid_format": "Nieprawidłowy plik kopii: to nie jest kopia BetterDesk"
},
"cdap": {
"device_detail": "Urządzenie CDAP",
"loading": "Ładowanie...",
"loading_widgets": "Ładowanie widgetów urządzenia...",
"load_error": "Nie udało się załadować danych urządzenia",
"connected": "Połączony",
"disconnected": "Rozłączony",
"device_offline_msg": "Urządzenie jest obecnie offline. Wartości widgetów mogą być nieaktualne.",
"no_widgets": "Brak dostępnych widgetów",
"no_widgets_desc": "To urządzenie nie zarejestrowało manifestu CDAP z definicjami widgetów.",
"command_log": "Dziennik poleceń",
"clear_log": "Wyczyść dziennik",
"confirm_command": "Potwierdź polecenie",
"select_option": "Wybierz",
"cdap_status": "Status CDAP",
"cdap_enabled": "CDAP włączony",
"cdap_disabled": "CDAP wyłączony",
"cdap_devices": "Urządzenia CDAP",
"cdap_connections": "Aktywne połączenia",
"send_command": "Wyślij polecenie",
"command_sent": "Polecenie wysłane pomyślnie",
"command_failed": "Nie udało się wysłać polecenia"
},
"desktop": {
"switch_mode": "Tryb pulpitu",
"console_mode": "Tryb konsoli",
"loading": "Ładowanie...",
"minimize": "Minimalizuj",
"maximize": "Maksymalizuj",
"restore": "Przywróć",
"close": "Zamknij"
}
}
+43 -1
View File
@@ -122,6 +122,9 @@
"delete_warning": "您即将永久删除此设备:",
"delete_permanent": "此操作无法撤销。与该设备关联的所有数据将从数据库中永久删除。",
"delete_success": "设备删除成功",
"revoke_option": "吊销设备(断开连接 + 阻止重新注册)",
"revoke_hint": "设备将被立即断开连接,并永久阻止重新连接到此服务器。",
"revoke_success": "设备已吊销并加入黑名单",
"details": "设备详情",
"ban_title": "封禁设备",
"ban_confirm": "确定要封禁设备 {id} 吗?",
@@ -147,7 +150,14 @@
"invalid_id": "设备 ID 无效(需要 6-16 个字符)",
"invalid_id_format": "ID 格式无效(仅允许字母、数字、连字符和下划线)",
"id_exists": "设备 ID 已存在",
"no_selection": "未选择任何设备"
"no_selection": "未选择任何设备",
"device_type": "类型",
"filter_type_all": "所有类型",
"filter_type_rustdesk": "RustDesk",
"filter_type_desktop": "桌面",
"filter_type_scada": "SCADA",
"filter_type_iot": "物联网",
"filter_type_agent": "代理"
},
"keys": {
"title": "服务器密钥",
@@ -1060,5 +1070,37 @@
"no_file": "未选择备份文件",
"invalid_json": "无效的备份文件:不是有效的 JSON",
"invalid_format": "无效的备份文件:不是 BetterDesk 备份"
},
"cdap": {
"device_detail": "CDAP 设备",
"loading": "加载中...",
"loading_widgets": "正在加载设备控件...",
"load_error": "无法加载设备数据",
"connected": "已连接",
"disconnected": "已断开",
"device_offline_msg": "设备当前处于离线状态。控件值可能已过时。",
"no_widgets": "没有可用的控件",
"no_widgets_desc": "此设备尚未注册包含控件定义的 CDAP 清单。",
"command_log": "命令日志",
"clear_log": "清除日志",
"confirm_command": "确认命令",
"select_option": "选择",
"cdap_status": "CDAP 状态",
"cdap_enabled": "CDAP 已启用",
"cdap_disabled": "CDAP 已禁用",
"cdap_devices": "CDAP 设备",
"cdap_connections": "活跃连接",
"send_command": "发送命令",
"command_sent": "命令发送成功",
"command_failed": "命令发送失败"
},
"desktop": {
"switch_mode": "桌面模式",
"console_mode": "控制台模式",
"loading": "加载中...",
"minimize": "最小化",
"maximize": "最大化",
"restore": "还原",
"close": "关闭"
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ const helmetMiddleware = helmet({
imgSrc: ["'self'", "data:", "blob:"],
mediaSrc: ["'self'", "blob:"], // blob: required by JMuxer MSE video decoding
connectSrc: connectSources,
frameSrc: ["'none'"],
frameSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
+682
View File
@@ -0,0 +1,682 @@
/**
* BetterDesk Console - CDAP Device Page Styles
* Widget grid layout, individual widget types, and command log.
*/
/* ── Page Layout ──────────────────────────────────────────────────── */
.cdap-device-page {
max-width: 1400px;
margin: 0 auto;
}
/* ── Device Header ────────────────────────────────────────────────── */
.cdap-device-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md, 1rem);
margin-bottom: var(--space-lg, 1.5rem);
flex-wrap: wrap;
}
.cdap-device-title {
display: flex;
align-items: center;
gap: var(--space-sm, 0.5rem);
}
.cdap-back-btn {
color: var(--text-secondary, #8b949e);
transition: color 0.15s;
}
.cdap-back-btn:hover {
color: var(--text-primary, #e6edf3);
}
.cdap-device-identity h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #e6edf3);
margin: 0;
}
.cdap-device-meta {
display: flex;
align-items: center;
gap: var(--space-md, 1rem);
margin-top: 4px;
}
.cdap-meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.8rem;
color: var(--text-secondary, #8b949e);
}
.cdap-meta-item .material-icons {
font-size: 16px;
}
/* Status indicator */
.cdap-device-status {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
background: var(--bg-tertiary, #21262d);
border: 1px solid var(--border-color, #30363d);
}
.cdap-device-status.online {
background: rgba(63, 185, 80, 0.1);
border-color: rgba(63, 185, 80, 0.3);
color: #3fb950;
}
.cdap-device-status.offline {
background: rgba(139, 148, 158, 0.1);
border-color: rgba(139, 148, 158, 0.3);
color: #8b949e;
}
.cdap-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.cdap-device-status.online .cdap-status-dot {
box-shadow: 0 0 6px rgba(63, 185, 80, 0.5);
}
/* ── Offline Banner ───────────────────────────────────────────────── */
.cdap-offline-banner {
display: flex;
align-items: center;
gap: var(--space-sm, 0.5rem);
padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
background: rgba(210, 153, 34, 0.1);
border: 1px solid rgba(210, 153, 34, 0.3);
border-radius: var(--radius-md, 8px);
color: #d29922;
font-size: 0.85rem;
margin-bottom: var(--space-lg, 1.5rem);
}
.cdap-offline-banner .material-icons {
font-size: 20px;
}
/* ── Widget Grid ──────────────────────────────────────────────────── */
.cdap-widget-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-md, 1rem);
margin-bottom: var(--space-lg, 1.5rem);
}
.cdap-widget-category {
grid-column: 1 / -1;
margin-top: var(--space-md, 1rem);
}
.cdap-widget-category:first-child {
margin-top: 0;
}
.cdap-widget-category h3 {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary, #8b949e);
margin: 0;
padding-bottom: var(--space-xs, 0.25rem);
border-bottom: 1px solid var(--border-color, #30363d);
}
/* Widget Card */
.cdap-widget {
background: var(--bg-secondary, #161b22);
border: 1px solid var(--border-color, #30363d);
border-radius: var(--radius-md, 8px);
padding: var(--space-md, 1rem);
display: flex;
flex-direction: column;
gap: var(--space-sm, 0.5rem);
transition: border-color 0.15s;
}
.cdap-widget:hover {
border-color: var(--border-hover, #484f58);
}
.cdap-widget-readonly {
opacity: 0.85;
}
/* Widget sizes */
.cdap-widget-sm {
min-height: auto;
}
.cdap-widget-lg {
grid-column: span 2;
}
@media (max-width: 768px) {
.cdap-widget-lg {
grid-column: span 1;
}
}
.cdap-widget-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.cdap-widget-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary, #8b949e);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.cdap-widget-unit {
font-size: 0.75rem;
color: var(--text-tertiary, #6e7681);
}
.cdap-widget-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
/* ── Toggle Widget ────────────────────────────────────────────────── */
.cdap-toggle {
position: relative;
display: inline-block;
width: 48px;
height: 26px;
}
.cdap-toggle-input {
opacity: 0;
width: 0;
height: 0;
}
.cdap-toggle-slider {
position: absolute;
inset: 0;
background: var(--bg-tertiary, #21262d);
border: 1px solid var(--border-color, #30363d);
border-radius: 26px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.cdap-toggle-slider::before {
content: '';
position: absolute;
left: 3px;
top: 3px;
width: 18px;
height: 18px;
background: var(--text-secondary, #8b949e);
border-radius: 50%;
transition: transform 0.2s, background 0.2s;
}
.cdap-toggle-input:checked + .cdap-toggle-slider {
background: rgba(63, 185, 80, 0.2);
border-color: rgba(63, 185, 80, 0.4);
}
.cdap-toggle-input:checked + .cdap-toggle-slider::before {
transform: translateX(22px);
background: #3fb950;
}
.cdap-toggle-input:disabled + .cdap-toggle-slider {
cursor: not-allowed;
opacity: 0.5;
}
.cdap-toggle-label {
margin-left: var(--space-sm, 0.5rem);
font-size: 0.85rem;
font-weight: 500;
color: var(--text-primary, #e6edf3);
}
/* ── Gauge Widget ─────────────────────────────────────────────────── */
.cdap-gauge {
display: flex;
flex-direction: column;
gap: 6px;
}
.cdap-gauge-bar {
height: 8px;
background: var(--bg-tertiary, #21262d);
border-radius: 4px;
overflow: hidden;
}
.cdap-gauge-fill {
height: 100%;
background: var(--accent-color, #58a6ff);
border-radius: 4px;
transition: width 0.5s ease;
}
.cdap-gauge-fill.cdap-gauge-warning {
background: #d29922;
}
.cdap-gauge-fill.cdap-gauge-danger {
background: #f85149;
}
.cdap-gauge-value {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.cdap-gauge-number {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #e6edf3);
}
.cdap-gauge-range {
font-size: 0.75rem;
color: var(--text-tertiary, #6e7681);
}
/* ── Button Widget ────────────────────────────────────────────────── */
.cdap-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--bg-tertiary, #21262d);
border: 1px solid var(--border-color, #30363d);
border-radius: var(--radius-sm, 6px);
color: var(--text-primary, #e6edf3);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.cdap-action-btn:hover {
background: var(--bg-hover, #292e36);
border-color: var(--border-hover, #484f58);
}
.cdap-action-btn:active {
background: var(--accent-color, #58a6ff);
color: #fff;
}
.cdap-action-btn .material-icons {
font-size: 18px;
}
/* ── LED Widget ───────────────────────────────────────────────────── */
.cdap-led {
display: flex;
align-items: center;
gap: var(--space-sm, 0.5rem);
}
.cdap-led-light {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--border-color, #30363d);
transition: background 0.3s, box-shadow 0.3s;
}
.cdap-led-light.off {
background: var(--bg-tertiary, #21262d);
}
.cdap-led-light.on {
background: #3fb950;
box-shadow: 0 0 8px rgba(63, 185, 80, 0.5);
border-color: rgba(63, 185, 80, 0.4);
}
.cdap-led-label {
font-size: 0.85rem;
color: var(--text-primary, #e6edf3);
}
/* ── Text Widget ──────────────────────────────────────────────────── */
.cdap-text-value {
font-size: 1.1rem;
font-weight: 500;
color: var(--text-primary, #e6edf3);
word-break: break-word;
}
/* ── Slider Widget ────────────────────────────────────────────────── */
.cdap-slider-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.cdap-slider-input {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background: var(--bg-tertiary, #21262d);
border-radius: 3px;
outline: none;
}
.cdap-slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: var(--accent-color, #58a6ff);
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--bg-primary, #0d1117);
transition: transform 0.1s;
}
.cdap-slider-input::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.cdap-slider-input::-moz-range-thumb {
width: 18px;
height: 18px;
background: var(--accent-color, #58a6ff);
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--bg-primary, #0d1117);
}
.cdap-slider-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cdap-slider-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-tertiary, #6e7681);
}
.cdap-slider-value {
font-weight: 600;
color: var(--text-primary, #e6edf3);
}
/* ── Select Widget ────────────────────────────────────────────────── */
.cdap-select-input {
width: 100%;
padding: 6px 10px;
background: var(--bg-tertiary, #21262d);
border: 1px solid var(--border-color, #30363d);
border-radius: var(--radius-sm, 6px);
color: var(--text-primary, #e6edf3);
font-size: 0.85rem;
}
.cdap-select-input:focus {
border-color: var(--accent-color, #58a6ff);
outline: none;
}
/* ── Chart Widget (simple bars) ───────────────────────────────────── */
.cdap-chart-bars {
display: flex;
flex-direction: column;
gap: 8px;
}
.cdap-chart-bar-wrap {
display: grid;
grid-template-columns: 80px 1fr 50px;
align-items: center;
gap: 8px;
}
.cdap-chart-bar-label {
font-size: 0.75rem;
color: var(--text-secondary, #8b949e);
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cdap-chart-bar-track {
height: 8px;
background: var(--bg-tertiary, #21262d);
border-radius: 4px;
overflow: hidden;
}
.cdap-chart-bar-fill {
height: 100%;
background: var(--accent-color, #58a6ff);
border-radius: 4px;
transition: width 0.5s ease;
}
.cdap-chart-bar-value {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-primary, #e6edf3);
text-align: right;
}
/* ── Loading & Empty States ───────────────────────────────────────── */
.cdap-loading {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-secondary, #8b949e);
}
.cdap-loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color, #30363d);
border-top-color: var(--accent-color, #58a6ff);
border-radius: 50%;
animation: cdap-spin 0.8s linear infinite;
margin-bottom: var(--space-md, 1rem);
}
@keyframes cdap-spin {
to { transform: rotate(360deg); }
}
.cdap-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.cdap-empty-icon {
font-size: 64px;
color: var(--text-tertiary, #6e7681);
margin-bottom: var(--space-md, 1rem);
}
.cdap-empty h3 {
color: var(--text-primary, #e6edf3);
margin: 0 0 8px;
}
.cdap-empty p {
color: var(--text-secondary, #8b949e);
margin: 0;
max-width: 400px;
}
.cdap-error {
color: #f85149;
text-align: center;
}
/* ── Command Log ──────────────────────────────────────────────────── */
.cdap-command-log {
background: var(--bg-secondary, #161b22);
border: 1px solid var(--border-color, #30363d);
border-radius: var(--radius-md, 8px);
overflow: hidden;
}
.cdap-command-log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
border-bottom: 1px solid var(--border-color, #30363d);
}
.cdap-command-log-header h3 {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary, #8b949e);
margin: 0;
}
.cdap-command-log-header h3 .material-icons {
font-size: 18px;
}
.cdap-command-log-entries {
max-height: 240px;
overflow-y: auto;
padding: var(--space-xs, 0.25rem) 0;
}
.cdap-log-entry {
display: flex;
align-items: center;
gap: 8px;
padding: 6px var(--space-md, 1rem);
font-size: 0.8rem;
border-bottom: 1px solid var(--border-subtle, rgba(48, 54, 61, 0.5));
}
.cdap-log-entry:last-child {
border-bottom: none;
}
.cdap-log-icon {
font-size: 16px;
}
.cdap-log-success .cdap-log-icon {
color: #3fb950;
}
.cdap-log-error .cdap-log-icon {
color: #f85149;
}
.cdap-log-time {
color: var(--text-tertiary, #6e7681);
min-width: 70px;
font-family: monospace;
}
.cdap-log-detail {
flex: 1;
color: var(--text-primary, #e6edf3);
}
.cdap-log-detail strong {
color: var(--accent-color, #58a6ff);
}
.cdap-log-error-msg {
color: #f85149;
font-size: 0.75rem;
}
.cdap-log-empty {
padding: var(--space-md, 1rem);
text-align: center;
color: var(--text-tertiary, #6e7681);
font-size: 0.8rem;
}
/* ── Unsupported Widget ───────────────────────────────────────────── */
.cdap-widget-unsupported {
padding: var(--space-sm, 0.5rem);
text-align: center;
font-size: 0.8rem;
color: var(--text-tertiary, #6e7681);
font-style: italic;
}
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 640px) {
.cdap-widget-grid {
grid-template-columns: 1fr;
}
.cdap-device-header {
flex-direction: column;
align-items: flex-start;
}
.cdap-device-meta {
flex-wrap: wrap;
}
.cdap-chart-bar-wrap {
grid-template-columns: 60px 1fr 40px;
}
}
+597
View File
@@ -0,0 +1,597 @@
/**
* BetterDesk Console - Desktop Mode Styles
* Windows-like desktop environment with floating windows, taskbar, and animations.
* Only active on viewports >= 1200px.
*/
/* ============ Desktop Toggle Button (navbar) ============ */
.desktop-toggle-btn {
display: none !important;
}
@media (min-width: 1200px) {
.desktop-toggle-btn {
display: flex !important;
}
}
/* ============ Desktop Shell ============ */
.desktop-shell {
display: none;
position: fixed;
inset: 0;
z-index: 9000;
flex-direction: column;
overflow: hidden;
}
body.desktop-active .app-layout {
display: none !important;
}
body.desktop-active .desktop-shell {
display: flex;
}
/* ============ Wallpaper ============ */
.desktop-wallpaper {
position: absolute;
inset: 0;
background-image: url('/img/betterdesk_wallpaper.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: 0;
}
.desktop-wallpaper::after {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.15);
pointer-events: none;
}
/* ============ Desktop Icons Grid ============ */
.desktop-icons {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
bottom: 56px;
z-index: 1;
display: grid;
grid-template-columns: repeat(auto-fill, 96px);
grid-template-rows: repeat(auto-fill, 96px);
gap: 8px;
align-content: start;
padding: 8px;
pointer-events: none;
}
.desktop-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 88px;
height: 88px;
border-radius: 8px;
cursor: pointer;
pointer-events: all;
user-select: none;
transition: background 0.15s ease, transform 0.15s ease;
text-decoration: none;
opacity: 0;
animation: iconAppear 0.4s ease forwards;
}
.desktop-icon:hover {
background: rgba(255, 255, 255, 0.12);
transform: scale(1.05);
}
.desktop-icon:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 0.18);
}
.desktop-icon-img {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-size: 0;
}
.desktop-icon-img .material-icons {
font-size: 28px;
color: #fff;
}
.desktop-icon-label {
font-size: 11px;
font-weight: 500;
color: #fff;
text-align: center;
line-height: 1.2;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
}
/* Stagger animation for desktop icons */
@keyframes iconAppear {
from {
opacity: 0;
transform: translateY(12px) scale(0.8);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ============ Floating Windows ============ */
.desktop-windows {
position: absolute;
inset: 0;
bottom: 48px;
z-index: 2;
pointer-events: none;
}
.desktop-window {
position: absolute;
display: flex;
flex-direction: column;
min-width: 420px;
min-height: 300px;
background: var(--bg-primary, #0d1117);
border: 1px solid var(--border-color, #30363d);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.25);
pointer-events: all;
overflow: hidden;
animation: windowOpen 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.desktop-window.closing {
animation: windowClose 0.2s ease-in forwards;
}
.desktop-window.minimizing {
animation: windowMinimize 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.desktop-window.maximized {
border-radius: 0;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.desktop-window.focused {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.55), 0 4px 12px rgba(0, 0, 0, 0.3),
0 0 0 1px var(--accent-color, #58a6ff);
}
/* ============ Window Title Bar ============ */
.window-titlebar {
display: flex;
align-items: center;
height: 38px;
min-height: 38px;
padding: 0 8px 0 12px;
background: var(--bg-secondary, #161b22);
border-bottom: 1px solid var(--border-color, #30363d);
cursor: default;
user-select: none;
gap: 8px;
}
.window-titlebar-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
flex-shrink: 0;
}
.window-titlebar-icon .material-icons {
font-size: 16px;
color: #fff;
}
.window-titlebar-text {
flex: 1;
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #e6edf3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.window-titlebar-controls {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
.window-ctrl-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-secondary, #8b949e);
cursor: pointer;
border-radius: 6px;
transition: background 0.12s ease, color 0.12s ease;
}
.window-ctrl-btn .material-icons {
font-size: 16px;
}
.window-ctrl-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary, #e6edf3);
}
.window-ctrl-btn.close-btn:hover {
background: #da3633;
color: #fff;
}
/* ============ Window Content ============ */
.window-content {
flex: 1;
position: relative;
overflow: hidden;
background: var(--bg-primary, #0d1117);
}
.window-content iframe {
width: 100%;
height: 100%;
border: none;
background: var(--bg-primary, #0d1117);
}
.window-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: var(--bg-primary, #0d1117);
z-index: 1;
transition: opacity 0.3s ease;
}
.window-loading.hidden {
opacity: 0;
pointer-events: none;
}
.window-loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color, #30363d);
border-top-color: var(--accent-color, #58a6ff);
border-radius: 50%;
animation: windowSpin 0.8s linear infinite;
}
.window-loading-text {
font-size: 12px;
color: var(--text-secondary, #8b949e);
}
@keyframes windowSpin {
to { transform: rotate(360deg); }
}
/* ============ Window Resize Edges & Corners ============ */
.window-edge {
position: absolute;
z-index: 5;
}
/* Edges */
.edge-n { top: -3px; left: 8px; right: 8px; height: 6px; cursor: ns-resize; }
.edge-s { bottom: -3px; left: 8px; right: 8px; height: 6px; cursor: ns-resize; }
.edge-e { top: 8px; right: -3px; bottom: 8px; width: 6px; cursor: ew-resize; }
.edge-w { top: 8px; left: -3px; bottom: 8px; width: 6px; cursor: ew-resize; }
/* Corners */
.edge-ne { top: -3px; right: -3px; width: 14px; height: 14px; cursor: nesw-resize; }
.edge-nw { top: -3px; left: -3px; width: 14px; height: 14px; cursor: nwse-resize; }
.edge-se { bottom: -3px; right: -3px; width: 14px; height: 14px; cursor: nwse-resize; }
.edge-sw { bottom: -3px; left: -3px; width: 14px; height: 14px; cursor: nesw-resize; }
/* Visual indicator for SE corner */
.edge-se::after {
content: '';
position: absolute;
bottom: 5px;
right: 5px;
width: 8px;
height: 8px;
border-right: 2px solid var(--text-secondary, #484f58);
border-bottom: 2px solid var(--text-secondary, #484f58);
opacity: 0.3;
transition: opacity 0.15s ease;
}
.edge-se:hover::after { opacity: 0.7; }
.desktop-window.maximized .window-edge { display: none; }
/* ============ Taskbar ============ */
.desktop-taskbar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 48px;
z-index: 100;
display: flex;
align-items: center;
background: rgba(13, 17, 23, 0.85);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-top: 1px solid rgba(48, 54, 61, 0.6);
padding: 0 8px;
gap: 4px;
animation: taskbarSlideUp 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.taskbar-start {
display: flex;
align-items: center;
padding-right: 4px;
}
.taskbar-start-btn {
width: 40px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-primary, #e6edf3);
cursor: pointer;
border-radius: 6px;
transition: background 0.15s ease;
}
.taskbar-start-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.taskbar-start-btn .material-icons {
font-size: 22px;
}
.taskbar-apps {
flex: 1;
display: flex;
align-items: center;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.taskbar-apps::-webkit-scrollbar {
display: none;
}
.taskbar-app-btn {
display: flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 12px;
border: none;
background: transparent;
color: var(--text-secondary, #8b949e);
cursor: pointer;
border-radius: 6px;
transition: background 0.15s ease, color 0.15s ease;
white-space: nowrap;
flex-shrink: 0;
font-size: 12px;
font-family: inherit;
position: relative;
}
.taskbar-app-btn::after {
content: '';
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 2px;
border-radius: 1px;
background: var(--accent-color, #58a6ff);
transition: width 0.2s ease;
}
.taskbar-app-btn.active::after {
width: 20px;
}
.taskbar-app-btn.focused {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary, #e6edf3);
}
.taskbar-app-btn.focused::after {
width: 20px;
}
.taskbar-app-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary, #e6edf3);
}
.taskbar-app-btn .material-icons {
font-size: 18px;
}
.taskbar-right {
display: flex;
align-items: center;
gap: 4px;
padding-left: 8px;
margin-left: auto;
}
.taskbar-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-secondary, #8b949e);
cursor: pointer;
border-radius: 6px;
transition: background 0.15s ease, color 0.15s ease;
}
.taskbar-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary, #e6edf3);
}
.taskbar-btn .material-icons {
font-size: 20px;
}
.taskbar-clock {
font-size: 12px;
color: var(--text-secondary, #8b949e);
min-width: 50px;
text-align: center;
padding: 0 6px;
font-variant-numeric: tabular-nums;
}
/* ============ Window Animations ============ */
@keyframes windowOpen {
from {
opacity: 0;
transform: scale(0.88);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes windowClose {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.88);
}
}
@keyframes windowMinimize {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.5) translateY(40vh);
}
}
@keyframes taskbarSlideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* ============ Desktop Activation Transition ============ */
body.desktop-entering .desktop-shell {
animation: desktopFadeIn 0.35s ease forwards;
}
body.desktop-leaving .app-layout {
animation: desktopFadeIn 0.35s ease forwards;
}
@keyframes desktopFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* ============ Embed Mode ============ */
body.embed-mode {
background: var(--bg-primary, #0d1117);
overflow: auto;
}
body.embed-mode .main-wrapper {
margin-left: 0;
padding-top: 0;
}
body.embed-mode .main-content {
padding: 16px;
max-width: 100%;
}
/* ============ Responsive ============ */
@media (max-width: 1199px) {
.desktop-shell {
display: none !important;
}
body.desktop-active .app-layout {
display: flex !important;
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+10
View File
@@ -15,6 +15,7 @@
initUserMenu();
initRefreshButton();
initRegistrationBadge();
initDesktopMode();
}
/**
@@ -176,4 +177,13 @@
setInterval(updateBadge, 30000); // every 30s
}
/**
* Desktop mode initialization
*/
function initDesktopMode() {
if (window.DesktopMode && typeof window.DesktopMode.init === 'function') {
window.DesktopMode.init();
}
}
})();
+187
View File
@@ -0,0 +1,187 @@
/**
* BetterDesk Console - CDAP Command Sender
* Handles sending commands to CDAP devices with confirmation dialogs,
* cooldown management, and command log tracking.
*/
(function () {
'use strict';
const COOLDOWN_MS = 1000;
const MAX_LOG_ENTRIES = 50;
const lastCommandTime = {};
const commandLog = [];
// ── Command Sending ──────────────────────────────────────────────────
async function send(deviceId, widgetId, action, value, reason) {
// Cooldown check per widget
const key = `${deviceId}:${widgetId}`;
const now = Date.now();
if (lastCommandTime[key] && (now - lastCommandTime[key]) < COOLDOWN_MS) {
return;
}
lastCommandTime[key] = now;
const csrfToken = window.BetterDesk?.csrfToken || '';
try {
const res = await fetch(`/api/cdap/devices/${encodeURIComponent(deviceId)}/command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
widget_id: widgetId,
action: action,
value: value,
reason: reason || undefined
})
});
const data = await res.json();
const success = res.ok && data.success;
addLogEntry({
time: new Date(),
widgetId,
action,
value,
success,
error: success ? null : (data.error || 'Unknown error')
});
if (!success) {
showToast(data.error || 'Command failed', 'error');
}
return data;
} catch (err) {
addLogEntry({
time: new Date(),
widgetId,
action,
value,
success: false,
error: err.message
});
showToast('Failed to send command', 'error');
return null;
}
}
function sendWithConfirm(deviceId, widgetId, action, value, confirmMsg) {
const __ = window.BetterDesk?.translations || {};
const title = __?.cdap?.confirm_command || 'Confirm Command';
if (!window.BetterDeskModal) {
if (confirm(confirmMsg || title)) {
return send(deviceId, widgetId, action, value);
}
return Promise.resolve(null);
}
return new Promise((resolve) => {
window.BetterDeskModal.confirm({
title: title,
message: confirmMsg || `${action}${widgetId}?`,
confirmText: __?.common?.confirm || 'Confirm',
cancelText: __?.common?.cancel || 'Cancel',
type: 'warning',
onConfirm: async () => {
const result = await send(deviceId, widgetId, action, value);
resolve(result);
},
onCancel: () => resolve(null)
});
});
}
// ── Command Log ──────────────────────────────────────────────────────
function addLogEntry(entry) {
commandLog.unshift(entry);
if (commandLog.length > MAX_LOG_ENTRIES) commandLog.pop();
renderLog();
}
function renderLog() {
const container = document.getElementById('cdap-log-entries');
if (!container) return;
let html = '';
for (const entry of commandLog) {
const time = entry.time.toLocaleTimeString();
const icon = entry.success ? 'check_circle' : 'error';
const cls = entry.success ? 'cdap-log-success' : 'cdap-log-error';
const valueStr = entry.value !== null && entry.value !== undefined
? ` = ${escapeHtml(String(entry.value))}`
: '';
const errorStr = entry.error
? `<span class="cdap-log-error-msg">${escapeHtml(entry.error)}</span>`
: '';
html += `
<div class="cdap-log-entry ${cls}">
<span class="material-icons cdap-log-icon">${icon}</span>
<span class="cdap-log-time">${time}</span>
<span class="cdap-log-detail">
<strong>${escapeHtml(entry.widgetId)}</strong>
${escapeHtml(entry.action)}${valueStr}
</span>
${errorStr}
</div>
`;
}
container.innerHTML = html || '<div class="cdap-log-empty">No commands sent yet</div>';
}
function clearLog() {
commandLog.length = 0;
renderLog();
}
// ── Init ─────────────────────────────────────────────────────────────
function init() {
const clearBtn = document.getElementById('cdap-clear-log');
if (clearBtn) {
clearBtn.addEventListener('click', clearLog);
}
renderLog();
}
// ── Utilities ────────────────────────────────────────────────────────
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
function showToast(message, type) {
if (window.BetterDeskNotifications?.show) {
window.BetterDeskNotifications.show(message, type);
}
}
// ── Public API ───────────────────────────────────────────────────────
window.CDAPCommands = {
send,
sendWithConfirm,
clearLog,
getLog: () => [...commandLog]
};
// Auto-init
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+629
View File
@@ -0,0 +1,629 @@
/**
* BetterDesk Console - CDAP Widget Renderer
* Renders device widgets based on CDAP manifest and polls state updates.
* Supports Phase 2 widget types: toggle, gauge, button, led, text, slider, select, chart.
*/
(function () {
'use strict';
const __ = window.BetterDesk?.translations || {};
const t = (key) => {
const parts = key.split('.');
let val = __;
for (const p of parts) {
val = val?.[p];
}
return val || key;
};
const STATE_POLL_INTERVAL = 3000;
const INFO_POLL_INTERVAL = 10000;
let deviceId = '';
let manifest = null;
let widgetState = {};
let statePollTimer = null;
let infoPollTimer = null;
let isConnected = false;
// ── Initialization ───────────────────────────────────────────────────
function init() {
const page = document.querySelector('.cdap-device-page');
if (!page) return;
deviceId = page.dataset.deviceId;
if (!deviceId) return;
loadDeviceInfo();
loadManifestAndState();
}
async function loadDeviceInfo() {
try {
const res = await fetch(`/api/cdap/devices/${encodeURIComponent(deviceId)}`, {
headers: { 'X-CSRF-Token': window.BetterDesk?.csrfToken || '' }
});
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();
if (data.success && data.data) {
updateDeviceHeader(data.data);
}
} catch (err) {
console.error('CDAP device info error:', err);
}
// Schedule periodic info refresh
if (!infoPollTimer) {
infoPollTimer = setInterval(loadDeviceInfo, INFO_POLL_INTERVAL);
}
}
function updateDeviceHeader(info) {
isConnected = !!info.connected;
// Device name (prefer manifest name or hostname)
const nameEl = document.getElementById('cdap-device-name');
if (nameEl) {
nameEl.textContent = info.manifest?.device?.name || info.hostname || deviceId;
}
// Device type
const typeEl = document.getElementById('cdap-device-type');
if (typeEl && info.manifest?.device?.type) {
const iconMap = {
scada: 'factory',
iot: 'sensors',
os_agent: 'computer',
network: 'router',
camera: 'videocam',
desktop: 'desktop_windows',
custom: 'memory'
};
const icon = iconMap[info.manifest.device.type] || 'memory';
typeEl.innerHTML = `<span class="material-icons">${icon}</span><span>${info.manifest.device.type}</span>`;
}
// Version
const verEl = document.getElementById('cdap-device-version');
if (verEl && info.manifest?.device?.firmware_version) {
verEl.innerHTML = `<span class="material-icons">info_outline</span><span>v${escapeHtml(info.manifest.device.firmware_version)}</span>`;
}
// Uptime
const uptimeEl = document.getElementById('cdap-device-uptime');
if (uptimeEl && info.connected_at) {
const uptime = formatDuration(Date.now() - new Date(info.connected_at).getTime());
uptimeEl.innerHTML = `<span class="material-icons">schedule</span><span>${uptime}</span>`;
}
// Status indicator
const statusEl = document.getElementById('cdap-device-status');
if (statusEl) {
statusEl.className = `cdap-device-status ${isConnected ? 'online' : 'offline'}`;
statusEl.innerHTML = `
<span class="cdap-status-dot"></span>
<span class="cdap-status-text">${isConnected ? t('cdap.connected') : t('cdap.disconnected')}</span>
`;
}
// Offline banner
const banner = document.getElementById('cdap-offline-banner');
if (banner) {
banner.classList.toggle('hidden', isConnected);
}
}
async function loadManifestAndState() {
const loading = document.getElementById('cdap-loading');
const grid = document.getElementById('cdap-widget-grid');
const empty = document.getElementById('cdap-empty');
try {
// Fetch manifest and state in parallel
const [manifestRes, stateRes] = await Promise.all([
fetch(`/api/cdap/devices/${encodeURIComponent(deviceId)}/manifest`, {
headers: { 'X-CSRF-Token': window.BetterDesk?.csrfToken || '' }
}),
fetch(`/api/cdap/devices/${encodeURIComponent(deviceId)}/state`, {
headers: { 'X-CSRF-Token': window.BetterDesk?.csrfToken || '' }
})
]);
if (manifestRes.ok) {
const mData = await manifestRes.json();
if (mData.success) manifest = mData.data;
}
if (stateRes.ok) {
const sData = await stateRes.json();
if (sData.success) widgetState = sData.data || {};
}
if (loading) loading.classList.add('hidden');
if (!manifest || !manifest.widgets || manifest.widgets.length === 0) {
if (empty) empty.classList.remove('hidden');
return;
}
renderWidgets();
startStatePolling();
} catch (err) {
console.error('CDAP manifest/state error:', err);
if (loading) {
loading.innerHTML = `<p class="cdap-error">${t('cdap.load_error')}</p>`;
}
}
}
// ── Widget Rendering ─────────────────────────────────────────────────
function renderWidgets() {
const grid = document.getElementById('cdap-widget-grid');
if (!grid || !manifest?.widgets) return;
// Remove loading state
const loading = document.getElementById('cdap-loading');
if (loading) loading.remove();
// Group widgets by category if categories exist
const widgets = manifest.widgets;
const grouped = groupByCategory(widgets);
let html = '';
for (const [category, catWidgets] of Object.entries(grouped)) {
if (category !== '_default') {
html += `<div class="cdap-widget-category"><h3>${escapeHtml(category)}</h3></div>`;
}
for (const widget of catWidgets) {
html += renderWidget(widget);
}
}
grid.innerHTML = html;
// Apply initial state values
applyState(widgetState);
// Show command log if any interactive widgets
const hasInteractive = widgets.some(w =>
['toggle', 'button', 'slider', 'select'].includes(w.type)
);
if (hasInteractive) {
const log = document.getElementById('cdap-command-log');
if (log) log.classList.remove('hidden');
}
// Bind widget event handlers
bindWidgetEvents();
}
function groupByCategory(widgets) {
const groups = {};
for (const w of widgets) {
const cat = w.category || '_default';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(w);
}
return groups;
}
function renderWidget(widget) {
const { id, type, label, unit, read_only } = widget;
const safeId = escapeHtml(id);
const safeLabel = escapeHtml(label || id);
const readOnlyClass = read_only ? ' cdap-widget-readonly' : '';
const sizeClass = getWidgetSizeClass(type, widget);
let inner = '';
switch (type) {
case 'toggle':
inner = renderToggle(widget);
break;
case 'gauge':
inner = renderGauge(widget);
break;
case 'button':
inner = renderButton(widget);
break;
case 'led':
inner = renderLed(widget);
break;
case 'text':
inner = renderText(widget);
break;
case 'slider':
inner = renderSlider(widget);
break;
case 'select':
inner = renderSelect(widget);
break;
case 'chart':
inner = renderChart(widget);
break;
default:
inner = `<div class="cdap-widget-unsupported">${escapeHtml(type)}</div>`;
}
return `
<div class="cdap-widget ${sizeClass}${readOnlyClass}" data-widget-id="${safeId}" data-widget-type="${escapeHtml(type)}">
<div class="cdap-widget-header">
<span class="cdap-widget-label">${safeLabel}</span>
${unit ? `<span class="cdap-widget-unit">${escapeHtml(unit)}</span>` : ''}
</div>
<div class="cdap-widget-body">
${inner}
</div>
</div>
`;
}
function getWidgetSizeClass(type, widget) {
if (widget.size === 'large') return 'cdap-widget-lg';
if (widget.size === 'small') return 'cdap-widget-sm';
// Default sizes by type
switch (type) {
case 'chart': return 'cdap-widget-lg';
case 'text': return 'cdap-widget-sm';
case 'led': return 'cdap-widget-sm';
default: return '';
}
}
// ── Individual Widget Renderers ──────────────────────────────────────
function renderToggle(widget) {
const disabled = widget.read_only ? 'disabled' : '';
return `
<label class="cdap-toggle">
<input type="checkbox" class="cdap-toggle-input" data-action="set" ${disabled}>
<span class="cdap-toggle-slider"></span>
</label>
<span class="cdap-toggle-label" id="wval-${escapeHtml(widget.id)}"></span>
`;
}
function renderGauge(widget) {
const min = widget.min ?? 0;
const max = widget.max ?? 100;
return `
<div class="cdap-gauge">
<div class="cdap-gauge-bar">
<div class="cdap-gauge-fill" id="wbar-${escapeHtml(widget.id)}" style="width: 0%"></div>
</div>
<div class="cdap-gauge-value">
<span class="cdap-gauge-number" id="wval-${escapeHtml(widget.id)}"></span>
<span class="cdap-gauge-range">${min} ${max}</span>
</div>
</div>
`;
}
function renderButton(widget) {
const icon = widget.icon || 'play_arrow';
const confirmText = widget.confirm ? `data-confirm="${escapeHtml(widget.confirm)}"` : '';
return `
<button class="btn cdap-action-btn" data-action="trigger" ${confirmText}>
<span class="material-icons">${escapeHtml(icon)}</span>
<span>${escapeHtml(widget.label || widget.id)}</span>
</button>
`;
}
function renderLed(widget) {
return `
<div class="cdap-led" id="wled-${escapeHtml(widget.id)}">
<div class="cdap-led-light off"></div>
<span class="cdap-led-label" id="wval-${escapeHtml(widget.id)}"></span>
</div>
`;
}
function renderText(widget) {
return `
<div class="cdap-text-value" id="wval-${escapeHtml(widget.id)}"></div>
`;
}
function renderSlider(widget) {
const min = widget.min ?? 0;
const max = widget.max ?? 100;
const step = widget.step ?? 1;
const disabled = widget.read_only ? 'disabled' : '';
return `
<div class="cdap-slider-wrap">
<input type="range" class="cdap-slider-input"
min="${min}" max="${max}" step="${step}" value="${min}"
data-action="set" ${disabled}>
<div class="cdap-slider-labels">
<span>${min}</span>
<span class="cdap-slider-value" id="wval-${escapeHtml(widget.id)}">${min}</span>
<span>${max}</span>
</div>
</div>
`;
}
function renderSelect(widget) {
const options = widget.options || [];
const disabled = widget.read_only ? 'disabled' : '';
let optHtml = `<option value="">— ${t('cdap.select_option')} —</option>`;
for (const opt of options) {
const val = typeof opt === 'object' ? opt.value : opt;
const label = typeof opt === 'object' ? (opt.label || opt.value) : opt;
optHtml += `<option value="${escapeHtml(String(val))}">${escapeHtml(String(label))}</option>`;
}
return `
<select class="form-input cdap-select-input" data-action="set" ${disabled}>
${optHtml}
</select>
`;
}
function renderChart(widget) {
// Phase 2: simple bar-style multi-value chart
const series = widget.series || [];
let barsHtml = '';
for (const s of series) {
barsHtml += `
<div class="cdap-chart-bar-wrap" data-series="${escapeHtml(s.key || s.label || '')}">
<div class="cdap-chart-bar-label">${escapeHtml(s.label || s.key || '')}</div>
<div class="cdap-chart-bar-track">
<div class="cdap-chart-bar-fill" id="wbar-${escapeHtml(widget.id)}-${escapeHtml(s.key || '')}" style="width: 0%"></div>
</div>
<div class="cdap-chart-bar-value" id="wval-${escapeHtml(widget.id)}-${escapeHtml(s.key || '')}"></div>
</div>
`;
}
return `<div class="cdap-chart-bars">${barsHtml}</div>`;
}
// ── State Polling & Application ──────────────────────────────────────
function startStatePolling() {
if (statePollTimer) clearInterval(statePollTimer);
statePollTimer = setInterval(pollState, STATE_POLL_INTERVAL);
}
async function pollState() {
try {
const res = await fetch(`/api/cdap/devices/${encodeURIComponent(deviceId)}/state`, {
headers: { 'X-CSRF-Token': window.BetterDesk?.csrfToken || '' }
});
if (!res.ok) return;
const data = await res.json();
if (data.success && data.data) {
widgetState = data.data;
applyState(widgetState);
}
} catch (err) {
// Silent fail — device may be offline
}
}
function applyState(state) {
if (!state || !manifest?.widgets) return;
for (const widget of manifest.widgets) {
const val = state[widget.id];
if (val === undefined) continue;
const el = document.querySelector(`[data-widget-id="${CSS.escape(widget.id)}"]`);
if (!el) continue;
switch (widget.type) {
case 'toggle':
applyToggleState(el, widget, val);
break;
case 'gauge':
applyGaugeState(el, widget, val);
break;
case 'led':
applyLedState(el, widget, val);
break;
case 'text':
applyTextState(el, widget, val);
break;
case 'slider':
applySliderState(el, widget, val);
break;
case 'select':
applySelectState(el, widget, val);
break;
case 'chart':
applyChartState(el, widget, val);
break;
}
}
}
function applyToggleState(el, widget, val) {
const input = el.querySelector('.cdap-toggle-input');
const label = document.getElementById(`wval-${widget.id}`);
const checked = val === true || val === 1 || val === 'on' || val === 'true';
if (input && !input._userInteracting) input.checked = checked;
if (label) label.textContent = checked ? 'ON' : 'OFF';
}
function applyGaugeState(el, widget, val) {
const num = parseFloat(val);
if (isNaN(num)) return;
const min = widget.min ?? 0;
const max = widget.max ?? 100;
const pct = Math.min(100, Math.max(0, ((num - min) / (max - min)) * 100));
const bar = document.getElementById(`wbar-${widget.id}`);
const valEl = document.getElementById(`wval-${widget.id}`);
if (bar) {
bar.style.width = pct + '%';
// Color based on thresholds
if (pct > 90) bar.className = 'cdap-gauge-fill cdap-gauge-danger';
else if (pct > 70) bar.className = 'cdap-gauge-fill cdap-gauge-warning';
else bar.className = 'cdap-gauge-fill';
}
if (valEl) valEl.textContent = num.toFixed(widget.decimals ?? 1);
}
function applyLedState(el, widget, val) {
const light = el.querySelector('.cdap-led-light');
const label = document.getElementById(`wval-${widget.id}`);
const on = val === true || val === 1 || val === 'on' || val === 'true';
if (light) {
light.className = `cdap-led-light ${on ? 'on' : 'off'}`;
if (typeof val === 'string' && val.startsWith('#')) {
light.style.backgroundColor = val;
light.className = 'cdap-led-light on';
}
}
if (label) label.textContent = typeof val === 'string' ? val : (on ? 'ON' : 'OFF');
}
function applyTextState(el, widget, val) {
const valEl = document.getElementById(`wval-${widget.id}`);
if (valEl) valEl.textContent = String(val);
}
function applySliderState(el, widget, val) {
const input = el.querySelector('.cdap-slider-input');
const valEl = document.getElementById(`wval-${widget.id}`);
const num = parseFloat(val);
if (isNaN(num)) return;
if (input && !input._userInteracting) input.value = num;
if (valEl) valEl.textContent = num.toFixed(widget.decimals ?? 0);
}
function applySelectState(el, widget, val) {
const select = el.querySelector('.cdap-select-input');
if (select && !select._userInteracting) select.value = String(val);
}
function applyChartState(el, widget, val) {
if (typeof val !== 'object') return;
const series = widget.series || [];
for (const s of series) {
const key = s.key || s.label || '';
const seriesVal = val[key];
if (seriesVal === undefined) continue;
const num = parseFloat(seriesVal);
if (isNaN(num)) continue;
const min = s.min ?? 0;
const max = s.max ?? 100;
const pct = Math.min(100, Math.max(0, ((num - min) / (max - min)) * 100));
const bar = document.getElementById(`wbar-${widget.id}-${key}`);
const valEl = document.getElementById(`wval-${widget.id}-${key}`);
if (bar) bar.style.width = pct + '%';
if (valEl) valEl.textContent = num.toFixed(1);
}
}
// ── Event Binding ────────────────────────────────────────────────────
function bindWidgetEvents() {
// Toggle switches
document.querySelectorAll('.cdap-toggle-input').forEach(input => {
input.addEventListener('change', (e) => {
const widgetEl = e.target.closest('.cdap-widget');
if (!widgetEl) return;
const wid = widgetEl.dataset.widgetId;
window.CDAPCommands?.send(deviceId, wid, 'set', e.target.checked);
});
// Prevent state polling from overriding user interaction
input.addEventListener('mousedown', () => { input._userInteracting = true; });
input.addEventListener('change', () => { setTimeout(() => { input._userInteracting = false; }, 2000); });
});
// Action buttons
document.querySelectorAll('.cdap-action-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const widgetEl = e.target.closest('.cdap-widget');
if (!widgetEl) return;
const wid = widgetEl.dataset.widgetId;
const confirm = btn.dataset.confirm;
if (confirm) {
window.CDAPCommands?.sendWithConfirm(deviceId, wid, 'trigger', null, confirm);
} else {
window.CDAPCommands?.send(deviceId, wid, 'trigger', null);
}
});
});
// Sliders (debounced)
document.querySelectorAll('.cdap-slider-input').forEach(input => {
let debounce = null;
input.addEventListener('input', (e) => {
const widgetEl = e.target.closest('.cdap-widget');
if (!widgetEl) return;
const wid = widgetEl.dataset.widgetId;
const valEl = document.getElementById(`wval-${wid}`);
if (valEl) valEl.textContent = e.target.value;
input._userInteracting = true;
clearTimeout(debounce);
debounce = setTimeout(() => {
window.CDAPCommands?.send(deviceId, wid, 'set', parseFloat(e.target.value));
setTimeout(() => { input._userInteracting = false; }, 2000);
}, 300);
});
});
// Selects
document.querySelectorAll('.cdap-select-input').forEach(select => {
select.addEventListener('change', (e) => {
const widgetEl = e.target.closest('.cdap-widget');
if (!widgetEl) return;
const wid = widgetEl.dataset.widgetId;
select._userInteracting = true;
window.CDAPCommands?.send(deviceId, wid, 'set', e.target.value);
setTimeout(() => { select._userInteracting = false; }, 2000);
});
});
}
// ── Utilities ────────────────────────────────────────────────────────
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
function formatDuration(ms) {
if (ms < 0) ms = 0;
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
if (d > 0) return `${d}d ${h % 24}h`;
if (h > 0) return `${h}h ${m % 60}m`;
if (m > 0) return `${m}m`;
return `${s}s`;
}
// ── Public API ───────────────────────────────────────────────────────
window.CDAPWidgets = {
init,
refresh: loadManifestAndState,
getState: () => widgetState,
getManifest: () => manifest,
isDeviceConnected: () => isConnected
};
// Auto-init on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (statePollTimer) clearInterval(statePollTimer);
if (infoPollTimer) clearInterval(infoPollTimer);
});
})();
+805
View File
@@ -0,0 +1,805 @@
/**
* BetterDesk Console - Desktop Mode
* Windows-like desktop environment with floating windows, taskbar, and app icons.
* Available on viewports >= 1200px.
*/
(function() {
'use strict';
// ============ Constants ============
const MIN_WIDTH = 420;
const MIN_HEIGHT = 300;
const TASKBAR_HEIGHT = 48;
const BREAKPOINT = 1200;
const STORAGE_KEY = 'betterdesk_desktop_mode';
const STORAGE_WINS_KEY = 'betterdesk_desktop_wins';
const CASCADE_OFFSET = 32;
// ============ State ============
let active = false;
let windows = new Map();
let zCounter = 100;
let focusedWindowId = null;
let cascadeIndex = 0;
let dragState = null;
let resizeState = null;
// ============ Apps Definition ============
function getApps() {
var t = typeof _ === 'function' ? _ : function(k) { return k; };
var isAdmin = window.BetterDesk && window.BetterDesk.user &&
window.BetterDesk.user.role === 'admin';
var apps = [
{ id: 'dashboard', icon: 'dashboard', route: '/', color: '#58a6ff', name: t('nav.dashboard') },
{ id: 'devices', icon: 'devices', route: '/devices', color: '#3fb950', name: t('nav.devices') },
{ id: 'registrations', icon: 'how_to_reg', route: '/registrations', color: '#79c0ff', name: t('nav.registrations') },
{ id: 'keys', icon: 'vpn_key', route: '/keys', color: '#d29922', name: t('nav.keys') },
{ id: 'generator', icon: 'build', route: '/generator', color: '#bc8cff', name: t('nav.generator') },
{ id: 'settings', icon: 'settings', route: '/settings', color: '#8b949e', name: t('nav.settings') }
];
if (isAdmin) {
apps.splice(5, 0, {
id: 'users', icon: 'group', route: '/users', color: '#f778ba', name: t('nav.users')
});
}
return apps;
}
// ============ Initialization ============
function init() {
if (window.BetterDesk && window.BetterDesk.embed) return;
if (window.innerWidth < BREAKPOINT) return;
setupGlobalListeners();
if (localStorage.getItem(STORAGE_KEY) === 'true' && window.innerWidth >= BREAKPOINT) {
activate(true);
}
}
function setupGlobalListeners() {
// Navbar toggle button
var btn = document.getElementById('desktop-toggle-btn');
if (btn) {
btn.addEventListener('click', function() { toggle(); });
}
// Taskbar console button
var consoleBtn = document.getElementById('taskbar-console-btn');
if (consoleBtn) {
consoleBtn.addEventListener('click', function() { deactivate(); });
}
// Taskbar start button
var startBtn = document.getElementById('taskbar-start-btn');
if (startBtn) {
startBtn.addEventListener('click', function() { openStartMenu(); });
}
// Global mouse events for drag/resize
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Responsive: deactivate if viewport shrinks below breakpoint
window.addEventListener('resize', Utils.debounce(function() {
if (active && window.innerWidth < BREAKPOINT) {
deactivate(true);
}
}, 200));
}
// ============ Activate / Deactivate ============
function activate(skipAnimation) {
if (active) return;
active = true;
localStorage.setItem(STORAGE_KEY, 'true');
document.body.classList.add('desktop-active');
if (!skipAnimation) {
document.body.classList.add('desktop-entering');
setTimeout(function() {
document.body.classList.remove('desktop-entering');
}, 350);
}
renderDesktopIcons();
startClock();
}
function deactivate(silent) {
if (!active) return;
active = false;
localStorage.setItem(STORAGE_KEY, 'false');
// Close all windows
windows.forEach(function(win) {
removeWindowDOM(win.id, true);
});
windows.clear();
focusedWindowId = null;
cascadeIndex = 0;
document.body.classList.remove('desktop-active', 'desktop-entering');
stopClock();
clearDesktopIcons();
clearTaskbar();
if (!silent) {
// Reload to restore console view properly
window.location.reload();
}
}
function toggle() {
if (active) {
deactivate();
} else {
activate();
}
}
// ============ Desktop Icons ============
function renderDesktopIcons() {
var container = document.getElementById('desktop-icons');
if (!container) return;
container.innerHTML = '';
var apps = getApps();
apps.forEach(function(app, index) {
var el = document.createElement('div');
el.className = 'desktop-icon';
el.setAttribute('data-app', app.id);
el.style.animationDelay = (index * 0.05) + 's';
el.innerHTML =
'<div class="desktop-icon-img" style="background:' + app.color + '">' +
'<span class="material-icons">' + app.icon + '</span>' +
'</div>' +
'<span class="desktop-icon-label">' + escapeHtml(app.name) + '</span>';
el.addEventListener('dblclick', function() {
openApp(app);
});
container.appendChild(el);
});
}
function clearDesktopIcons() {
var container = document.getElementById('desktop-icons');
if (container) container.innerHTML = '';
}
// ============ Start Menu ============
function openStartMenu() {
// Simple: open a small overlay with app list near taskbar
var existing = document.getElementById('desktop-start-menu');
if (existing) {
existing.remove();
return;
}
var apps = getApps();
var menu = document.createElement('div');
menu.id = 'desktop-start-menu';
menu.style.cssText =
'position:fixed;bottom:52px;left:8px;z-index:9999;' +
'background:rgba(13,17,23,0.92);backdrop-filter:blur(20px);' +
'border:1px solid rgba(48,54,61,0.6);border-radius:10px;' +
'padding:8px;min-width:220px;' +
'animation:windowOpen 0.2s cubic-bezier(0.34,1.56,0.64,1) forwards;';
apps.forEach(function(app) {
var item = document.createElement('div');
item.style.cssText =
'display:flex;align-items:center;gap:10px;padding:8px 12px;' +
'border-radius:6px;cursor:pointer;color:var(--text-primary,#e6edf3);' +
'font-size:13px;transition:background 0.12s ease;';
item.innerHTML =
'<span class="material-icons" style="font-size:20px;color:' + app.color + '">' +
app.icon +
'</span>' +
'<span>' + escapeHtml(app.name) + '</span>';
item.addEventListener('mouseenter', function() {
item.style.background = 'rgba(255,255,255,0.08)';
});
item.addEventListener('mouseleave', function() {
item.style.background = 'transparent';
});
item.addEventListener('click', function() {
menu.remove();
openApp(app);
});
menu.appendChild(item);
});
// Close button
var closeItem = document.createElement('div');
closeItem.style.cssText =
'display:flex;align-items:center;gap:10px;padding:8px 12px;' +
'border-radius:6px;cursor:pointer;color:var(--accent-red,#da3633);' +
'font-size:13px;transition:background 0.12s ease;margin-top:4px;' +
'border-top:1px solid rgba(48,54,61,0.4);padding-top:12px;';
var t = typeof _ === 'function' ? _ : function(k) { return k; };
closeItem.innerHTML =
'<span class="material-icons" style="font-size:20px">view_sidebar</span>' +
'<span>' + escapeHtml(t('desktop.console_mode')) + '</span>';
closeItem.addEventListener('mouseenter', function() {
closeItem.style.background = 'rgba(255,255,255,0.08)';
});
closeItem.addEventListener('mouseleave', function() {
closeItem.style.background = 'transparent';
});
closeItem.addEventListener('click', function() {
menu.remove();
deactivate();
});
menu.appendChild(closeItem);
document.body.appendChild(menu);
// Close on outside click
setTimeout(function() {
function closeMenu(e) {
if (!menu.contains(e.target) && e.target.id !== 'taskbar-start-btn' &&
!e.target.closest('#taskbar-start-btn')) {
menu.remove();
document.removeEventListener('click', closeMenu);
}
}
document.addEventListener('click', closeMenu);
}, 10);
}
// ============ Window Management ============
function openApp(app) {
// Check if window already open for this app
var existingId = null;
windows.forEach(function(win, id) {
if (win.appId === app.id) existingId = id;
});
if (existingId) {
var win = windows.get(existingId);
if (win.minimized) {
restoreWindow(existingId);
}
focusWindow(existingId);
return;
}
createWindow(app);
}
function createWindow(app) {
var id = 'win-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
// Calculate position (cascading)
var area = getDesktopArea();
var width = Math.min(960, area.width - 80);
var height = Math.min(640, area.height - 80);
var x = area.x + 60 + (cascadeIndex * CASCADE_OFFSET) % (area.width - width - 60);
var y = area.y + 40 + (cascadeIndex * CASCADE_OFFSET) % (area.height - height - 40);
cascadeIndex++;
var win = {
id: id,
appId: app.id,
app: app,
x: x,
y: y,
width: width,
height: height,
minimized: false,
maximized: false,
prevBounds: null,
zIndex: ++zCounter
};
windows.set(id, win);
renderWindow(win);
focusWindow(id);
updateTaskbar();
}
function renderWindow(win) {
var container = document.getElementById('desktop-windows');
if (!container) return;
var el = document.createElement('div');
el.className = 'desktop-window focused';
el.id = win.id;
el.style.left = win.x + 'px';
el.style.top = win.y + 'px';
el.style.width = win.width + 'px';
el.style.height = win.height + 'px';
el.style.zIndex = win.zIndex;
var t = typeof _ === 'function' ? _ : function(k) { return k; };
el.innerHTML =
'<div class="window-titlebar" data-win="' + win.id + '">' +
'<div class="window-titlebar-icon" style="background:' + win.app.color + '">' +
'<span class="material-icons">' + win.app.icon + '</span>' +
'</div>' +
'<div class="window-titlebar-text">' + escapeHtml(win.app.name) + '</div>' +
'<div class="window-titlebar-controls">' +
'<button class="window-ctrl-btn minimize-btn" data-action="minimize" title="' + escapeAttr(t('desktop.minimize')) + '">' +
'<span class="material-icons">minimize</span>' +
'</button>' +
'<button class="window-ctrl-btn maximize-btn" data-action="maximize" title="' + escapeAttr(t('desktop.maximize')) + '">' +
'<span class="material-icons">crop_square</span>' +
'</button>' +
'<button class="window-ctrl-btn close-btn" data-action="close" title="' + escapeAttr(t('desktop.close')) + '">' +
'<span class="material-icons">close</span>' +
'</button>' +
'</div>' +
'</div>' +
'<div class="window-content">' +
'<div class="window-loading">' +
'<div class="window-loading-spinner"></div>' +
'<div class="window-loading-text">' + escapeHtml(t('desktop.loading')) + '</div>' +
'</div>' +
'<iframe src="' + escapeAttr(win.app.route + '?embed=1') + '" ' +
'sandbox="allow-same-origin allow-scripts allow-forms allow-popups" ' +
'loading="lazy"></iframe>' +
'</div>' +
'<div class="window-edge edge-n" data-dir="n"></div>' +
'<div class="window-edge edge-s" data-dir="s"></div>' +
'<div class="window-edge edge-e" data-dir="e"></div>' +
'<div class="window-edge edge-w" data-dir="w"></div>' +
'<div class="window-edge edge-ne" data-dir="ne"></div>' +
'<div class="window-edge edge-nw" data-dir="nw"></div>' +
'<div class="window-edge edge-se" data-dir="se"></div>' +
'<div class="window-edge edge-sw" data-dir="sw"></div>';
// Event: focus on click
el.addEventListener('mousedown', function(e) {
if (!e.target.closest('.window-ctrl-btn')) {
focusWindow(win.id);
}
});
// Event: title bar controls
el.querySelectorAll('.window-ctrl-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
var action = btn.getAttribute('data-action');
if (action === 'minimize') minimizeWindow(win.id);
else if (action === 'maximize') toggleMaximize(win.id);
else if (action === 'close') closeWindow(win.id);
});
});
// Event: drag via title bar
var titlebar = el.querySelector('.window-titlebar');
titlebar.addEventListener('mousedown', function(e) {
if (e.target.closest('.window-ctrl-btn')) return;
startDrag(win.id, e);
});
// Event: double-click title bar to maximize
titlebar.addEventListener('dblclick', function(e) {
if (e.target.closest('.window-ctrl-btn')) return;
toggleMaximize(win.id);
});
// Event: resize edges/corners
el.querySelectorAll('.window-edge').forEach(function(edge) {
edge.addEventListener('mousedown', function(e) {
e.stopPropagation();
startResize(win.id, e, edge.getAttribute('data-dir'));
});
});
// Event: iframe loaded
var iframe = el.querySelector('iframe');
var loadingOverlay = el.querySelector('.window-loading');
iframe.addEventListener('load', function() {
loadingOverlay.classList.add('hidden');
});
container.appendChild(el);
}
function closeWindow(id) {
var el = document.getElementById(id);
if (!el) {
windows.delete(id);
updateTaskbar();
return;
}
el.classList.add('closing');
el.addEventListener('animationend', function() {
removeWindowDOM(id, false);
}, { once: true });
}
function removeWindowDOM(id, immediate) {
var el = document.getElementById(id);
if (el) {
// Destroy iframe to free memory
var iframe = el.querySelector('iframe');
if (iframe) iframe.src = 'about:blank';
el.remove();
}
windows.delete(id);
if (focusedWindowId === id) {
focusedWindowId = null;
// Focus next topmost window
var topWin = null;
windows.forEach(function(w) {
if (!w.minimized && (!topWin || w.zIndex > topWin.zIndex)) {
topWin = w;
}
});
if (topWin) focusWindow(topWin.id);
}
updateTaskbar();
}
function minimizeWindow(id) {
var win = windows.get(id);
if (!win) return;
win.minimized = true;
var el = document.getElementById(id);
if (el) {
el.classList.add('minimizing');
el.addEventListener('animationend', function() {
el.style.display = 'none';
el.classList.remove('minimizing');
}, { once: true });
}
if (focusedWindowId === id) {
focusedWindowId = null;
// Focus next topmost visible window
var topWin = null;
windows.forEach(function(w) {
if (!w.minimized && w.id !== id && (!topWin || w.zIndex > topWin.zIndex)) {
topWin = w;
}
});
if (topWin) focusWindow(topWin.id);
}
updateTaskbar();
}
function restoreWindow(id) {
var win = windows.get(id);
if (!win) return;
win.minimized = false;
var el = document.getElementById(id);
if (el) {
el.style.display = '';
el.style.animation = 'none';
// Force reflow
el.offsetHeight;
el.style.animation = '';
el.classList.remove('minimizing');
// Re-trigger open animation
el.style.animation = 'windowOpen 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards';
}
focusWindow(id);
updateTaskbar();
}
function toggleMaximize(id) {
var win = windows.get(id);
if (!win) return;
var el = document.getElementById(id);
if (!el) return;
if (win.maximized) {
// Restore
win.maximized = false;
el.classList.remove('maximized');
if (win.prevBounds) {
el.style.left = win.prevBounds.x + 'px';
el.style.top = win.prevBounds.y + 'px';
el.style.width = win.prevBounds.width + 'px';
el.style.height = win.prevBounds.height + 'px';
win.x = win.prevBounds.x;
win.y = win.prevBounds.y;
win.width = win.prevBounds.width;
win.height = win.prevBounds.height;
}
} else {
// Maximize
win.prevBounds = { x: win.x, y: win.y, width: win.width, height: win.height };
win.maximized = true;
var area = getDesktopArea();
el.classList.add('maximized');
el.style.left = area.x + 'px';
el.style.top = area.y + 'px';
el.style.width = area.width + 'px';
el.style.height = area.height + 'px';
win.x = area.x;
win.y = area.y;
win.width = area.width;
win.height = area.height;
}
// Update maximize button icon
var maxBtn = el.querySelector('.maximize-btn .material-icons');
if (maxBtn) {
maxBtn.textContent = win.maximized ? 'filter_none' : 'crop_square';
}
}
function focusWindow(id) {
if (focusedWindowId === id) return;
// Unfocus previous
if (focusedWindowId) {
var prevEl = document.getElementById(focusedWindowId);
if (prevEl) prevEl.classList.remove('focused');
}
focusedWindowId = id;
var win = windows.get(id);
if (!win) return;
win.zIndex = ++zCounter;
var el = document.getElementById(id);
if (el) {
el.style.zIndex = win.zIndex;
el.classList.add('focused');
// Disable pointer events on iframe when not focused for drag/resize
}
updateTaskbar();
}
// ============ Drag ============
function startDrag(winId, e) {
var win = windows.get(winId);
if (!win || win.maximized) return;
e.preventDefault();
focusWindow(winId);
dragState = {
winId: winId,
startX: e.clientX,
startY: e.clientY,
origX: win.x,
origY: win.y
};
disableIframePointerEvents();
document.body.style.cursor = 'move';
}
function handleMouseMove(e) {
if (dragState) {
var dx = e.clientX - dragState.startX;
var dy = e.clientY - dragState.startY;
var win = windows.get(dragState.winId);
if (!win) return;
win.x = dragState.origX + dx;
win.y = Math.max(0, dragState.origY + dy); // don't drag above viewport
var el = document.getElementById(dragState.winId);
if (el) {
el.style.left = win.x + 'px';
el.style.top = win.y + 'px';
}
}
if (resizeState) {
var dx = e.clientX - resizeState.startX;
var dy = e.clientY - resizeState.startY;
var win = windows.get(resizeState.winId);
if (!win) return;
var dir = resizeState.dir;
var newX = win.x, newY = win.y;
var newW = win.width, newH = win.height;
if (dir.indexOf('e') !== -1) {
newW = Math.max(MIN_WIDTH, resizeState.origW + dx);
}
if (dir.indexOf('w') !== -1) {
var dw = resizeState.origW - dx;
if (dw >= MIN_WIDTH) {
newW = dw;
newX = resizeState.origX + dx;
}
}
if (dir.indexOf('s') !== -1) {
newH = Math.max(MIN_HEIGHT, resizeState.origH + dy);
}
if (dir === 'n' || dir === 'ne' || dir === 'nw') {
var dh = resizeState.origH - dy;
if (dh >= MIN_HEIGHT) {
newH = dh;
newY = resizeState.origY + dy;
}
}
win.x = newX;
win.y = newY;
win.width = newW;
win.height = newH;
var el = document.getElementById(resizeState.winId);
if (el) {
el.style.left = newX + 'px';
el.style.top = newY + 'px';
el.style.width = newW + 'px';
el.style.height = newH + 'px';
}
}
}
function handleMouseUp() {
if (dragState || resizeState) {
enableIframePointerEvents();
document.body.style.cursor = '';
}
dragState = null;
resizeState = null;
}
// ============ Resize ============
var cursorMap = { n:'ns-resize', s:'ns-resize', e:'ew-resize', w:'ew-resize',
ne:'nesw-resize', sw:'nesw-resize', nw:'nwse-resize', se:'nwse-resize' };
function startResize(winId, e, dir) {
var win = windows.get(winId);
if (!win || win.maximized) return;
e.preventDefault();
focusWindow(winId);
resizeState = {
winId: winId,
dir: dir || 'se',
startX: e.clientX,
startY: e.clientY,
origW: win.width,
origH: win.height,
origX: win.x,
origY: win.y
};
disableIframePointerEvents();
document.body.style.cursor = cursorMap[dir] || 'se-resize';
}
// ============ Iframe Pointer Control ============
function disableIframePointerEvents() {
document.querySelectorAll('.desktop-window iframe').forEach(function(iframe) {
iframe.style.pointerEvents = 'none';
});
}
function enableIframePointerEvents() {
document.querySelectorAll('.desktop-window iframe').forEach(function(iframe) {
iframe.style.pointerEvents = '';
});
}
// ============ Taskbar ============
function updateTaskbar() {
var container = document.getElementById('taskbar-apps');
if (!container) return;
container.innerHTML = '';
windows.forEach(function(win) {
var btn = document.createElement('button');
btn.className = 'taskbar-app-btn';
if (!win.minimized) btn.classList.add('active');
if (win.id === focusedWindowId) btn.classList.add('focused');
btn.innerHTML =
'<span class="material-icons" style="color:' + win.app.color + '">' +
win.app.icon +
'</span>' +
'<span>' + escapeHtml(win.app.name) + '</span>';
btn.addEventListener('click', function() {
if (win.minimized) {
restoreWindow(win.id);
} else if (win.id === focusedWindowId) {
minimizeWindow(win.id);
} else {
focusWindow(win.id);
}
});
container.appendChild(btn);
});
}
function clearTaskbar() {
var container = document.getElementById('taskbar-apps');
if (container) container.innerHTML = '';
}
// ============ Clock ============
var clockInterval = null;
function startClock() {
updateClock();
clockInterval = setInterval(updateClock, 1000);
}
function stopClock() {
if (clockInterval) {
clearInterval(clockInterval);
clockInterval = null;
}
}
function updateClock() {
var el = document.getElementById('taskbar-clock');
if (!el) return;
var now = new Date();
var h = String(now.getHours()).padStart(2, '0');
var m = String(now.getMinutes()).padStart(2, '0');
el.textContent = h + ':' + m;
}
// ============ Helpers ============
function getDesktopArea() {
return {
x: 0,
y: 0,
width: window.innerWidth,
height: window.innerHeight - TASKBAR_HEIGHT
};
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function escapeAttr(str) {
return (str || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;')
.replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ============ Public API ============
window.DesktopMode = {
init: init,
toggle: toggle,
isActive: function() { return active; },
activate: activate,
deactivate: deactivate
};
})();
+148 -87
View File
@@ -11,6 +11,19 @@
if (/^#[0-9A-Fa-f]{3,6}$/.test(c)) return c;
return '#808080';
}
// Map device_type to Material Icons
function getDeviceTypeIcon(type) {
switch ((type || '').toLowerCase()) {
case 'desktop': return 'desktop_windows';
case 'scada': return 'precision_manufacturing';
case 'iot': return 'sensors';
case 'os_agent': return 'terminal';
case 'mobile': return 'phone_android';
case 'rustdesk': return 'connected_tv';
default: return 'connected_tv';
}
}
document.addEventListener('DOMContentLoaded', init);
@@ -49,8 +62,9 @@
initSync();
initFolders();
initDragDrop();
attachFolderDropEvents(); // For static folders
attachFolderDropEvents(); // For static folder chips
initColumnVisibility(); // Column show/hide toggle
initKebabGlobalClose(); // Close kebab menus on outside click
// Refresh handler
window.addEventListener('app:refresh', () => {
@@ -64,6 +78,28 @@
loadDevices();
});
}
/**
* Close all open kebab menus when clicking outside
*/
function initKebabGlobalClose() {
document.addEventListener('click', (e) => {
if (!e.target.closest('.kebab-wrapper')) {
closeAllKebabMenus();
}
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllKebabMenus();
});
}
function closeAllKebabMenus() {
document.querySelectorAll('.kebab-menu.open').forEach(m => m.classList.remove('open'));
const overlay = document.getElementById('kebab-overlay');
if (overlay) overlay.classList.remove('open');
}
/**
* Load devices from API
@@ -110,7 +146,8 @@
device.id?.toLowerCase().includes(q) ||
device.hostname?.toLowerCase().includes(q) ||
device.username?.toLowerCase().includes(q) ||
device.platform?.toLowerCase().includes(q);
device.platform?.toLowerCase().includes(q) ||
(device.device_type || 'rustdesk').toLowerCase().includes(q);
if (!match) return false;
}
@@ -173,20 +210,30 @@
return;
}
tableBody.innerHTML = pageDevices.map(device => `
<tr data-id="${Utils.escapeHtml(device.id)}" class="${device.banned ? 'banned-row' : ''}" draggable="true">
<td class="drag-handle-cell">
<span class="drag-handle material-icons">drag_indicator</span>
</td>
const statusClass = (d) => d.banned ? 'banned' : d.online ? 'online' : 'offline';
const statusLabel = (d) => d.banned ? _('status.banned') : d.online ? _('status.online') : _('status.offline');
tableBody.innerHTML = pageDevices.map(device => {
const eid = Utils.escapeHtml(device.id);
const sc = statusClass(device);
return `
<tr data-id="${eid}" class="${device.banned ? 'banned-row' : ''}" draggable="true">
<td data-column="id">
<div class="device-id">
<span class="device-id-text">${Utils.escapeHtml(device.id)}</span>
<button class="btn-icon-sm copy-btn" title="${_('actions.copy')}" data-copy="${Utils.escapeHtml(device.id)}">
<span class="device-status-dot ${sc}"></span>
<span class="device-id-text">${eid}</span>
<button class="copy-btn" title="${_('actions.copy')}" data-copy="${eid}">
<span class="material-icons">content_copy</span>
</button>
</div>
</td>
<td data-column="hostname">${Utils.escapeHtml(device.hostname || device.note || '-')}</td>
<td data-column="device_type">
<div class="platform-icon">
<span class="material-icons">${getDeviceTypeIcon(device.device_type)}</span>
<span>${Utils.escapeHtml(device.device_type || 'rustdesk')}</span>
</div>
</td>
<td data-column="platform">
<div class="platform-icon">
<span class="material-icons">${Utils.getPlatformIcon(device.platform || device.os)}</span>
@@ -194,41 +241,44 @@
</div>
</td>
<td data-column="last_online">
<div class="last-seen">
<div class="last-seen-time">${Utils.formatDate(device.last_online)}</div>
<div class="last-seen-ago">${Utils.formatRelativeTime(device.last_online)}</div>
</div>
<span class="last-seen-text" title="${Utils.formatDate(device.last_online)}">${Utils.formatRelativeTime(device.last_online)}</span>
</td>
<td data-column="status">
${device.banned
? `<span class="status-badge banned"><span class="status-dot"></span>${_('status.banned')}</span>`
: device.online
? `<span class="status-badge online"><span class="status-dot"></span>${_('status.online')}</span>`
: `<span class="status-badge offline"><span class="status-dot"></span>${_('status.offline')}</span>`
}
<span class="status-badge ${sc}"><span class="status-dot"></span>${statusLabel(device)}</span>
</td>
<td data-column="actions">
<div class="device-actions">
<button class="action-btn connect" title="${_('actions.connect')}" data-action="connect" data-id="${Utils.escapeHtml(device.id)}">
<span class="material-icons">link</span>
</button>
<button class="action-btn connect-desktop" title="${_('actions.connect_desktop')}" data-action="connect-desktop" data-id="${Utils.escapeHtml(device.id)}">
<span class="material-icons">computer</span>
</button>
<button class="action-btn info" title="${_('actions.details')}" data-action="details" data-id="${Utils.escapeHtml(device.id)}">
<span class="material-icons">info</span>
</button>
<button class="action-btn ${device.banned ? 'unban' : 'ban'}" title="${device.banned ? _('actions.unban') : _('actions.ban')}"
data-action="toggle-ban" data-id="${Utils.escapeHtml(device.id)}" data-banned="${device.banned}">
<span class="material-icons">${device.banned ? 'check_circle' : 'block'}</span>
</button>
<button class="action-btn danger" title="${_('actions.delete')}" data-action="delete" data-id="${Utils.escapeHtml(device.id)}">
<span class="material-icons">delete</span>
<div class="kebab-wrapper">
<button class="kebab-btn" title="${_('devices.actions')}">
<span class="material-icons">more_vert</span>
</button>
<div class="kebab-menu">
<button class="kebab-menu-item connect" data-action="connect" data-id="${eid}">
<span class="material-icons">link</span>
<span>${_('actions.connect')}</span>
</button>
<button class="kebab-menu-item connect-desktop" data-action="connect-desktop" data-id="${eid}">
<span class="material-icons">computer</span>
<span>${_('actions.connect_desktop')}</span>
</button>
<div class="kebab-divider"></div>
<button class="kebab-menu-item info" data-action="details" data-id="${eid}">
<span class="material-icons">info</span>
<span>${_('actions.details')}</span>
</button>
<button class="kebab-menu-item ${device.banned ? 'unban' : 'ban'}" data-action="toggle-ban" data-id="${eid}" data-banned="${device.banned}">
<span class="material-icons">${device.banned ? 'check_circle' : 'block'}</span>
<span>${device.banned ? _('actions.unban') : _('actions.ban')}</span>
</button>
<div class="kebab-divider"></div>
<button class="kebab-menu-item danger" data-action="delete" data-id="${eid}">
<span class="material-icons">delete</span>
<span>${_('actions.delete')}</span>
</button>
</div>
</div>
</td>
</tr>
`).join('');
</tr>`;
}).join('');
// Re-apply column visibility to newly rendered rows
applyColumnVisibility();
@@ -252,30 +302,36 @@
Notifications.success(_('common.copied'));
});
});
// Checkboxes
tableBody.querySelectorAll('.device-checkbox').forEach(cb => {
cb.addEventListener('change', () => {
const id = cb.dataset.id;
if (cb.checked) {
selectedIds.add(id);
} else {
selectedIds.delete(id);
// Kebab menu toggle
tableBody.querySelectorAll('.kebab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const menu = btn.nextElementSibling;
const wasOpen = menu.classList.contains('open');
closeAllKebabMenus();
if (!wasOpen) {
menu.classList.add('open');
const overlay = document.getElementById('kebab-overlay');
if (overlay) overlay.classList.add('open');
}
updateSelectionUI();
});
});
// Action buttons
tableBody.querySelectorAll('.action-btn').forEach(btn => {
btn.addEventListener('click', () => handleAction(btn.dataset.action, btn.dataset.id, btn.dataset));
// Kebab menu item actions
tableBody.querySelectorAll('.kebab-menu-item').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
closeAllKebabMenus();
handleAction(btn.dataset.action, btn.dataset.id, btn.dataset);
});
});
// Double-click row to open device detail panel
tableBody.querySelectorAll('tr[data-id]').forEach(row => {
row.addEventListener('dblclick', (e) => {
// Ignore double-click on action buttons and drag handle
if (e.target.closest('.action-btn') || e.target.closest('.drag-handle') || e.target.closest('.copy-btn')) return;
// Ignore double-click on kebab menu and copy button
if (e.target.closest('.kebab-wrapper') || e.target.closest('.copy-btn')) return;
const deviceId = row.dataset.id;
if (deviceId && typeof DeviceDetail !== 'undefined') {
DeviceDetail.open(deviceId);
@@ -469,6 +525,16 @@
<p class="delete-warning">${_('devices.delete_warning')}</p>
<p class="delete-device-id"><strong>${Utils.escapeHtml(deviceId)}</strong></p>
<p class="delete-info">${_('devices.delete_permanent')}</p>
<div class="revoke-options" style="margin-top: 12px; padding: 10px; border: 1px solid var(--border-color); border-radius: 6px;">
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; margin-bottom: 6px;">
<input type="checkbox" id="revoke-check-${deviceId}" />
<span class="material-icons" style="font-size: 18px; color: var(--accent-red);">block</span>
<span>${_('devices.revoke_option')}</span>
</label>
<p class="revoke-hint" style="font-size: 0.8rem; opacity: 0.7; margin: 0 0 0 30px;">
${_('devices.revoke_hint')}
</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary cancel-btn">${_('actions.cancel')}</button>
@@ -487,6 +553,7 @@
const confirmBtn = modal.querySelector('.confirm-delete-btn');
const cancelBtn = modal.querySelector('.cancel-btn');
const countdownEl = confirmBtn.querySelector('.countdown');
const revokeCheck = document.getElementById(`revoke-check-${deviceId}`);
let countdown = 3;
const timer = setInterval(() => {
@@ -518,11 +585,17 @@
confirmBtn.addEventListener('click', async () => {
if (confirmBtn.disabled) return;
const revoke = revokeCheck && revokeCheck.checked;
closeModal();
try {
await Utils.api(`/api/devices/${deviceId}`, { method: 'DELETE' });
Notifications.success(_('devices.delete_success'));
const params = new URLSearchParams();
if (revoke) params.set('revoke', 'true');
const qs = params.toString();
const url = `/api/devices/${deviceId}${qs ? '?' + qs : ''}`;
await Utils.api(url, { method: 'DELETE' });
const msg = revoke ? _('devices.revoke_success') : _('devices.delete_success');
Notifications.success(msg);
loadDevices();
resolve(true);
} catch (error) {
@@ -817,31 +890,27 @@
container.innerHTML = folders.map(folder => {
const safeColor = (Utils.sanitizeColor || _sanitizeColorFallback)(folder.color);
return `
<div class="folder-item ${currentFolder == folder.id ? 'active' : ''}"
<button class="folder-chip ${currentFolder == folder.id ? 'active' : ''}"
data-folder="${folder.id}"
style="--folder-color: ${safeColor}">
<span class="material-icons folder-icon" style="color: ${safeColor}">folder</span>
<span class="folder-name">${Utils.escapeHtml(folder.name)}</span>
<span class="folder-count">${folder.device_count || 0}</span>
<div class="folder-actions">
<button class="btn-icon-sm folder-edit" data-id="${folder.id}" title="${_('actions.edit')}">
<span class="material-icons chip-icon" style="color: ${safeColor}">folder</span>
<span class="chip-label">${Utils.escapeHtml(folder.name)}</span>
<span class="chip-count">${folder.device_count || 0}</span>
<span class="chip-actions">
<span class="chip-action folder-edit" data-id="${folder.id}" title="${_('actions.edit')}">
<span class="material-icons">edit</span>
</button>
<button class="btn-icon-sm folder-delete" data-id="${folder.id}" title="${_('actions.delete')}">
</span>
<span class="chip-action folder-delete" data-id="${folder.id}" title="${_('actions.delete')}">
<span class="material-icons">delete</span>
</button>
</div>
</div>
</span>
</span>
</button>
`}).join('');
// Attach folder event listeners
container.querySelectorAll('.folder-item').forEach(el => {
// Attach folder click listeners
container.querySelectorAll('.folder-chip').forEach(el => {
el.addEventListener('click', (e) => {
// In collapsed mode, always select folder (ignore edit/delete buttons)
const sidebar = document.getElementById('folders-sidebar');
const isCollapsed = sidebar && sidebar.classList.contains('collapsed');
if (isCollapsed || !e.target.closest('.folder-actions')) {
if (!e.target.closest('.chip-actions')) {
selectFolder(el.dataset.folder);
}
});
@@ -849,10 +918,6 @@
container.querySelectorAll('.folder-edit').forEach(btn => {
btn.addEventListener('click', (e) => {
// Don't trigger edit in collapsed mode
const sidebar = document.getElementById('folders-sidebar');
if (sidebar && sidebar.classList.contains('collapsed')) return;
e.stopPropagation();
editFolder(btn.dataset.id);
});
@@ -860,16 +925,12 @@
container.querySelectorAll('.folder-delete').forEach(btn => {
btn.addEventListener('click', (e) => {
// Don't trigger delete in collapsed mode
const sidebar = document.getElementById('folders-sidebar');
if (sidebar && sidebar.classList.contains('collapsed')) return;
e.stopPropagation();
deleteFolder(btn.dataset.id);
});
});
// Attach drag & drop events for all folder items
// Attach drag & drop events for all folder chips
attachFolderDropEvents();
}
@@ -890,7 +951,7 @@
// Update custom folder counts from devices array
for (const folder of folders) {
const el = document.querySelector(`.folder-item[data-folder="${folder.id}"] .folder-count`);
const el = document.querySelector(`.folder-chip[data-folder="${folder.id}"] .chip-count`);
if (el) {
const count = devices.filter(d => d.folder_id === folder.id).length;
el.textContent = count;
@@ -940,7 +1001,7 @@
currentPage = 1;
// Update active state
document.querySelectorAll('.folder-item').forEach(el => {
document.querySelectorAll('.folder-chip').forEach(el => {
el.classList.toggle('active', el.dataset.folder == folderId);
});
@@ -955,7 +1016,7 @@
document.getElementById('add-folder-btn')?.addEventListener('click', showAddFolderModal);
// Special folder clicks
document.querySelectorAll('.folder-item[data-folder="all"], .folder-item[data-folder="unassigned"]').forEach(el => {
document.querySelectorAll('.folder-chip[data-folder="all"], .folder-chip[data-folder="unassigned"]').forEach(el => {
el.addEventListener('click', () => selectFolder(el.dataset.folder));
});
}
@@ -1208,7 +1269,7 @@
draggedDeviceId = null;
// Remove drop indicators
document.querySelectorAll('.folder-item.drag-over').forEach(el => {
document.querySelectorAll('.folder-chip.drag-over').forEach(el => {
el.classList.remove('drag-over');
});
});
@@ -1219,7 +1280,7 @@
*/
function attachFolderDropEvents() {
// Handle drop on ALL folders (static + dynamic)
document.querySelectorAll('.folder-item').forEach(folder => {
document.querySelectorAll('.folder-chip').forEach(folder => {
// Skip if already has drag handlers (check with data attribute)
if (folder.dataset.dragAttached) return;
folder.dataset.dragAttached = 'true';
+125
View File
@@ -0,0 +1,125 @@
/**
* BetterDesk Console - CDAP Routes
* Routes for CDAP (Custom Device Automation Protocol) device management
* and widget rendering in the admin panel.
*/
const express = require('express');
const router = express.Router();
const { requireAuth, requireRole } = require('../middleware/auth');
const betterdeskApi = require('../services/betterdeskApi');
// ── Page Routes ──────────────────────────────────────────────────────────
/**
* CDAP device detail page with widget panel
* GET /cdap/devices/:id
*/
router.get('/cdap/devices/:id', requireAuth, async (req, res) => {
try {
const { id } = req.params;
res.render('cdap-device', {
title: req.__('cdap.device_detail'),
activePage: 'devices',
deviceId: id
});
} catch (err) {
console.error('CDAP device page error:', err.message);
res.redirect('/devices');
}
});
// ── API Routes ───────────────────────────────────────────────────────────
/**
* GET /api/cdap/status
* Returns CDAP gateway status (enabled, connections, port)
*/
router.get('/api/cdap/status', requireAuth, async (req, res) => {
try {
const result = await betterdeskApi.getCDAPStatus();
res.json(result);
} catch (err) {
res.status(500).json({ success: false, error: 'Failed to get CDAP status' });
}
});
/**
* GET /api/cdap/devices
* Returns all connected CDAP devices
*/
router.get('/api/cdap/devices', requireAuth, async (req, res) => {
try {
const result = await betterdeskApi.getCDAPDevices();
res.json(result);
} catch (err) {
res.status(500).json({ success: false, error: 'Failed to list CDAP devices' });
}
});
/**
* GET /api/cdap/devices/:id
* Returns full CDAP device info (manifest + state + connection)
*/
router.get('/api/cdap/devices/:id', requireAuth, async (req, res) => {
try {
const result = await betterdeskApi.getCDAPDeviceInfo(req.params.id);
res.json(result);
} catch (err) {
res.status(500).json({ success: false, error: 'Failed to get CDAP device info' });
}
});
/**
* GET /api/cdap/devices/:id/manifest
* Returns device manifest (capabilities, widgets, alerts)
*/
router.get('/api/cdap/devices/:id/manifest', requireAuth, async (req, res) => {
try {
const result = await betterdeskApi.getCDAPDeviceManifest(req.params.id);
res.json(result);
} catch (err) {
res.status(500).json({ success: false, error: 'Failed to get CDAP device manifest' });
}
});
/**
* GET /api/cdap/devices/:id/state
* Returns current widget values for connected device
*/
router.get('/api/cdap/devices/:id/state', requireAuth, async (req, res) => {
try {
const result = await betterdeskApi.getCDAPDeviceState(req.params.id);
res.json(result);
} catch (err) {
res.status(500).json({ success: false, error: 'Failed to get CDAP device state' });
}
});
/**
* POST /api/cdap/devices/:id/command
* Sends a command to a connected CDAP device
* Body: { widget_id, action, value, reason? }
*/
router.post('/api/cdap/devices/:id/command', requireAuth, requireRole('operator'), async (req, res) => {
try {
const { widget_id, action, value, reason } = req.body;
if (!widget_id || !action) {
return res.status(400).json({ success: false, error: 'widget_id and action are required' });
}
const result = await betterdeskApi.sendCDAPCommand(
req.params.id,
widget_id,
action,
value,
reason
);
res.json(result);
} catch (err) {
res.status(500).json({ success: false, error: 'Failed to send command' });
}
});
module.exports = router;
+14 -3
View File
@@ -173,10 +173,13 @@ router.patch('/api/devices/:id', requireAuth, requireRole('operator'), async (re
/**
* DELETE /api/devices/:id - Delete device (soft delete)
* Query params: revoke=true (blocklist + disconnect), cascade=true (delete linked devices)
*/
router.delete('/api/devices/:id', requireAuth, requireRole('operator'), async (req, res) => {
try {
const id = req.params.id;
const revoke = req.query.revoke === 'true';
const cascade = req.query.cascade === 'true';
const device = await serverBackend.getDeviceById(id);
if (!device) {
@@ -186,7 +189,7 @@ router.delete('/api/devices/:id', requireAuth, requireRole('operator'), async (r
});
}
const result = await serverBackend.deleteDevice(id);
const result = await serverBackend.deleteDevice(id, { revoke, cascade });
if (!result || !result.success) {
return res.status(500).json({
@@ -201,9 +204,17 @@ router.delete('/api/devices/:id', requireAuth, requireRole('operator'), async (r
} catch { /* non-critical: auth.db cleanup is secondary */ }
// Log action
await db.logAction(req.session.userId, 'device_deleted', `Device ${id} deleted`, req.ip);
const action = revoke ? 'device_revoked' : 'device_deleted';
const details = revoke
? `Device ${id} revoked (blocklist + disconnect)${cascade ? ' + cascade' : ''}`
: `Device ${id} deleted`;
await db.logAction(req.session.userId, action, details, req.ip);
res.json({ success: true });
res.json({
success: true,
revoked: revoke,
cascaded: result.cascaded || [],
});
} catch (err) {
console.error('Delete device error:', err);
res.status(500).json({
+2
View File
@@ -27,6 +27,7 @@ const dataguardRoutes = require('./dataguard.routes');
const reportsRoutes = require('./reports.routes');
const tenantsRoutes = require('./tenants.routes');
const registrationRoutes = require('./registration.routes');
const cdapRoutes = require('./cdap.routes');
/**
* Middleware to require JSON Content-Type for POST/PATCH/PUT requests to API routes.
@@ -92,5 +93,6 @@ router.use('/api/reports', reportsRoutes); // admin-facing: /api/reports/*
router.use('/api/tenants', tenantsRoutes); // admin-facing: /api/tenants/*
router.use('/', registrationRoutes); // admin-facing: /registrations, /api/registrations/*
router.use('/api/bd', registrationRoutes); // device-facing: /api/bd/register-request, /api/bd/register-status
router.use('/', cdapRoutes); // admin-facing: /cdap/devices/:id, /api/cdap/*
module.exports = router;
+7
View File
@@ -101,6 +101,13 @@ app.use(rustdeskApiRoutes);
// i18n middleware
app.use(initI18n());
// Embed mode — when ?embed=1 is present, layout renders without sidebar/navbar
// Used by Desktop Mode to load pages inside floating windows (iframes)
app.use((req, res, next) => {
res.locals.embed = req.query.embed === '1';
next();
});
// CSRF protection — generate token for views, validate on POST/PUT/DELETE/PATCH
app.use(csrfTokenProvider);
app.use(doubleCsrfProtection);
+80 -2
View File
@@ -115,10 +115,18 @@ async function getPeer(id) {
/**
* DELETE /api/peers/:id
* @param {string} id - Peer ID
* @param {object} [options] - Optional: { revoke: bool, cascade: bool, hard: bool }
*/
async function deletePeer(id) {
async function deletePeer(id, options = {}) {
try {
const { data } = await apiClient.delete(`/peers/${encodeURIComponent(id)}`);
const params = new URLSearchParams();
if (options.revoke) params.set('revoke', 'true');
if (options.cascade) params.set('cascade', 'true');
if (options.hard) params.set('hard', 'true');
const qs = params.toString();
const url = `/peers/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`;
const { data } = await apiClient.delete(url);
return wrap(data);
} catch (err) {
if (err.response?.data) return wrap(err.response.data);
@@ -415,6 +423,69 @@ function normalisePeer(peer) {
};
}
// ---------------------------------------------------------------------------
// CDAP (Custom Device Automation Protocol) endpoints
// ---------------------------------------------------------------------------
async function getCDAPStatus() {
try {
const { data } = await apiClient.get('/cdap/status');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
async function getCDAPDevices() {
try {
const { data } = await apiClient.get('/cdap/devices');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
async function getCDAPDeviceInfo(id) {
try {
const { data } = await apiClient.get(`/cdap/devices/${encodeURIComponent(id)}`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
async function getCDAPDeviceManifest(id) {
try {
const { data } = await apiClient.get(`/cdap/devices/${encodeURIComponent(id)}/manifest`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
async function getCDAPDeviceState(id) {
try {
const { data } = await apiClient.get(`/cdap/devices/${encodeURIComponent(id)}/state`);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
async function sendCDAPCommand(id, widgetId, action, value, reason) {
try {
const { data } = await apiClient.post(`/cdap/devices/${encodeURIComponent(id)}/command`, {
widget_id: widgetId,
action,
value,
reason: reason || ''
});
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
module.exports = {
// Health / Stats
getHealth,
@@ -447,6 +518,13 @@ module.exports = {
setConfig,
// Sync (no-op)
syncOnlineStatus,
// CDAP
getCDAPStatus,
getCDAPDevices,
getCDAPDeviceInfo,
getCDAPDeviceManifest,
getCDAPDeviceState,
sendCDAPCommand,
// Helpers
normalisePeer
};
+2 -2
View File
@@ -153,8 +153,8 @@ async function getDeviceById(id) {
return peer;
}
async function deleteDevice(id) {
return betterdeskApi.deletePeer(id);
async function deleteDevice(id, options = {}) {
return betterdeskApi.deletePeer(id, options);
}
async function setBanStatus(id, banned, reason = '') {
+81
View File
@@ -0,0 +1,81 @@
<%- include('layouts/main', {
title: _('cdap.device_detail'),
pageStyles: ['cdap'],
pageScripts: ['cdap-widgets', 'cdap-commands'],
currentPage: 'devices',
breadcrumb: [
{ label: _('nav.devices'), href: '/devices' },
{ label: _('cdap.device_detail') }
],
body: `
<div class="cdap-device-page" data-device-id="${deviceId}">
<!-- Device Header -->
<div class="cdap-device-header">
<div class="cdap-device-title">
<a href="/devices" class="btn-icon cdap-back-btn" title="${_('common.back')}">
<span class="material-icons">arrow_back</span>
</a>
<div class="cdap-device-identity">
<h1 id="cdap-device-name">${deviceId}</h1>
<div class="cdap-device-meta" id="cdap-device-meta">
<span class="cdap-meta-item" id="cdap-device-type">
<span class="material-icons">memory</span>
<span></span>
</span>
<span class="cdap-meta-item" id="cdap-device-version">
<span class="material-icons">info_outline</span>
<span></span>
</span>
<span class="cdap-meta-item" id="cdap-device-uptime">
<span class="material-icons">schedule</span>
<span></span>
</span>
</div>
</div>
</div>
<div class="cdap-device-status" id="cdap-device-status">
<span class="cdap-status-dot"></span>
<span class="cdap-status-text">${_('cdap.loading')}</span>
</div>
</div>
<!-- Connection Banner (shown when disconnected) -->
<div class="cdap-offline-banner hidden" id="cdap-offline-banner">
<span class="material-icons">cloud_off</span>
<span>${_('cdap.device_offline_msg')}</span>
</div>
<!-- Widget Grid -->
<div class="cdap-widget-grid" id="cdap-widget-grid">
<!-- Widgets rendered by cdap-widgets.js -->
<div class="cdap-loading" id="cdap-loading">
<div class="cdap-loading-spinner"></div>
<p>${_('cdap.loading_widgets')}</p>
</div>
</div>
<!-- Empty State (no manifest) -->
<div class="cdap-empty hidden" id="cdap-empty">
<span class="material-icons cdap-empty-icon">widgets</span>
<h3>${_('cdap.no_widgets')}</h3>
<p>${_('cdap.no_widgets_desc')}</p>
</div>
<!-- Command Log -->
<div class="cdap-command-log hidden" id="cdap-command-log">
<div class="cdap-command-log-header">
<h3>
<span class="material-icons">terminal</span>
${_('cdap.command_log')}
</h3>
<button class="btn-icon" id="cdap-clear-log" title="${_('cdap.clear_log')}">
<span class="material-icons">delete_sweep</span>
</button>
</div>
<div class="cdap-command-log-entries" id="cdap-log-entries">
<!-- Command log entries appended by cdap-commands.js -->
</div>
</div>
</div>
`
}) %>
+107 -136
View File
@@ -5,156 +5,127 @@
currentPage: 'devices',
breadcrumb: [{ label: _('nav.devices') }],
body: `
<div class="devices-layout">
<!-- Folders Sidebar -->
<aside class="folders-sidebar" id="folders-sidebar">
<div class="folders-header">
<h3 class="sidebar-title">${_('folders.title')}</h3>
<button class="btn-icon" id="add-folder-btn" title="${_('folders.create')}">
<span class="material-icons">create_new_folder</span>
<div class="devices-page">
<!-- Header -->
<div class="devices-header">
<div class="devices-title">
<h1>${_('devices.title')}</h1>
<span class="devices-count" id="devices-count">0</span>
</div>
<div class="devices-actions">
<button class="btn btn-secondary btn-sm" id="sync-btn">
<span class="material-icons">sync</span>
<span class="btn-label">${_('devices.sync_status')}</span>
</button>
</div>
<div class="folders-list" id="folders-list">
<!-- All Devices (special folder) -->
<div class="folder-item active" data-folder="all" draggable="false">
<span class="material-icons folder-icon">devices</span>
<span class="folder-name">${_('folders.all_devices')}</span>
<span class="folder-count" id="folder-count-all">0</span>
</div>
<!-- Unassigned folder -->
<div class="folder-item" data-folder="unassigned" draggable="false">
<span class="material-icons folder-icon">folder_off</span>
<span class="folder-name">${_('folders.unassigned')}</span>
<span class="folder-count" id="folder-count-unassigned">0</span>
</div>
<div class="folders-divider"></div>
<!-- Custom folders will be loaded here -->
<div id="custom-folders"></div>
</div>
<!-- Folder Chips -->
<div class="folders-bar" id="folders-bar">
<div class="folders-chips" id="folders-chips">
<button class="folder-chip active" data-folder="all">
<span class="material-icons chip-icon">devices</span>
<span class="chip-label">${_('folders.all_devices')}</span>
<span class="chip-count" id="folder-count-all">0</span>
</button>
<button class="folder-chip" data-folder="unassigned">
<span class="material-icons chip-icon">folder_off</span>
<span class="chip-label">${_('folders.unassigned')}</span>
<span class="chip-count" id="folder-count-unassigned">0</span>
</button>
<span id="custom-folders"></span>
</div>
<div class="folders-hint">
<span class="material-icons">info</span>
<span class="hint-text">${_('folders.drag_hint')}</span>
<button class="folder-chip chip-add" id="add-folder-btn" title="${_('folders.create')}">
<span class="material-icons">add</span>
</button>
</div>
<!-- Toolbar -->
<div class="devices-toolbar">
<div class="toolbar-search search-wrapper">
<span class="material-icons">search</span>
<input type="text" class="form-input" id="search-input"
placeholder="${_('devices.search_placeholder')}">
</div>
</aside>
<!-- Main Content -->
<main class="devices-main">
<!-- Devices Header -->
<div class="devices-header">
<div class="devices-title">
<h1>${_('devices.title')}</h1>
<span class="devices-count" id="devices-count">0</span>
</div>
<div class="devices-actions">
<button class="btn btn-secondary" id="sync-btn">
<span class="material-icons">sync</span>
${_('devices.sync_status')}
</button>
<div class="toolbar-filters">
<button class="filter-btn active" data-filter="all">${_('devices.filter_all')}</button>
<button class="filter-btn" data-filter="online">${_('devices.filter_online')}</button>
<button class="filter-btn" data-filter="offline">${_('devices.filter_offline')}</button>
<button class="filter-btn" data-filter="banned">${_('devices.filter_banned')}</button>
</div>
<div class="column-visibility-dropdown">
<button class="btn-icon toolbar-icon" id="columns-btn" title="${_('devices.toggle_columns')}">
<span class="material-icons">view_column</span>
</button>
<div class="column-visibility-menu" id="columns-menu">
<label class="column-toggle">
<input type="checkbox" data-column="device_type" checked>
<span>${_('devices.device_type')}</span>
</label>
<label class="column-toggle">
<input type="checkbox" data-column="platform" checked>
<span>${_('devices.platform')}</span>
</label>
<label class="column-toggle">
<input type="checkbox" data-column="last_online" checked>
<span>${_('devices.last_seen')}</span>
</label>
<label class="column-toggle">
<input type="checkbox" data-column="status" checked>
<span>${_('devices.status')}</span>
</label>
</div>
</div>
<!-- Filters -->
<div class="devices-filters">
<div class="devices-search search-wrapper">
<span class="material-icons">search</span>
<input
type="text"
class="form-input"
id="search-input"
placeholder="${_('devices.search_placeholder')}"
>
</div>
<div class="column-visibility-dropdown">
<button class="btn" id="columns-btn" title="${_('devices.toggle_columns')}">
<span class="material-icons">view_column</span>
${_('devices.columns')}
</button>
<div class="column-visibility-menu" id="columns-menu">
<label class="column-toggle">
<input type="checkbox" data-column="platform" checked>
<span>${_('devices.platform')}</span>
</label>
<label class="column-toggle">
<input type="checkbox" data-column="last_online" checked>
<span>${_('devices.last_seen')}</span>
</label>
<label class="column-toggle">
<input type="checkbox" data-column="status" checked>
<span>${_('devices.status')}</span>
</label>
<label class="column-toggle">
<input type="checkbox" data-column="actions" checked>
<span>${_('devices.actions')}</span>
</label>
</div>
</div>
<div class="devices-filter-group">
<button class="filter-btn active" data-filter="all">${_('devices.filter_all')}</button>
<button class="filter-btn" data-filter="online">${_('devices.filter_online')}</button>
<button class="filter-btn" data-filter="offline">${_('devices.filter_offline')}</button>
<button class="filter-btn" data-filter="banned">${_('devices.filter_banned')}</button>
</div>
<!-- Table -->
<div class="devices-table-container">
<table class="devices-table" id="devices-table">
<thead>
<tr>
<th class="sortable" data-sort="id" data-column="id">${_('devices.id')}</th>
<th class="sortable" data-sort="hostname" data-column="hostname">${_('devices.hostname')}</th>
<th class="sortable" data-sort="device_type" data-column="device_type">${_('devices.device_type')}</th>
<th class="sortable" data-sort="platform" data-column="platform">${_('devices.platform')}</th>
<th class="sortable" data-sort="last_online" data-column="last_online">${_('devices.last_seen')}</th>
<th data-column="status">${_('devices.status')}</th>
<th class="col-actions" data-column="actions"></th>
</tr>
</thead>
<tbody id="devices-tbody">
<tr class="loading-row">
<td colspan=\"7\" style=\"text-align: center; padding: 40px;\">
<span class="skeleton skeleton-text" style="width: 200px;"></span>
</td>
</tr>
</tbody>
</table>
<div class="devices-pagination" id="pagination">
<div class="pagination-info">
<span id="pagination-info">${_('devices.showing')} 0-0 ${_('devices.of')} 0</span>
</div>
<div class="pagination-controls" id="pagination-controls"></div>
</div>
<!-- Devices Table -->
<div class="devices-table-container">
<div class="table-wrapper">
<table class="devices-table table" id="devices-table">
<thead>
<tr>
<th class="drag-handle-cell"></th>
<th class="sortable" data-sort="id" data-column="id">${_('devices.id')}</th>
<th class="sortable" data-sort="hostname" data-column="hostname">${_('devices.hostname')}</th>
<th class="sortable" data-sort="platform" data-column="platform">${_('devices.platform')}</th>
<th class="sortable" data-sort="last_online" data-column="last_online">${_('devices.last_seen')}</th>
<th data-column="status">${_('devices.status')}</th>
<th data-column="actions">${_('devices.actions')}</th>
</tr>
</thead>
<tbody id="devices-tbody">
<!-- Devices will be loaded via JS -->
<tr class="loading-row">
<td colspan=\"7\" style=\"text-align: center; padding: 40px;\">
<span class="skeleton skeleton-text" style="width: 200px;"></span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="devices-pagination" id="pagination">
<div class="pagination-info">
<span id="pagination-info">${_('devices.showing')} 0-0 ${_('devices.of')} 0</span>
</div>
<div class="pagination-controls" id="pagination-controls">
<!-- Pagination buttons will be generated via JS -->
</div>
</div>
</div>
<!-- Empty state (hidden by default) -->
<div class="devices-empty hidden" id="devices-empty">
<span class="material-icons devices-empty-icon">devices_other</span>
<h3 class="devices-empty-title">${_('devices.empty_title')}</h3>
<p class="devices-empty-text">${_('devices.empty_text')}</p>
</div>
</main>
</div>
<!-- Empty state -->
<div class="devices-empty hidden" id="devices-empty">
<span class="material-icons devices-empty-icon">devices_other</span>
<h3 class="devices-empty-title">${_('devices.empty_title')}</h3>
<p class="devices-empty-text">${_('devices.empty_text')}</p>
</div>
<!-- Kebab overlay for mobile bottom sheet -->
<div class="kebab-overlay" id="kebab-overlay"></div>
</div>
<!-- Folder Form Template -->
<template id="folder-form-template">
<form id="folder-form" class="folder-form">
<div class="form-group">
<label for="folder-name">${_('folders.name')}</label>
<input type="text" id="folder-name" name="name" class="form-input"
<input type="text" id="folder-name" name="name" class="form-input"
maxlength="50" required placeholder="${_('folders.name_placeholder')}">
</div>
<div class="form-group">
+36 -2
View File
@@ -13,24 +13,31 @@
<!-- Stylesheets -->
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/theme.css">
<% if (!embed) { %>
<link rel="stylesheet" href="/css/desktop-mode.css">
<% } %>
<% if (typeof pageStyles !== 'undefined' && pageStyles.length) { %>
<% pageStyles.forEach(style => { %>
<link rel="stylesheet" href="/css/<%= style %>.css">
<% }); %>
<% } %>
</head>
<body>
<body<%= embed ? ' class="embed-mode"' : '' %>>
<div class="app-layout" id="app">
<% if (!embed) { %>
<!-- Sidebar -->
<%- include('../partials/sidebar') %>
<!-- Sidebar overlay for mobile -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<% } %>
<!-- Main wrapper -->
<div class="main-wrapper">
<% if (!embed) { %>
<!-- Navbar -->
<%- include('../partials/navbar') %>
<% } %>
<!-- Main content -->
<main class="main-content">
@@ -39,6 +46,29 @@
</div>
</div>
<% if (!embed) { %>
<!-- Desktop Mode Shell (hidden by default, activated via toggle) -->
<div id="desktop-shell" class="desktop-shell">
<div class="desktop-wallpaper"></div>
<div class="desktop-icons" id="desktop-icons"></div>
<div class="desktop-windows" id="desktop-windows"></div>
<div class="desktop-taskbar" id="desktop-taskbar">
<div class="taskbar-start">
<button class="taskbar-start-btn" id="taskbar-start-btn" title="<%= appName %>">
<span class="material-icons">grid_view</span>
</button>
</div>
<div class="taskbar-apps" id="taskbar-apps"></div>
<div class="taskbar-right">
<button class="taskbar-btn taskbar-console-btn" id="taskbar-console-btn" title="<%= _('desktop.console_mode') %>">
<span class="material-icons">view_sidebar</span>
</button>
<div class="taskbar-clock" id="taskbar-clock"></div>
</div>
</div>
</div>
<% } %>
<!-- Modal container -->
<div id="modal-container"></div>
@@ -53,7 +83,8 @@
translations: <%- JSON.stringify(translations || {}) %>,
csrfToken: '<%= typeof csrfToken !== 'undefined' ? csrfToken : '' %>',
user: <%- JSON.stringify(user || null) %>,
branding: <%- JSON.stringify(branding || {}) %>
branding: <%- JSON.stringify(branding || {}) %>,
embed: <%= embed ? 'true' : 'false' %>
};
</script>
<script src="/js/utils.js"></script>
@@ -61,6 +92,9 @@
<script src="/js/notifications.js?v=<%= Date.now() %>"></script>
<script src="/js/modal.js?v=<%= Date.now() %>"></script>
<script src="/js/app.js?v=<%= Date.now() %>"></script>
<% if (!embed) { %>
<script src="/js/desktop-mode.js?v=<%= Date.now() %>"></script>
<% } %>
<% if (typeof pageScripts !== 'undefined' && pageScripts.length) { %>
<% pageScripts.forEach(script => { %>
<script src="/js/<%= script %>.js?v=<%= Date.now() %>"></script>
+5
View File
@@ -28,6 +28,11 @@
<span class="material-icons">refresh</span>
</button>
<!-- Desktop mode toggle (visible only on large screens) -->
<button class="navbar-btn desktop-toggle-btn" id="desktop-toggle-btn" title="<%= _('desktop.switch_mode') %>">
<span class="material-icons">desktop_windows</span>
</button>
<!-- Language selector -->
<div class="lang-selector">
<button class="navbar-btn" id="lang-btn" title="<%= _('settings.language') %>">