Files
mo a4c9b60079 feat(ui): Data Sources UI — enterprise browser for all 5 source databases
New "Data Sources UI" tab with per-database Browser (catalog + sample data),
Query Console (SqlWorkbench) and embedded interactive Shell (DbShell via SSH).

Backend:
- Extend sql_console.py with Cassandra (CQL) + Neo4j (Cypher) engines
- Add GET /api/sql/catalog/{engine} and GET /api/sql/sample/{engine}
- ssh_terminal: optional initial_command for auto-launching DB CLIs

Frontend:
- DataSourcesView with 5-DB rail, health dots, Browser/Console/Shell sub-tabs
- DbShell embedded xterm terminal with docker exec CLI per engine
- Deep-link topology DB nodes to Data Sources UI (no SQL dock on platform)
- WorkbenchPanel restricted to agent mode only — frees dashboard space
2026-06-27 15:42:37 +00:00

154 lines
5.2 KiB
Python

"""Interactive SSH terminal bridge over WebSocket (paramiko PTY <-> xterm.js)."""
from __future__ import annotations
import asyncio
import json
from typing import Any
import paramiko
from fastapi import WebSocket, WebSocketDisconnect
async def _send(ws: WebSocket, type_: str, **kw: Any) -> None:
try:
await ws.send_text(json.dumps({"type": type_, **kw}))
except Exception:
pass
def _open_ssh(cfg: dict[str, Any]) -> tuple[paramiko.SSHClient, paramiko.Channel]:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs: dict[str, Any] = {
"hostname": cfg["host"],
"port": int(cfg.get("port") or 22),
"username": cfg.get("username") or "root",
"timeout": 12,
"banner_timeout": 12,
"auth_timeout": 12,
"look_for_keys": False,
"allow_agent": False,
}
password = cfg.get("password")
key_data = cfg.get("private_key")
if key_data:
from io import StringIO
pkey = None
for loader in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey):
try:
pkey = loader.from_private_key(StringIO(key_data), password=password or None)
break
except Exception:
continue
if pkey is None:
raise ValueError("Unsupported or invalid private key")
connect_kwargs["pkey"] = pkey
elif password:
connect_kwargs["password"] = password
else:
raise ValueError("No password or private key provided")
client.connect(**connect_kwargs)
cols = int(cfg.get("cols") or 120)
rows = int(cfg.get("rows") or 32)
chan = client.invoke_shell(term="xterm-256color", width=cols, height=rows)
chan.settimeout(0.0)
return client, chan
async def ssh_session(ws: WebSocket) -> None:
await ws.accept()
client: paramiko.SSHClient | None = None
chan: paramiko.Channel | None = None
loop = asyncio.get_event_loop()
try:
# First message must be the connect config
first = await ws.receive_text()
cfg = json.loads(first)
if cfg.get("type") != "connect":
await _send(ws, "error", message="Expected connect message")
await ws.close()
return
await _send(ws, "status", message=f"Connecting to {cfg.get('username','root')}@{cfg.get('host')}:{cfg.get('port',22)}…")
try:
client, chan = await loop.run_in_executor(None, _open_ssh, cfg)
except paramiko.AuthenticationException:
await _send(ws, "error", message="Authentication failed — check username/password")
await ws.close()
return
except Exception as exc:
await _send(ws, "error", message=f"Connection failed: {exc}")
await ws.close()
return
await _send(ws, "connected", message="connected")
initial_cmd = cfg.get("initial_command")
if initial_cmd and chan and not chan.closed:
try:
chan.send(initial_cmd if initial_cmd.endswith("\n") else initial_cmd + "\n")
except Exception:
pass
async def pump_out() -> None:
assert chan is not None
while True:
if chan.closed or chan.exit_status_ready() and not chan.recv_ready():
if not chan.recv_ready():
break
if chan.recv_ready():
try:
data = await loop.run_in_executor(None, chan.recv, 65536)
except Exception:
break
if not data:
break
await ws.send_text(json.dumps({"type": "data", "data": data.decode("utf-8", errors="replace")}))
else:
await asyncio.sleep(0.02)
await _send(ws, "closed", message="Session closed")
out_task = asyncio.create_task(pump_out())
try:
while True:
msg = await ws.receive_text()
try:
parsed = json.loads(msg)
except json.JSONDecodeError:
parsed = {"type": "data", "data": msg}
mtype = parsed.get("type")
if mtype == "data" and chan and not chan.closed:
chan.send(parsed.get("data", ""))
elif mtype == "resize" and chan and not chan.closed:
try:
chan.resize_pty(width=int(parsed.get("cols", 120)), height=int(parsed.get("rows", 32)))
except Exception:
pass
elif mtype == "disconnect":
break
finally:
out_task.cancel()
except WebSocketDisconnect:
pass
except Exception as exc:
await _send(ws, "error", message=str(exc))
finally:
try:
if chan is not None:
chan.close()
except Exception:
pass
try:
if client is not None:
client.close()
except Exception:
pass
try:
await ws.close()
except Exception:
pass