From ff0f29f920248a767eae92eae5a8054c15159973 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Sun, 5 Apr 2026 22:55:03 -0500 Subject: [PATCH] add disconnect reasons to dashboard and fix icons in sideview --- Overlord-Client/cmd/agent/session.go | 41 ++++++++++++++++++- Overlord-Client/cmd/agent/wire/protocol.go | 6 +++ Overlord-Server/public/assets/main.css | 1 + Overlord-Server/public/assets/render.js | 8 ++++ Overlord-Server/src/db.ts | 33 +++++++++++---- .../routes/websocket-lifecycle-routes.ts | 19 +++++++-- Overlord-Server/src/sessions/types.ts | 2 + Overlord-Server/src/types.ts | 2 + Overlord-Server/src/wsValidation.ts | 1 + 9 files changed, 101 insertions(+), 12 deletions(-) diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index fad2b3e..29bd4c7 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -469,6 +469,38 @@ func getPingInterval() time.Duration { return time.Duration(ms) * time.Millisecond } +func classifySessionError(err error) (reason, detail string) { + if err == nil { + return "crash", "" + } + msg := err.Error() + msgLower := strings.ToLower(msg) + if strings.Contains(msgLower, "panic") { + return "panic", truncateStr(msg, 300) + } + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) { + if closeErr.Code == websocket.StatusNormalClosure || closeErr.Code == websocket.StatusGoingAway { + return "normal", "" + } + return "network", fmt.Sprintf("ws close code=%d reason=%s", closeErr.Code, truncateStr(closeErr.Reason, 100)) + } + if strings.Contains(msgLower, "timed out from inactivity") || strings.Contains(msgLower, "pong timeout") { + return "timeout", truncateStr(msg, 300) + } + if strings.Contains(msgLower, "context canceled") || strings.Contains(msgLower, "context deadline exceeded") { + return "normal", "" + } + return "network", truncateStr(msg, 300) +} + +func truncateStr(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.Conn, cfg config.Config) (err error) { defer func() { if r := recover(); r != nil { @@ -480,7 +512,14 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. } }() defer cancel() - defer conn.Close(websocket.StatusNormalClosure, "bye") + defer func() { + reason, detail := classifySessionError(err) + di := wire.DisconnectInfo{Type: "disconnect_info", Reason: reason, Detail: detail} + sendCtx, sendCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer sendCancel() + _ = wire.WriteMsg(sendCtx, conn, di) + conn.Close(websocket.StatusNormalClosure, "bye") + }() identity := config.DeriveIdentity() log.Printf("[purgatory] identity fingerprint=%s", identity.Fingerprint) diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index 4e2f0f7..a8f129c 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -248,3 +248,9 @@ type ProxyClose struct { Type string `msgpack:"type"` ConnectionID string `msgpack:"connectionId"` } + +type DisconnectInfo struct { + Type string `msgpack:"type"` + Reason string `msgpack:"reason"` // "normal", "panic", "crash", "network", "timeout" + Detail string `msgpack:"detail,omitempty"` // error message +} diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index d01b43b..ebc4511 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -2184,6 +2184,7 @@ body.sb-collapsed .sb-collapse-btn i { body.sb-collapsed .sb-link { justify-content: center; padding: 12px 0; + gap: 0; } .sb-icon { flex-shrink: 0; diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index 726ba13..fda69a4 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -266,6 +266,14 @@ export function createRenderer({ ${client.online ? "Online" : "Offline"} + ${!client.online && client.disconnectReason && client.disconnectReason !== "normal" ? (() => { + const iconMap = { panic: "fa-skull-crossbones", crash: "fa-skull", timeout: "fa-clock", network: "fa-plug-circle-xmark" }; + const colorMap = { panic: "text-red-400", crash: "text-red-400", timeout: "text-amber-400", network: "text-slate-400" }; + const icon = iconMap[client.disconnectReason] || "fa-circle-exclamation"; + const color = colorMap[client.disconnectReason] || "text-slate-400"; + const detail = client.disconnectDetail ? escapeHtml(client.disconnectDetail) : ""; + return ` ${escapeHtml(client.disconnectReason)}`; + })() : ""} ${hasTagNote ? `
${escapeHtml(customTagNote)}
` : ""}
diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 902921c..bcb9a18 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -97,6 +97,12 @@ db.run( db.run( `CREATE INDEX IF NOT EXISTS idx_clients_ping_ms ON clients(ping_ms);`, ); +try { + db.run(`ALTER TABLE clients ADD COLUMN disconnect_reason TEXT`); +} catch {} +try { + db.run(`ALTER TABLE clients ADD COLUMN disconnect_detail TEXT`); +} catch {} db.run(` CREATE TABLE IF NOT EXISTS banned_ips ( @@ -304,13 +310,22 @@ export function upsertClientRow( } } -export function setOnlineState(id: string, online: boolean) { - db.run( - `UPDATE clients SET online=?, last_seen=? WHERE id=?`, - online ? 1 : 0, - Date.now(), - id, - ); +export function setOnlineState(id: string, online: boolean, disconnectReason?: string, disconnectDetail?: string) { + if (online) { + db.run( + `UPDATE clients SET online=1, last_seen=?, disconnect_reason=NULL, disconnect_detail=NULL WHERE id=?`, + Date.now(), + id, + ); + } else { + db.run( + `UPDATE clients SET online=0, last_seen=?, disconnect_reason=?, disconnect_detail=? WHERE id=?`, + Date.now(), + disconnectReason ?? null, + disconnectDetail ?? null, + id, + ); + } } export function deleteClientRow(id: string) { @@ -541,7 +556,7 @@ export function listClients(filters: ListFilters): ListResult { const rows = db .query( - `SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked, enrollment_status as enrollmentStatus, public_key as publicKey, key_fingerprint as keyFingerprint, cpu, gpu, ram + `SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked, enrollment_status as enrollmentStatus, public_key as publicKey, key_fingerprint as keyFingerprint, cpu, gpu, ram, disconnect_reason as disconnectReason, disconnect_detail as disconnectDetail FROM clients ${whereSql} ${orderBy} @@ -573,6 +588,8 @@ export function listClients(filters: ListFilters): ListResult { cpu: c.cpu || null, gpu: c.gpu || null, ram: c.ram || null, + disconnectReason: c.disconnectReason || null, + disconnectDetail: c.disconnectDetail || null, thumbnail: getThumbnail(c.id), })); diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index ff42802..225c9eb 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -555,6 +555,16 @@ export async function handleWebSocketMessage( } break; } + case "disconnect_info": { + const reason = typeof (payload as any).reason === "string" ? (payload as any).reason : ""; + const detail = typeof (payload as any).detail === "string" ? (payload as any).detail : ""; + if (reason) { + ws.data.disconnectReason = reason; + ws.data.disconnectDetail = detail || undefined; + logger.debug(`[disconnect_info] ${client.id} reason=${reason}`); + } + break; + } default: break; } @@ -709,15 +719,18 @@ export function handleWebSocketClose( }); } + const storedDisconnectReason = ws.data.disconnectReason; + const storedDisconnectDetail = ws.data.disconnectDetail; + clientManager.deleteClient(clientId); stopAllProxiesForClient(clientId); clearClientSyncState(clientId); deps.notifyConsoleClosed(clientId, "Client disconnected"); - setOnlineState(clientId, false); + setOnlineState(clientId, false, storedDisconnectReason, storedDisconnectDetail); deps.clearPendingNotificationScreenshots(clientId); deps.clearClientPluginState(clientId); deps.notifyDashboard(); - logger.info(`[close] ${clientId} code=${code} reason=${reason}`); + logger.info(`[close] ${clientId} code=${code} reason=${reason} disconnect_reason=${storedDisconnectReason || "unknown"}`); if (role === "client") { deps.notifyRemoteDesktopStatus(clientId, "offline", "Client disconnected"); @@ -729,7 +742,7 @@ export function handleWebSocketClose( action: AuditAction.CLIENT_DISCONNECT, targetClientId: clientId, success: true, - details: JSON.stringify({ code, reason: String(reason || "") }), + details: JSON.stringify({ code, reason: String(reason || ""), disconnectReason: storedDisconnectReason || null }), }); } } diff --git a/Overlord-Server/src/sessions/types.ts b/Overlord-Server/src/sessions/types.ts index 1ba7e89..90871fd 100644 --- a/Overlord-Server/src/sessions/types.ts +++ b/Overlord-Server/src/sessions/types.ts @@ -24,6 +24,8 @@ export type SocketData = { autoTasksRan?: boolean; autoDeploysRan?: boolean; enrollmentNonce?: string; + disconnectReason?: string; + disconnectDetail?: string; }; export type ConsoleSession = { diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts index f02e2d7..79e4cb0 100644 --- a/Overlord-Server/src/types.ts +++ b/Overlord-Server/src/types.ts @@ -31,6 +31,8 @@ export type ClientInfo = { enrollmentStatus?: EnrollmentStatus; publicKey?: string; keyFingerprint?: string; + disconnectReason?: string; + disconnectDetail?: string; }; export type ListFilters = { diff --git a/Overlord-Server/src/wsValidation.ts b/Overlord-Server/src/wsValidation.ts index 585b258..710a36d 100644 --- a/Overlord-Server/src/wsValidation.ts +++ b/Overlord-Server/src/wsValidation.ts @@ -32,6 +32,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([ "clipboard_content", "proxy_data", "proxy_close", + "disconnect_info", ]); export function isAllowedClientMessageType(type: string): boolean {