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 {