From e4a30664458947090110e10a5422da1935e9d86c Mon Sep 17 00:00:00 2001 From: DanailDR Date: Fri, 18 Sep 2026 12:01:18 +0300 Subject: [PATCH] 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 --- src/static/webssh.css | 10 ++++++++ src/static/webssh.js | 40 ++++++++++++++++++++++++++++++++ src/webssh_backend/http_api.py | 42 ++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/src/static/webssh.css b/src/static/webssh.css index eb45cd2..231a2d8 100644 --- a/src/static/webssh.css +++ b/src/static/webssh.css @@ -134,6 +134,16 @@ body { 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 { display: inline-block; width: 8px; diff --git a/src/static/webssh.js b/src/static/webssh.js index c268b47..aa86b39 100644 --- a/src/static/webssh.js +++ b/src/static/webssh.js @@ -10,6 +10,7 @@ let maxUploadSize = localStorage.getItem('maxUploadSize') ? parseInt(localStorag let apiBaseUrl = window.location.origin; let backendWsPort = null; let backendWsHost = null; +let hostStatusTimer = null; // Use the frontend proxy so the browser does not need direct access to the backend port. function getWsUrl() { @@ -41,6 +42,8 @@ async function loadSavedConnections() { if (!conn.terminalBuffer) conn.terminalBuffer = []; }); renderConnectionList(); + refreshHostStatuses(); + startHostStatusPolling(); if (connections.length > 0) { term.writeln(`[Loaded ${connections.length} connections from server]`); } @@ -454,6 +457,9 @@ function renderConnectionList() { ${conn.username}@${conn.host}:${conn.port} +
+ Host: ${conn.hostStatus === true ? 'Up' : conn.hostStatus === false ? 'Down' : 'Checking...'} +
${conn.connected ? `` @@ -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) { currentConnectionId = id; const conn = getCurrentConnection(); diff --git a/src/webssh_backend/http_api.py b/src/webssh_backend/http_api.py index db43efe..ef17961 100644 --- a/src/webssh_backend/http_api.py +++ b/src/webssh_backend/http_api.py @@ -5,7 +5,9 @@ import hashlib import io import json import os +import socket import stat +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler from typing import Optional @@ -44,6 +46,8 @@ class LoginHandler(BaseHTTPRequestHandler): self.handle_verify() elif parsed.path == '/api/connections': self.handle_save_connection() + elif parsed.path == '/api/connections/status': + self.handle_connection_status() elif parsed.path == '/api/config': self.handle_config() elif parsed.path == '/api/sftp/list': @@ -197,6 +201,44 @@ class LoginHandler(BaseHTTPRequestHandler): logger.error(f"Get connections error: {e}") 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): """Get configuration including max upload size.""" username = self._get_username_from_token()