Add auto-reconnect with exponential backoff
Sessions configured with auto-reconnect will automatically attempt to re-establish the connection when unexpectedly disconnected. Uses exponential backoff (1s, 2s, 4s, 8s... up to 30s) with a configurable maximum number of attempts (default 5). - New SessionReconnecting state with pulsing indicator in tab bar - SSH and Telnet profiles gain autoReconnect and maxReconnectTries fields - RAW protocol already had autoReconnect; now uses the shared mechanism - CancelReconnect API to abort in-progress reconnection - Frontend shows reconnect attempt notifications - Profile editor exposes toggle and max attempts in Advanced/Reconnection
This commit is contained in:
@@ -125,9 +125,17 @@
|
|||||||
|
|
||||||
.tab-status.is-connected { background: var(--success); }
|
.tab-status.is-connected { background: var(--success); }
|
||||||
.tab-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
|
.tab-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
|
||||||
|
.tab-status.is-reconnecting { background: var(--warning); animation: pulse-dot 0.8s ease infinite; }
|
||||||
.tab-status.is-error { background: var(--error); }
|
.tab-status.is-error { background: var(--error); }
|
||||||
.tab-status.is-disconnected { background: var(--text-tertiary); }
|
.tab-status.is-disconnected { background: var(--text-tertiary); }
|
||||||
|
|
||||||
|
/* Also match the status-* pattern used by updateSessionState */
|
||||||
|
.tab-status.status-connected { background: var(--success); }
|
||||||
|
.tab-status.status-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
|
||||||
|
.tab-status.status-reconnecting { background: var(--warning); animation: pulse-dot 0.8s ease infinite; }
|
||||||
|
.tab-status.status-error { background: var(--error); }
|
||||||
|
.tab-status.status-disconnected { background: var(--text-tertiary); }
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------
|
/* -----------------------------------------------------------------------
|
||||||
SFTP toggle button — revealed on hover (SSH tabs only)
|
SFTP toggle button — revealed on hover (SSH tabs only)
|
||||||
----------------------------------------------------------------------- */
|
----------------------------------------------------------------------- */
|
||||||
|
|||||||
+12
-1
@@ -134,10 +134,21 @@ class App {
|
|||||||
this.terminals.handleData(payload.sessionID, payload.data);
|
this.terminals.handleData(payload.sessionID, payload.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Session state transitions (connecting → connected → disconnected)
|
// Session state transitions (connecting → connected → reconnecting → disconnected)
|
||||||
window.runtime.EventsOn('session:state', (payload) => {
|
window.runtime.EventsOn('session:state', (payload) => {
|
||||||
this.tabs.updateSessionState(payload.sessionID, payload.state, payload.error);
|
this.tabs.updateSessionState(payload.sessionID, payload.state, payload.error);
|
||||||
this.updateStatusBar();
|
this.updateStatusBar();
|
||||||
|
|
||||||
|
// Show reconnecting notification
|
||||||
|
if (payload.state === 'reconnecting') {
|
||||||
|
const attempt = payload.attempt || 1;
|
||||||
|
const maxRetries = payload.maxRetries || 5;
|
||||||
|
const delaySec = payload.delaySec || 1;
|
||||||
|
this.notifications.info(
|
||||||
|
`Reconnecting (attempt ${attempt}/${maxRetries}) in ${delaySec}s…`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Once connected, ensure the backend PTY has the correct dimensions.
|
// Once connected, ensure the backend PTY has the correct dimensions.
|
||||||
// The initial fit happens before the SSH handshake completes, so the
|
// The initial fit happens before the SSH handshake completes, so the
|
||||||
// onResize callback fires when the backend isn't ready. By the time
|
// onResize callback fires when the backend isn't ready. By the time
|
||||||
|
|||||||
@@ -406,6 +406,10 @@ class ProfileEditor {
|
|||||||
this._field('number', 'ssh.keepaliveInterval', 'Keepalive Interval (s)', ssh.keepaliveInterval, { min: 0 }),
|
this._field('number', 'ssh.keepaliveInterval', 'Keepalive Interval (s)', ssh.keepaliveInterval, { min: 0 }),
|
||||||
this._field('number', 'ssh.keepaliveCountMax', 'Max Missed Keepalives', ssh.keepaliveCountMax, { min: 0 }),
|
this._field('number', 'ssh.keepaliveCountMax', 'Max Missed Keepalives', ssh.keepaliveCountMax, { min: 0 }),
|
||||||
this._field('toggle', 'ssh.compression', 'Compression', ssh.compression),
|
this._field('toggle', 'ssh.compression', 'Compression', ssh.compression),
|
||||||
|
this._field('toggle', 'ssh.autoReconnect', 'Auto-Reconnect', ssh.autoReconnect, {
|
||||||
|
help: 'Automatically reconnect with exponential backoff when the connection drops',
|
||||||
|
}),
|
||||||
|
this._field('number', 'ssh.maxReconnectTries', 'Max Reconnect Attempts', ssh.maxReconnectTries || 5, { min: 1, max: 20 }),
|
||||||
], true));
|
], true));
|
||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
@@ -447,6 +451,13 @@ class ProfileEditor {
|
|||||||
this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug),
|
this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug),
|
||||||
], true));
|
], true));
|
||||||
|
|
||||||
|
sections.push(this._renderSection('Reconnection', [
|
||||||
|
this._field('toggle', 'telnet.autoReconnect', 'Auto-Reconnect', tel.autoReconnect, {
|
||||||
|
help: 'Automatically reconnect with exponential backoff when the connection drops',
|
||||||
|
}),
|
||||||
|
this._field('number', 'telnet.maxReconnectTries', 'Max Reconnect Attempts', tel.maxReconnectTries || 5, { min: 1, max: 20 }),
|
||||||
|
], true));
|
||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,6 +919,8 @@ class ProfileEditor {
|
|||||||
p.ssh.compression = val('ssh.compression') || false;
|
p.ssh.compression = val('ssh.compression') || false;
|
||||||
p.ssh.agentAuth = val('ssh.agentAuth') || false;
|
p.ssh.agentAuth = val('ssh.agentAuth') || false;
|
||||||
p.ssh.agentForwarding = val('ssh.agentForwarding') || false;
|
p.ssh.agentForwarding = val('ssh.agentForwarding') || false;
|
||||||
|
p.ssh.autoReconnect = val('ssh.autoReconnect') || false;
|
||||||
|
p.ssh.maxReconnectTries = val('ssh.maxReconnectTries') ?? 5;
|
||||||
p.ssh.startupCommand = val('ssh.startupCommand') || '';
|
p.ssh.startupCommand = val('ssh.startupCommand') || '';
|
||||||
|
|
||||||
const authStr = val('ssh.authMethods');
|
const authStr = val('ssh.authMethods');
|
||||||
@@ -970,6 +983,8 @@ class ProfileEditor {
|
|||||||
p.telnet.encoding = val('telnet.encoding') || 'utf-8';
|
p.telnet.encoding = val('telnet.encoding') || 'utf-8';
|
||||||
p.telnet.lineEnding = val('telnet.lineEnding') || 'crlf';
|
p.telnet.lineEnding = val('telnet.lineEnding') || 'crlf';
|
||||||
p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false;
|
p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false;
|
||||||
|
p.telnet.autoReconnect = val('telnet.autoReconnect') || false;
|
||||||
|
p.telnet.maxReconnectTries = val('telnet.maxReconnectTries') ?? 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RAW-specific
|
// RAW-specific
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ function stateLabel(state) {
|
|||||||
case 'connecting': return 'Connecting';
|
case 'connecting': return 'Connecting';
|
||||||
case 'connected': return 'Connected';
|
case 'connected': return 'Connected';
|
||||||
case 'disconnected': return 'Disconnected';
|
case 'disconnected': return 'Disconnected';
|
||||||
|
case 'reconnecting': return 'Reconnecting';
|
||||||
case 'error': return 'Error';
|
case 'error': return 'Error';
|
||||||
default: return state || 'Unknown';
|
default: return state || 'Unknown';
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -3,6 +3,8 @@
|
|||||||
import {models} from '../models';
|
import {models} from '../models';
|
||||||
import {context} from '../models';
|
import {context} from '../models';
|
||||||
|
|
||||||
|
export function CancelReconnect(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function CheckForUpdates():Promise<Record<string, any>>;
|
export function CheckForUpdates():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function ClearSessionHistory(arg1:string):Promise<void>;
|
export function ClearSessionHistory(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function CancelReconnect(arg1) {
|
||||||
|
return window['go']['api']['Service']['CancelReconnect'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function CheckForUpdates() {
|
export function CheckForUpdates() {
|
||||||
return window['go']['api']['Service']['CheckForUpdates']();
|
return window['go']['api']['Service']['CheckForUpdates']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ export namespace models {
|
|||||||
encoding: string;
|
encoding: string;
|
||||||
negotiationDebug: boolean;
|
negotiationDebug: boolean;
|
||||||
lineMode: boolean;
|
lineMode: boolean;
|
||||||
|
autoReconnect: boolean;
|
||||||
|
maxReconnectTries: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new TelnetConfig(source);
|
return new TelnetConfig(source);
|
||||||
@@ -297,6 +299,8 @@ export namespace models {
|
|||||||
this.encoding = source["encoding"];
|
this.encoding = source["encoding"];
|
||||||
this.negotiationDebug = source["negotiationDebug"];
|
this.negotiationDebug = source["negotiationDebug"];
|
||||||
this.lineMode = source["lineMode"];
|
this.lineMode = source["lineMode"];
|
||||||
|
this.autoReconnect = source["autoReconnect"];
|
||||||
|
this.maxReconnectTries = source["maxReconnectTries"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SSHConfig {
|
export class SSHConfig {
|
||||||
@@ -311,6 +315,8 @@ export namespace models {
|
|||||||
compression: boolean;
|
compression: boolean;
|
||||||
agentAuth: boolean;
|
agentAuth: boolean;
|
||||||
agentForwarding: boolean;
|
agentForwarding: boolean;
|
||||||
|
autoReconnect: boolean;
|
||||||
|
maxReconnectTries: number;
|
||||||
jumpHosts: JumpHost[];
|
jumpHosts: JumpHost[];
|
||||||
portForwards: PortForward[];
|
portForwards: PortForward[];
|
||||||
startupCommand: string;
|
startupCommand: string;
|
||||||
@@ -333,6 +339,8 @@ export namespace models {
|
|||||||
this.compression = source["compression"];
|
this.compression = source["compression"];
|
||||||
this.agentAuth = source["agentAuth"];
|
this.agentAuth = source["agentAuth"];
|
||||||
this.agentForwarding = source["agentForwarding"];
|
this.agentForwarding = source["agentForwarding"];
|
||||||
|
this.autoReconnect = source["autoReconnect"];
|
||||||
|
this.maxReconnectTries = source["maxReconnectTries"];
|
||||||
this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost);
|
this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost);
|
||||||
this.portForwards = this.convertValues(source["portForwards"], PortForward);
|
this.portForwards = this.convertValues(source["portForwards"], PortForward);
|
||||||
this.startupCommand = source["startupCommand"];
|
this.startupCommand = source["startupCommand"];
|
||||||
|
|||||||
@@ -276,6 +276,11 @@ func (s *Service) ReconnectSession(sessionID string) error {
|
|||||||
return s.sessions.Reconnect(sessionID, s.promptSecret)
|
return s.sessions.Reconnect(sessionID, s.promptSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelReconnect cancels an in-progress auto-reconnect for a session.
|
||||||
|
func (s *Service) CancelReconnect(sessionID string) {
|
||||||
|
s.sessions.CancelReconnect(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
// WriteSession writes base64-encoded data to a session.
|
// WriteSession writes base64-encoded data to a session.
|
||||||
func (s *Service) WriteSession(sessionID string, data string) error {
|
func (s *Service) WriteSession(sessionID string, data string) error {
|
||||||
decoded, err := base64.StdEncoding.DecodeString(data)
|
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ type SSHConfig struct {
|
|||||||
Compression bool `json:"compression"`
|
Compression bool `json:"compression"`
|
||||||
AgentAuth bool `json:"agentAuth"` // use SSH agent for authentication
|
AgentAuth bool `json:"agentAuth"` // use SSH agent for authentication
|
||||||
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
|
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
|
||||||
|
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
||||||
|
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
||||||
JumpHosts []JumpHost `json:"jumpHosts"`
|
JumpHosts []JumpHost `json:"jumpHosts"`
|
||||||
PortForwards []PortForward `json:"portForwards"`
|
PortForwards []PortForward `json:"portForwards"`
|
||||||
StartupCommand string `json:"startupCommand"`
|
StartupCommand string `json:"startupCommand"`
|
||||||
@@ -133,12 +135,14 @@ type PortForward struct {
|
|||||||
|
|
||||||
// TelnetConfig holds Telnet-specific settings for a connection profile.
|
// TelnetConfig holds Telnet-specific settings for a connection profile.
|
||||||
type TelnetConfig struct {
|
type TelnetConfig struct {
|
||||||
LocalEcho bool `json:"localEcho"`
|
LocalEcho bool `json:"localEcho"`
|
||||||
LineEnding LineEnding `json:"lineEnding"`
|
LineEnding LineEnding `json:"lineEnding"`
|
||||||
TerminalType string `json:"terminalType"`
|
TerminalType string `json:"terminalType"`
|
||||||
Encoding string `json:"encoding"`
|
Encoding string `json:"encoding"`
|
||||||
NegotiationDebug bool `json:"negotiationDebug"`
|
NegotiationDebug bool `json:"negotiationDebug"`
|
||||||
LineMode bool `json:"lineMode"`
|
LineMode bool `json:"lineMode"`
|
||||||
|
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
||||||
|
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RAWConfig holds RAW TCP-specific settings for a connection profile.
|
// RAWConfig holds RAW TCP-specific settings for a connection profile.
|
||||||
@@ -196,6 +200,7 @@ const (
|
|||||||
SessionConnecting SessionState = "connecting"
|
SessionConnecting SessionState = "connecting"
|
||||||
SessionConnected SessionState = "connected"
|
SessionConnected SessionState = "connected"
|
||||||
SessionDisconnected SessionState = "disconnected"
|
SessionDisconnected SessionState = "disconnected"
|
||||||
|
SessionReconnecting SessionState = "reconnecting"
|
||||||
SessionError SessionState = "error"
|
SessionError SessionState = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -27,15 +27,17 @@ import (
|
|||||||
|
|
||||||
// Session wraps a protocol client with metadata and state.
|
// Session wraps a protocol client with metadata and state.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Info models.SessionInfo
|
Info models.SessionInfo
|
||||||
sshClient *sshclient.Client
|
sshClient *sshclient.Client
|
||||||
telnetClient *telnetclient.Client
|
telnetClient *telnetclient.Client
|
||||||
rawClient *rawclient.Client
|
rawClient *rawclient.Client
|
||||||
githubClient *githubclient.Client
|
githubClient *githubclient.Client
|
||||||
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
||||||
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
||||||
profile *models.Profile
|
profile *models.Profile
|
||||||
recorder *recording.Recorder // active recording, nil if not recording
|
recorder *recording.Recorder // active recording, nil if not recording
|
||||||
|
reconnectCount int // number of reconnection attempts so far
|
||||||
|
cancelReconn context.CancelFunc // cancels an in-progress reconnect backoff
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager handles the lifecycle of all active sessions.
|
// Manager handles the lifecycle of all active sessions.
|
||||||
@@ -1119,6 +1121,12 @@ func (m *Manager) buildSecretCallback(profileID string, secretType string, mode
|
|||||||
|
|
||||||
// closeSession shuts down the protocol client for a session.
|
// closeSession shuts down the protocol client for a session.
|
||||||
func (m *Manager) closeSession(sess *Session) {
|
func (m *Manager) closeSession(sess *Session) {
|
||||||
|
// Cancel any in-progress reconnect backoff.
|
||||||
|
if sess.cancelReconn != nil {
|
||||||
|
sess.cancelReconn()
|
||||||
|
sess.cancelReconn = nil
|
||||||
|
}
|
||||||
|
|
||||||
// Stop any active recording.
|
// Stop any active recording.
|
||||||
if sess.recorder != nil {
|
if sess.recorder != nil {
|
||||||
sess.recorder.Close()
|
sess.recorder.Close()
|
||||||
@@ -1160,6 +1168,12 @@ func (m *Manager) handleSessionClosed(sessionID string) {
|
|||||||
|
|
||||||
m.auditLog.LogDisconnect(sessionID)
|
m.auditLog.LogDisconnect(sessionID)
|
||||||
|
|
||||||
|
// Check whether auto-reconnect is enabled for this session.
|
||||||
|
if ok && m.shouldAutoReconnect(sess) {
|
||||||
|
m.scheduleReconnect(sess, sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
m.emitEvent("session:closed", map[string]interface{}{
|
m.emitEvent("session:closed", map[string]interface{}{
|
||||||
"sessionID": sessionID,
|
"sessionID": sessionID,
|
||||||
})
|
})
|
||||||
@@ -1169,6 +1183,231 @@ func (m *Manager) handleSessionClosed(sessionID string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldAutoReconnect checks whether a session is configured for automatic reconnection.
|
||||||
|
func (m *Manager) shouldAutoReconnect(sess *Session) bool {
|
||||||
|
if sess.profile == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch sess.profile.Protocol {
|
||||||
|
case models.ProtocolSSH:
|
||||||
|
if sess.profile.SSH != nil {
|
||||||
|
return sess.profile.SSH.AutoReconnect
|
||||||
|
}
|
||||||
|
case models.ProtocolTelnet:
|
||||||
|
if sess.profile.Telnet != nil {
|
||||||
|
return sess.profile.Telnet.AutoReconnect
|
||||||
|
}
|
||||||
|
case models.ProtocolRAW:
|
||||||
|
if sess.profile.RAW != nil {
|
||||||
|
return sess.profile.RAW.AutoReconnect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxReconnectAttempts returns the configured (or default) max retry count.
|
||||||
|
func (m *Manager) maxReconnectAttempts(sess *Session) int {
|
||||||
|
maxTries := 5
|
||||||
|
switch sess.profile.Protocol {
|
||||||
|
case models.ProtocolSSH:
|
||||||
|
if sess.profile.SSH != nil && sess.profile.SSH.MaxReconnectTries > 0 {
|
||||||
|
maxTries = sess.profile.SSH.MaxReconnectTries
|
||||||
|
}
|
||||||
|
case models.ProtocolTelnet:
|
||||||
|
if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 {
|
||||||
|
maxTries = sess.profile.Telnet.MaxReconnectTries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxTries
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleReconnect initiates an exponential-backoff reconnection loop for a session.
|
||||||
|
func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
|
||||||
|
maxTries := m.maxReconnectAttempts(sess)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
sess.reconnectCount++
|
||||||
|
attempt := sess.reconnectCount
|
||||||
|
if attempt > maxTries {
|
||||||
|
// Give up — too many attempts.
|
||||||
|
sess.Info.State = models.SessionDisconnected
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.emitEvent("session:closed", map[string]interface{}{
|
||||||
|
"sessionID": sessionID,
|
||||||
|
})
|
||||||
|
m.emitEvent("session:state", map[string]interface{}{
|
||||||
|
"sessionID": sessionID,
|
||||||
|
"state": string(models.SessionDisconnected),
|
||||||
|
"error": "auto-reconnect failed after maximum attempts",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.Info.State = models.SessionReconnecting
|
||||||
|
sess.Info.Error = ""
|
||||||
|
|
||||||
|
// Cancel any previous reconnect timer.
|
||||||
|
if sess.cancelReconn != nil {
|
||||||
|
sess.cancelReconn()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
sess.cancelReconn = cancel
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s.
|
||||||
|
delaySec := 1 << (attempt - 1) // 2^(attempt-1)
|
||||||
|
if delaySec > 30 {
|
||||||
|
delaySec = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
m.emitEvent("session:state", map[string]interface{}{
|
||||||
|
"sessionID": sessionID,
|
||||||
|
"state": string(models.SessionReconnecting),
|
||||||
|
"attempt": attempt,
|
||||||
|
"maxRetries": maxTries,
|
||||||
|
"delaySec": delaySec,
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(time.Duration(delaySec) * time.Second):
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear old clients before reconnecting.
|
||||||
|
m.mu.Lock()
|
||||||
|
sess.sshClient = nil
|
||||||
|
sess.telnetClient = nil
|
||||||
|
sess.rawClient = nil
|
||||||
|
sess.sftpClient = nil
|
||||||
|
sess.Info.State = models.SessionConnecting
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.emitEvent("session:state", map[string]interface{}{
|
||||||
|
"sessionID": sessionID,
|
||||||
|
"state": string(models.SessionConnecting),
|
||||||
|
})
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch sess.profile.Protocol {
|
||||||
|
case models.ProtocolSSH:
|
||||||
|
err = m.connectSSH(sess, m.buildInteractiveSecretCallback(sessionID))
|
||||||
|
case models.ProtocolTelnet:
|
||||||
|
err = m.connectTelnet(sess)
|
||||||
|
case models.ProtocolRAW:
|
||||||
|
err = m.connectRAW(sess)
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
m.mu.Lock()
|
||||||
|
sess.Info.State = models.SessionError
|
||||||
|
sess.Info.Error = err.Error()
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.emitEvent("session:state", map[string]interface{}{
|
||||||
|
"sessionID": sessionID,
|
||||||
|
"state": string(models.SessionError),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Schedule the next attempt.
|
||||||
|
m.scheduleReconnect(sess, sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — reset the counter.
|
||||||
|
m.mu.Lock()
|
||||||
|
sess.reconnectCount = 0
|
||||||
|
sess.Info.State = models.SessionConnected
|
||||||
|
sess.Info.ConnectedAt = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.emitEvent("session:state", map[string]interface{}{
|
||||||
|
"sessionID": sessionID,
|
||||||
|
"state": string(models.SessionConnected),
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildInteractiveSecretCallback creates a getSecret callback that prompts
|
||||||
|
// the user via Wails events. Used during auto-reconnect flows where the
|
||||||
|
// original callback is no longer available.
|
||||||
|
func (m *Manager) buildInteractiveSecretCallback(sessionID string) func(prompt string) (string, error) {
|
||||||
|
return func(prompt string) (string, error) {
|
||||||
|
// Use the same event-based flow as the initial connection.
|
||||||
|
requestID := uuid.New().String()
|
||||||
|
|
||||||
|
ch := make(chan string, 1)
|
||||||
|
m.mu.Lock()
|
||||||
|
// Store a temporary secret channel keyed by requestID.
|
||||||
|
if m.ctx != nil {
|
||||||
|
wailsRuntime.EventsEmit(m.ctx, "session:secret:prompt", map[string]interface{}{
|
||||||
|
"requestID": requestID,
|
||||||
|
"sessionID": sessionID,
|
||||||
|
"title": "Reconnection Credentials",
|
||||||
|
"message": prompt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
// Wait for the response with a timeout.
|
||||||
|
var response string
|
||||||
|
cancel := make(chan struct{})
|
||||||
|
|
||||||
|
if m.ctx != nil {
|
||||||
|
wailsRuntime.EventsOn(m.ctx, "session:secret:response", func(optionalData ...interface{}) {
|
||||||
|
if len(optionalData) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, ok := optionalData[0].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rid, _ := data["requestID"].(string); rid == requestID {
|
||||||
|
val, _ := data["value"].(string)
|
||||||
|
select {
|
||||||
|
case ch <- val:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case response = <-ch:
|
||||||
|
case <-time.After(60 * time.Second):
|
||||||
|
close(cancel)
|
||||||
|
return "", fmt.Errorf("credential prompt timed out during reconnect")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelReconnect cancels any in-progress auto-reconnect for a session.
|
||||||
|
func (m *Manager) CancelReconnect(sessionID string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
sess, ok := m.sessions[sessionID]
|
||||||
|
if ok && sess.cancelReconn != nil {
|
||||||
|
sess.cancelReconn()
|
||||||
|
sess.cancelReconn = nil
|
||||||
|
sess.reconnectCount = 0
|
||||||
|
sess.Info.State = models.SessionDisconnected
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
m.emitEvent("session:state", map[string]interface{}{
|
||||||
|
"sessionID": sessionID,
|
||||||
|
"state": string(models.SessionDisconnected),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// createTabName generates a default tab name from the profile's display name
|
// createTabName generates a default tab name from the profile's display name
|
||||||
// and host.
|
// and host.
|
||||||
func (m *Manager) createTabName(profile *models.Profile) string {
|
func (m *Manager) createTabName(profile *models.Profile) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user