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()