mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-09 17:16:46 +00:00
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:
@@ -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*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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 4–29.
|
||||
|
||||
#### 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
@@ -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
@@ -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
@@ -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
@@ -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": "关闭"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+883
-739
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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, '&').replace(/"/g, '"')
|
||||
.replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ============ Public API ============
|
||||
|
||||
window.DesktopMode = {
|
||||
init: init,
|
||||
toggle: toggle,
|
||||
isActive: function() { return active; },
|
||||
activate: activate,
|
||||
deactivate: deactivate
|
||||
};
|
||||
|
||||
})();
|
||||
+148
-87
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 = '') {
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') %>">
|
||||
|
||||
Reference in New Issue
Block a user