some GUI changes
Build and Push Multi-Arch Docker Image / build (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build (push) Has been cancelled
This commit is contained in:
1 parent
8ce01cb00a
commit
21e03aa990
4 files changed
+93
-3
No files matched your search
@@ -10,6 +10,7 @@ Multi-connection SSH client that runs in your browser with JWT authentication.
|
|||||||
- **SFTP File Manager**: Built-in file manager with upload/download capabilities
|
- **SFTP File Manager**: Built-in file manager with upload/download capabilities
|
||||||
- **Terminal emulator**: Uses xterm.js for a full terminal experience
|
- **Terminal emulator**: Uses xterm.js for a full terminal experience
|
||||||
- **Connection management**: Sidebar with connection list, status indicators, and quick actions
|
- **Connection management**: Sidebar with connection list, status indicators, and quick actions
|
||||||
|
- **Remote load monitoring**: Active SSH connections display the remote Linux 1/5/15-minute load average
|
||||||
- **Session Recording**: Automatic recording of SSH sessions in asciinema format with playback and configurable retention
|
- **Session Recording**: Automatic recording of SSH sessions in asciinema format with playback and configurable retention
|
||||||
- **Dark theme**: Modern dark UI design
|
- **Dark theme**: Modern dark UI design
|
||||||
- **Single port**: All services run through port 8080 (HTTP API + WebSocket)
|
- **Single port**: All services run through port 8080 (HTTP API + WebSocket)
|
||||||
|
|||||||
+20
-1
@@ -77,12 +77,31 @@ body {
|
|||||||
.connection-name {
|
.connection-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 4px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-heading .connection-name { min-width: 0; }
|
||||||
|
|
||||||
|
.connection-load {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: 145px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #6ee7b7;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.connection-info {
|
.connection-info {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
|||||||
+20
-1
@@ -40,6 +40,7 @@ async function loadSavedConnections() {
|
|||||||
connections.forEach(conn => {
|
connections.forEach(conn => {
|
||||||
conn.connected = false;
|
conn.connected = false;
|
||||||
conn.socket = null;
|
conn.socket = null;
|
||||||
|
conn.loadAverage = null;
|
||||||
if (!conn.terminalBuffer) conn.terminalBuffer = [];
|
if (!conn.terminalBuffer) conn.terminalBuffer = [];
|
||||||
});
|
});
|
||||||
renderConnectionList();
|
renderConnectionList();
|
||||||
@@ -76,6 +77,7 @@ function loadLocalConnections() {
|
|||||||
key: '',
|
key: '',
|
||||||
connected: false,
|
connected: false,
|
||||||
socket: null,
|
socket: null,
|
||||||
|
loadAverage: null,
|
||||||
terminalBuffer: c.terminalBuffer || []
|
terminalBuffer: c.terminalBuffer || []
|
||||||
}));
|
}));
|
||||||
renderConnectionList();
|
renderConnectionList();
|
||||||
@@ -414,6 +416,7 @@ function createConnection() {
|
|||||||
disconnectTimeout: disconnectTimeout,
|
disconnectTimeout: disconnectTimeout,
|
||||||
connected: false,
|
connected: false,
|
||||||
socket: null,
|
socket: null,
|
||||||
|
loadAverage: null,
|
||||||
terminalBuffer: []
|
terminalBuffer: []
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -433,11 +436,17 @@ function renderConnectionList() {
|
|||||||
const list = document.getElementById('connectionList');
|
const list = document.getElementById('connectionList');
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
connections.forEach(conn => {
|
connections.forEach(conn => {
|
||||||
|
const loadText = conn.connected
|
||||||
|
? (conn.loadAverage || 'N/A')
|
||||||
|
: (conn.socket && conn.socket.readyState === WebSocket.CONNECTING ? 'Loading...' : '—');
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = `connection-item ${conn.id === currentConnectionId ? 'active' : ''} ${conn.connected ? 'connected' : 'disconnected'}`;
|
item.className = `connection-item ${conn.id === currentConnectionId ? 'active' : ''} ${conn.connected ? 'connected' : 'disconnected'}`;
|
||||||
item.onclick = () => selectConnection(conn.id);
|
item.onclick = () => selectConnection(conn.id);
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="connection-name">${conn.name}</div>
|
<div class="connection-heading">
|
||||||
|
<div class="connection-name">${conn.name}</div>
|
||||||
|
<div class="connection-load" title="Remote Linux load average">Load: ${loadText}</div>
|
||||||
|
</div>
|
||||||
<div class="connection-info">
|
<div class="connection-info">
|
||||||
<span class="connection-status ${conn.connected ? 'connected' : 'disconnected'}"></span>
|
<span class="connection-status ${conn.connected ? 'connected' : 'disconnected'}"></span>
|
||||||
${conn.username}@${conn.host}:${conn.port}
|
${conn.username}@${conn.host}:${conn.port}
|
||||||
@@ -607,6 +616,8 @@ async function connectConnection(id) {
|
|||||||
|
|
||||||
term.writeln(`Connecting to ${conn.name} (${conn.username}@${conn.host}:${conn.port})...`);
|
term.writeln(`Connecting to ${conn.name} (${conn.username}@${conn.host}:${conn.port})...`);
|
||||||
updateTerminalStatus('connecting');
|
updateTerminalStatus('connecting');
|
||||||
|
conn.loadAverage = null;
|
||||||
|
renderConnectionList();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!backendWsPort) {
|
if (!backendWsPort) {
|
||||||
@@ -649,12 +660,18 @@ async function connectConnection(id) {
|
|||||||
resetInactivityTimers();
|
resetInactivityTimers();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'load_average') {
|
||||||
|
conn.loadAverage = msg.value || null;
|
||||||
|
renderConnectionList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type === 'error') {
|
if (msg.type === 'error') {
|
||||||
if (conn.id === currentConnectionId) {
|
if (conn.id === currentConnectionId) {
|
||||||
term.writeln(`[ERROR] ${msg.message}`);
|
term.writeln(`[ERROR] ${msg.message}`);
|
||||||
updateTerminalStatus('disconnected');
|
updateTerminalStatus('disconnected');
|
||||||
}
|
}
|
||||||
conn.connected = false;
|
conn.connected = false;
|
||||||
|
conn.loadAverage = null;
|
||||||
renderConnectionList();
|
renderConnectionList();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -673,11 +690,13 @@ async function connectConnection(id) {
|
|||||||
updateTerminalStatus('disconnected');
|
updateTerminalStatus('disconnected');
|
||||||
}
|
}
|
||||||
conn.connected = false;
|
conn.connected = false;
|
||||||
|
conn.loadAverage = null;
|
||||||
renderConnectionList();
|
renderConnectionList();
|
||||||
};
|
};
|
||||||
|
|
||||||
conn.socket.onclose = (event) => {
|
conn.socket.onclose = (event) => {
|
||||||
conn.connected = false;
|
conn.connected = false;
|
||||||
|
conn.loadAverage = null;
|
||||||
conn.socket = null;
|
conn.socket = null;
|
||||||
if (conn.id === currentConnectionId) {
|
if (conn.id === currentConnectionId) {
|
||||||
term.writeln(`[DISCONNECTED] Code: ${event.code}`);
|
term.writeln(`[DISCONNECTED] Code: ${event.code}`);
|
||||||
|
|||||||
@@ -215,7 +215,58 @@ async def handle_client(ws: WebSocketServerProtocol):
|
|||||||
finally:
|
finally:
|
||||||
logger.info(f"ssh_to_ws finished for {client_ip}")
|
logger.info(f"ssh_to_ws finished for {client_ip}")
|
||||||
|
|
||||||
await asyncio.gather(ws_to_ssh(), ssh_to_ws())
|
async def monitor_load_average():
|
||||||
|
"""Send remote Linux load averages without using the interactive shell."""
|
||||||
|
monitoring_error_logged = False
|
||||||
|
try:
|
||||||
|
while chan.active and not ws_closed.is_set():
|
||||||
|
try:
|
||||||
|
command = "cat /proc/loadavg"
|
||||||
|
|
||||||
|
def read_load_average():
|
||||||
|
stdin, stdout, stderr = ssh.exec_command(command, timeout=SSH_TIMEOUT)
|
||||||
|
try:
|
||||||
|
return stdout.read().decode(errors="ignore")
|
||||||
|
finally:
|
||||||
|
stdin.close()
|
||||||
|
stdout.close()
|
||||||
|
stderr.close()
|
||||||
|
|
||||||
|
output = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, read_load_average
|
||||||
|
)
|
||||||
|
load_values = output.split()[:3]
|
||||||
|
if len(load_values) != 3 or any(
|
||||||
|
not value.replace('.', '', 1).isdigit() for value in load_values
|
||||||
|
):
|
||||||
|
raise ValueError("Unexpected /proc/loadavg response")
|
||||||
|
load_average = ", ".join(load_values)
|
||||||
|
monitoring_error_logged = False
|
||||||
|
except Exception as e:
|
||||||
|
load_average = None
|
||||||
|
if not monitoring_error_logged:
|
||||||
|
logger.warning(f"Load average unavailable for {config.host}: {e}")
|
||||||
|
monitoring_error_logged = True
|
||||||
|
|
||||||
|
if ws_closed.is_set():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"type": "load_average",
|
||||||
|
"value": load_average
|
||||||
|
}))
|
||||||
|
except websockets.exceptions.ConnectionClosed:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(ws_closed.wait(), timeout=5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
logger.info(f"load monitor finished for {client_ip}")
|
||||||
|
|
||||||
|
await asyncio.gather(ws_to_ssh(), ssh_to_ws(), monitor_load_average())
|
||||||
logger.info(f"Session ended for {client_ip}")
|
logger.info(f"Session ended for {client_ip}")
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
|
|||||||
Reference in new issue
Block a user