From d625c3903d22b58441c742aec3e0792f0c57ec99 Mon Sep 17 00:00:00 2001 From: Jamie Banks Date: Thu, 9 Jul 2026 20:57:52 +0100 Subject: [PATCH] feat: add host ping endpoint and automatic reconnection UI for disconnected terminals --- app.py | 21 +++++ frontend/src/api.ts | 5 ++ frontend/src/components/TabContent.vue | 115 ++++++++++++++++++------- 3 files changed, 111 insertions(+), 30 deletions(-) diff --git a/app.py b/app.py index 85eeb1c..de5bc55 100644 --- a/app.py +++ b/app.py @@ -4,6 +4,7 @@ SSH web client — Flask backend: auth, MariaDB, encrypted identities, WebSocket from __future__ import annotations import os +import socket if os.getenv("GEVENT_MONKEY_PATCH", "").lower() in ("1", "true", "yes"): from gevent import monkey @@ -1411,6 +1412,26 @@ def list_hosts(): return jsonify({"items": rows}) +@app.route("/api/hosts//ping", methods=["GET"]) +@require_auth("read:hosts") +def ping_host(hid: int): + with db_cursor() as (_, cur): + cur.execute("SELECT hostname, port FROM ssh_hosts WHERE id = %s", (hid,)) + row = cur.fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + + up = False + try: + with socket.create_connection((row["hostname"], row["port"]), timeout=2.0): + up = True + except Exception: + pass + + return jsonify({"up": up}) + + + @app.route("/api/tags", methods=["GET"]) @require_auth("read:hosts") def list_tags(): diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1642243..047a116 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -65,6 +65,11 @@ export const api = { return d.items; }, + async pingHost(id: number): Promise<{ up: boolean }> { + const res = await fetch(`/api/hosts/${id}/ping`, { credentials: "include" }); + return handle<{ up: boolean }>(res); + }, + async listTags(): Promise { const res = await fetch("/api/tags", { credentials: "include" }); const d = await handle<{ items: string[] }>(res); diff --git a/frontend/src/components/TabContent.vue b/frontend/src/components/TabContent.vue index a0b2b00..60b187a 100644 --- a/frontend/src/components/TabContent.vue +++ b/frontend/src/components/TabContent.vue @@ -6,6 +6,7 @@ import { watch, nextTick, } from "vue"; +import { api } from "@/api"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; @@ -32,12 +33,14 @@ defineExpose({ const termEl = ref(null); const status = ref("Connecting…"); const connId = ref(null); +const serverBackOnline = ref(false); let ws: WebSocket | null = null; let term: Terminal | null = null; let fit: FitAddon | null = null; let ro: ResizeObserver | null = null; let visibilityHandler: (() => void) | null = null; +let pingInterval: number | null = null; function wsUrl(hostId: number): string { const proto = location.protocol === "https:" ? "wss:" : "ws:"; @@ -85,6 +88,71 @@ function isControlMessage(raw: string): boolean { return false; } +function connectWs() { + serverBackOnline.value = false; + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } + if (ws) { + ws.close(); + } + + ws = new WebSocket(wsUrl(props.hostId)); + ws.binaryType = "arraybuffer"; + + ws.onopen = () => { + status.value = "Handshaking…"; + sendResize(); + }; + + ws.onmessage = (ev) => { + if (!term) return; + if (typeof ev.data === "string") { + if (isControlMessage(ev.data)) return; + term.write(ev.data); + return; + } + const u8 = new Uint8Array(ev.data as ArrayBuffer); + term.write(new TextDecoder().decode(u8)); + }; + + ws.onerror = () => { + status.value = "WebSocket error"; + }; + + ws.onclose = () => { + if (!connId.value) { + status.value = "Disconnected"; + } else { + status.value = "Session ended"; + } + + pingInterval = window.setInterval(async () => { + try { + const { up } = await api.pingHost(props.hostId); + if (up) { + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } + serverBackOnline.value = true; + } + } catch (err) { + // ignore + } + }, 5000); + }; +} + +function reconnect() { + term?.clear(); + term?.focus(); + connId.value = null; + status.value = "Connecting…"; + connectWs(); +} + onMounted(async () => { await nextTick(); if (!termEl.value) return; @@ -121,36 +189,7 @@ onMounted(async () => { ro = new ResizeObserver(() => fitAndResize()); ro.observe(termEl.value); - ws = new WebSocket(wsUrl(props.hostId)); - ws.binaryType = "arraybuffer"; - - ws.onopen = () => { - status.value = "Handshaking…"; - sendResize(); - }; - - ws.onmessage = (ev) => { - if (!term) return; - if (typeof ev.data === "string") { - if (isControlMessage(ev.data)) return; - term.write(ev.data); - return; - } - const u8 = new Uint8Array(ev.data as ArrayBuffer); - term.write(new TextDecoder().decode(u8)); - }; - - ws.onerror = () => { - status.value = "WebSocket error"; - }; - - ws.onclose = () => { - if (!connId.value) { - status.value = "Disconnected"; - } else { - status.value = "Session ended"; - } - }; + connectWs(); visibilityHandler = () => { if (document.visibilityState === "visible") { @@ -162,6 +201,10 @@ onMounted(async () => { }); onUnmounted(() => { + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } if (visibilityHandler) { document.removeEventListener("visibilitychange", visibilityHandler); visibilityHandler = null; @@ -197,6 +240,18 @@ watch( > {{ status }} +
+ Server is back online + +