Add host connection status check endpoint and UI indicator

- Implement handle_connection_status API to test host port reachability
- Display Host:Up/Down status in the UI
This commit is contained in:
dido committed 2026-09-18 12:01:18 +03:00
1 parent 1209384cad
commit e4a3066445
3 files changed
+92

No files matched your search

+10
View File
@@ -134,6 +134,16 @@ body {
color: #9ca3af; color: #9ca3af;
} }
.host-status {
margin-top: 4px;
font-size: 11px;
font-weight: 500;
}
.host-status.up { color: #34d399; }
.host-status.down { color: #f87171; }
.host-status.checking { color: #fbbf24; }
.connection-status { .connection-status {
display: inline-block; display: inline-block;
width: 8px; width: 8px;
+40
View File
@@ -10,6 +10,7 @@ let maxUploadSize = localStorage.getItem('maxUploadSize') ? parseInt(localStorag
let apiBaseUrl = window.location.origin; let apiBaseUrl = window.location.origin;
let backendWsPort = null; let backendWsPort = null;
let backendWsHost = null; let backendWsHost = null;
let hostStatusTimer = null;
// Use the frontend proxy so the browser does not need direct access to the backend port. // Use the frontend proxy so the browser does not need direct access to the backend port.
function getWsUrl() { function getWsUrl() {
@@ -41,6 +42,8 @@ async function loadSavedConnections() {
if (!conn.terminalBuffer) conn.terminalBuffer = []; if (!conn.terminalBuffer) conn.terminalBuffer = [];
}); });
renderConnectionList(); renderConnectionList();
refreshHostStatuses();
startHostStatusPolling();
if (connections.length > 0) { if (connections.length > 0) {
term.writeln(`[Loaded ${connections.length} connections from server]`); term.writeln(`[Loaded ${connections.length} connections from server]`);
} }
@@ -454,6 +457,9 @@ function renderConnectionList() {
<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}
</div> </div>
<div class="host-status ${conn.hostStatus === true ? 'up' : conn.hostStatus === false ? 'down' : 'checking'}">
Host: ${conn.hostStatus === true ? 'Up' : conn.hostStatus === false ? 'Down' : 'Checking...'}
</div>
<div class="connection-actions" onclick="event.stopPropagation()"> <div class="connection-actions" onclick="event.stopPropagation()">
${conn.connected ${conn.connected
? `<button class="btn-small btn-disconnect" onclick="disconnectConnection('${conn.id}')">Disconnect</button>` ? `<button class="btn-small btn-disconnect" onclick="disconnectConnection('${conn.id}')">Disconnect</button>`
@@ -467,6 +473,40 @@ function renderConnectionList() {
}); });
} }
async function refreshHostStatuses() {
if (!authToken || connections.length === 0) return;
connections.forEach(conn => { conn.hostStatus = null; });
renderConnectionList();
try {
const response = await fetch(`${apiBaseUrl}/api/connections/status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ ids: connections.map(conn => conn.id) })
});
if (!response.ok) return;
const data = await response.json();
connections.forEach(conn => {
if (Object.prototype.hasOwnProperty.call(data.statuses || {}, conn.id)) {
conn.hostStatus = data.statuses[conn.id];
}
});
renderConnectionList();
} catch (e) {
console.warn('Failed to refresh host statuses:', e);
}
}
function startHostStatusPolling() {
if (hostStatusTimer) clearInterval(hostStatusTimer);
hostStatusTimer = setInterval(refreshHostStatuses, 30000);
}
function selectConnection(id) { function selectConnection(id) {
currentConnectionId = id; currentConnectionId = id;
const conn = getCurrentConnection(); const conn = getCurrentConnection();
+42
View File
@@ -5,7 +5,9 @@ import hashlib
import io import io
import json import json
import os import os
import socket
import stat import stat
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler from http.server import BaseHTTPRequestHandler
from typing import Optional from typing import Optional
@@ -44,6 +46,8 @@ class LoginHandler(BaseHTTPRequestHandler):
self.handle_verify() self.handle_verify()
elif parsed.path == '/api/connections': elif parsed.path == '/api/connections':
self.handle_save_connection() self.handle_save_connection()
elif parsed.path == '/api/connections/status':
self.handle_connection_status()
elif parsed.path == '/api/config': elif parsed.path == '/api/config':
self.handle_config() self.handle_config()
elif parsed.path == '/api/sftp/list': elif parsed.path == '/api/sftp/list':
@@ -197,6 +201,44 @@ class LoginHandler(BaseHTTPRequestHandler):
logger.error(f"Get connections error: {e}") logger.error(f"Get connections error: {e}")
self.send_json_response(500, {'error': 'Internal error'}) self.send_json_response(500, {'error': 'Internal error'})
def handle_connection_status(self):
"""Check whether the configured host ports are reachable."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
requested_ids = json.loads(body).get('ids', [])
if not isinstance(requested_ids, list):
self.send_json_response(400, {'error': 'Connection IDs must be a list'})
return
connections = creds_manager.get_connections(username)
requested_ids = set(requested_ids)
targets = [conn for conn in connections if conn['id'] in requested_ids]
def check_host(conn):
try:
with socket.create_connection(
(conn['host'], int(conn.get('port', 22))), timeout=2
):
return conn['id'], True
except (OSError, ValueError, TypeError):
return conn['id'], False
with ThreadPoolExecutor(max_workers=10) as executor:
statuses = dict(executor.map(check_host, targets))
self.send_json_response(200, {'statuses': statuses})
except (json.JSONDecodeError, TypeError, ValueError):
self.send_json_response(400, {'error': 'Invalid request'})
except Exception as e:
logger.error(f"Connection status error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_config(self): def handle_config(self):
"""Get configuration including max upload size.""" """Get configuration including max upload size."""
username = self._get_username_from_token() username = self._get_username_from_token()