Upload 10 files
Browse files- app.py +41 -1
- proxy.py +236 -0
- requirements.txt +1 -0
- static/app.js +121 -1
- static/index.html +44 -0
- static/style.css +115 -0
- terminal.py +75 -32
app.py
CHANGED
|
@@ -5,11 +5,12 @@ from pathlib import Path
|
|
| 5 |
from contextlib import asynccontextmanager
|
| 6 |
|
| 7 |
import uvicorn
|
| 8 |
-
from fastapi import FastAPI, WebSocket, UploadFile, File, Form, HTTPException, Query
|
| 9 |
from fastapi.staticfiles import StaticFiles
|
| 10 |
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
| 11 |
|
| 12 |
import zones
|
|
|
|
| 13 |
from terminal import terminal_ws, kill_terminal, active_terminals
|
| 14 |
|
| 15 |
|
|
@@ -130,6 +131,45 @@ def api_rename_file(zone_name: str, old_path: str = Form(...), new_name: str = F
|
|
| 130 |
raise HTTPException(400, str(e))
|
| 131 |
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
# ── Terminal WebSocket ────────────────────────────────────
|
| 134 |
|
| 135 |
@app.websocket("/ws/terminal/{zone_name}")
|
|
|
|
| 5 |
from contextlib import asynccontextmanager
|
| 6 |
|
| 7 |
import uvicorn
|
| 8 |
+
from fastapi import FastAPI, Request, WebSocket, UploadFile, File, Form, HTTPException, Query
|
| 9 |
from fastapi.staticfiles import StaticFiles
|
| 10 |
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
| 11 |
|
| 12 |
import zones
|
| 13 |
+
import proxy
|
| 14 |
from terminal import terminal_ws, kill_terminal, active_terminals
|
| 15 |
|
| 16 |
|
|
|
|
| 131 |
raise HTTPException(400, str(e))
|
| 132 |
|
| 133 |
|
| 134 |
+
# ── Port Management API ───────────────────────────────────
|
| 135 |
+
|
| 136 |
+
@app.get("/api/zones/{zone_name}/ports")
|
| 137 |
+
def api_list_ports(zone_name: str):
|
| 138 |
+
try:
|
| 139 |
+
return proxy.list_ports(zone_name)
|
| 140 |
+
except ValueError as e:
|
| 141 |
+
raise HTTPException(400, str(e))
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@app.post("/api/zones/{zone_name}/ports")
|
| 145 |
+
def api_add_port(zone_name: str, port: int = Form(...), label: str = Form("")):
|
| 146 |
+
try:
|
| 147 |
+
return proxy.add_port(zone_name, port, label)
|
| 148 |
+
except ValueError as e:
|
| 149 |
+
raise HTTPException(400, str(e))
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@app.delete("/api/zones/{zone_name}/ports/{port}")
|
| 153 |
+
def api_remove_port(zone_name: str, port: int):
|
| 154 |
+
try:
|
| 155 |
+
proxy.remove_port(zone_name, port)
|
| 156 |
+
return {"ok": True}
|
| 157 |
+
except ValueError as e:
|
| 158 |
+
raise HTTPException(400, str(e))
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ── Reverse Proxy ────────────────────────────────────────
|
| 162 |
+
|
| 163 |
+
@app.api_route("/port/{zone_name}/{port}/{subpath:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
| 164 |
+
async def proxy_route(request: Request, zone_name: str, port: int, subpath: str = ""):
|
| 165 |
+
return await proxy.proxy_http(request, zone_name, port, subpath)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@app.websocket("/port/{zone_name}/{port}/ws/{subpath:path}")
|
| 169 |
+
async def proxy_ws_route(websocket: WebSocket, zone_name: str, port: int, subpath: str = ""):
|
| 170 |
+
await proxy.proxy_ws(websocket, zone_name, port, f"ws/{subpath}")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
# ── Terminal WebSocket ────────────────────────────────────
|
| 174 |
|
| 175 |
@app.websocket("/ws/terminal/{zone_name}")
|
proxy.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reverse proxy for virtual ports.
|
| 3 |
+
|
| 4 |
+
Each zone can run web servers on internal ports (e.g. 3000, 8080).
|
| 5 |
+
Since HF Spaces only exposes port 7860, this module proxies
|
| 6 |
+
/port/{zone}/{port}/... → localhost:{port} with proper path rewriting.
|
| 7 |
+
|
| 8 |
+
Port mappings are stored in zones_meta.json under each zone's "ports" key.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
from fastapi import Request, WebSocket, WebSocketDisconnect
|
| 17 |
+
from fastapi.responses import Response
|
| 18 |
+
|
| 19 |
+
# Port range allowed for proxying (unprivileged ports only)
|
| 20 |
+
MIN_PORT = 1024
|
| 21 |
+
MAX_PORT = 65535
|
| 22 |
+
|
| 23 |
+
# Reuse a single async HTTP client
|
| 24 |
+
_client: httpx.AsyncClient | None = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _get_client() -> httpx.AsyncClient:
|
| 28 |
+
global _client
|
| 29 |
+
if _client is None:
|
| 30 |
+
_client = httpx.AsyncClient(
|
| 31 |
+
timeout=httpx.Timeout(30.0, connect=5.0),
|
| 32 |
+
follow_redirects=False,
|
| 33 |
+
limits=httpx.Limits(max_connections=50),
|
| 34 |
+
)
|
| 35 |
+
return _client
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _meta_path() -> Path:
|
| 39 |
+
from zones import ZONES_META
|
| 40 |
+
return ZONES_META
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _load_meta() -> dict:
|
| 44 |
+
p = _meta_path()
|
| 45 |
+
if p.exists():
|
| 46 |
+
return json.loads(p.read_text(encoding="utf-8"))
|
| 47 |
+
return {}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _save_meta(meta: dict):
|
| 51 |
+
_meta_path().write_text(json.dumps(meta, indent=2, default=str), encoding="utf-8")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _validate_port(port: int):
|
| 55 |
+
if not (MIN_PORT <= port <= MAX_PORT):
|
| 56 |
+
raise ValueError(f"Port must be between {MIN_PORT} and {MAX_PORT}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _validate_zone(meta: dict, zone_name: str):
|
| 60 |
+
if zone_name not in meta:
|
| 61 |
+
raise ValueError(f"Zone '{zone_name}' does not exist")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ── Port CRUD ─────────────────────────────────
|
| 65 |
+
|
| 66 |
+
def list_ports(zone_name: str) -> list[dict]:
|
| 67 |
+
"""List all port mappings for a zone."""
|
| 68 |
+
meta = _load_meta()
|
| 69 |
+
_validate_zone(meta, zone_name)
|
| 70 |
+
ports = meta[zone_name].get("ports", [])
|
| 71 |
+
return ports
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def add_port(zone_name: str, port: int, label: str = "") -> dict:
|
| 75 |
+
"""Add a port mapping to a zone."""
|
| 76 |
+
_validate_port(port)
|
| 77 |
+
meta = _load_meta()
|
| 78 |
+
_validate_zone(meta, zone_name)
|
| 79 |
+
|
| 80 |
+
ports = meta[zone_name].setdefault("ports", [])
|
| 81 |
+
|
| 82 |
+
# Check duplicate
|
| 83 |
+
for p in ports:
|
| 84 |
+
if p["port"] == port:
|
| 85 |
+
raise ValueError(f"Port {port} already mapped in zone '{zone_name}'")
|
| 86 |
+
|
| 87 |
+
entry = {"port": port, "label": label or f"Port {port}"}
|
| 88 |
+
ports.append(entry)
|
| 89 |
+
_save_meta(meta)
|
| 90 |
+
return entry
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def remove_port(zone_name: str, port: int):
|
| 94 |
+
"""Remove a port mapping from a zone."""
|
| 95 |
+
meta = _load_meta()
|
| 96 |
+
_validate_zone(meta, zone_name)
|
| 97 |
+
|
| 98 |
+
ports = meta[zone_name].get("ports", [])
|
| 99 |
+
before = len(ports)
|
| 100 |
+
meta[zone_name]["ports"] = [p for p in ports if p["port"] != port]
|
| 101 |
+
if len(meta[zone_name]["ports"]) == before:
|
| 102 |
+
raise ValueError(f"Port {port} not found in zone '{zone_name}'")
|
| 103 |
+
_save_meta(meta)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ── HTTP Reverse Proxy ────────────────────────
|
| 107 |
+
|
| 108 |
+
# Headers that should not be forwarded
|
| 109 |
+
_HOP_HEADERS = frozenset({
|
| 110 |
+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
| 111 |
+
"te", "trailers", "transfer-encoding", "upgrade",
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def proxy_http(request: Request, zone_name: str, port: int, subpath: str = "") -> Response:
|
| 116 |
+
"""Proxy an HTTP request to localhost:{port}."""
|
| 117 |
+
_validate_port(port)
|
| 118 |
+
meta = _load_meta()
|
| 119 |
+
_validate_zone(meta, zone_name)
|
| 120 |
+
|
| 121 |
+
# Verify port is registered for this zone
|
| 122 |
+
ports = meta[zone_name].get("ports", [])
|
| 123 |
+
if not any(p["port"] == port for p in ports):
|
| 124 |
+
return Response(content="Port not mapped", status_code=404)
|
| 125 |
+
|
| 126 |
+
target_url = f"http://127.0.0.1:{port}/{subpath}"
|
| 127 |
+
if request.url.query:
|
| 128 |
+
target_url += f"?{request.url.query}"
|
| 129 |
+
|
| 130 |
+
# Build headers, filtering out hop-by-hop
|
| 131 |
+
headers = {}
|
| 132 |
+
for key, value in request.headers.items():
|
| 133 |
+
if key.lower() not in _HOP_HEADERS and key.lower() != "host":
|
| 134 |
+
headers[key] = value
|
| 135 |
+
headers["host"] = f"127.0.0.1:{port}"
|
| 136 |
+
headers["x-forwarded-for"] = request.client.host if request.client else "127.0.0.1"
|
| 137 |
+
headers["x-forwarded-proto"] = request.url.scheme
|
| 138 |
+
# Tell the target app its real base path
|
| 139 |
+
headers["x-forwarded-prefix"] = f"/port/{zone_name}/{port}"
|
| 140 |
+
|
| 141 |
+
body = await request.body()
|
| 142 |
+
client = _get_client()
|
| 143 |
+
|
| 144 |
+
try:
|
| 145 |
+
resp = await client.request(
|
| 146 |
+
method=request.method,
|
| 147 |
+
url=target_url,
|
| 148 |
+
headers=headers,
|
| 149 |
+
content=body,
|
| 150 |
+
)
|
| 151 |
+
except httpx.ConnectError:
|
| 152 |
+
return Response(
|
| 153 |
+
content=f"Cannot connect to port {port}. Make sure your server is running.",
|
| 154 |
+
status_code=502,
|
| 155 |
+
media_type="text/plain",
|
| 156 |
+
)
|
| 157 |
+
except httpx.TimeoutException:
|
| 158 |
+
return Response(content=f"Timeout connecting to port {port}", status_code=504, media_type="text/plain")
|
| 159 |
+
|
| 160 |
+
# Build response headers
|
| 161 |
+
resp_headers = {}
|
| 162 |
+
for key, value in resp.headers.items():
|
| 163 |
+
if key.lower() not in _HOP_HEADERS and key.lower() != "content-encoding":
|
| 164 |
+
resp_headers[key] = value
|
| 165 |
+
|
| 166 |
+
return Response(
|
| 167 |
+
content=resp.content,
|
| 168 |
+
status_code=resp.status_code,
|
| 169 |
+
headers=resp_headers,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ── WebSocket Reverse Proxy ──────────────────
|
| 174 |
+
|
| 175 |
+
async def proxy_ws(websocket: WebSocket, zone_name: str, port: int, subpath: str = ""):
|
| 176 |
+
"""Proxy a WebSocket connection to localhost:{port}."""
|
| 177 |
+
_validate_port(port)
|
| 178 |
+
meta = _load_meta()
|
| 179 |
+
_validate_zone(meta, zone_name)
|
| 180 |
+
|
| 181 |
+
ports = meta[zone_name].get("ports", [])
|
| 182 |
+
if not any(p["port"] == port for p in ports):
|
| 183 |
+
await websocket.close(code=4004, reason="Port not mapped")
|
| 184 |
+
return
|
| 185 |
+
|
| 186 |
+
await websocket.accept()
|
| 187 |
+
|
| 188 |
+
target_url = f"ws://127.0.0.1:{port}/{subpath}"
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
async with httpx.AsyncClient() as client:
|
| 192 |
+
async with client.stream("GET", target_url.replace("ws://", "http://")) as _:
|
| 193 |
+
pass
|
| 194 |
+
except Exception:
|
| 195 |
+
pass
|
| 196 |
+
|
| 197 |
+
# Use raw websockets for the backend connection
|
| 198 |
+
import asyncio
|
| 199 |
+
import websockets
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
async with websockets.connect(target_url) as backend_ws:
|
| 203 |
+
async def client_to_backend():
|
| 204 |
+
try:
|
| 205 |
+
while True:
|
| 206 |
+
msg = await websocket.receive()
|
| 207 |
+
if msg.get("type") == "websocket.disconnect":
|
| 208 |
+
break
|
| 209 |
+
if "text" in msg:
|
| 210 |
+
await backend_ws.send(msg["text"])
|
| 211 |
+
elif "bytes" in msg:
|
| 212 |
+
await backend_ws.send(msg["bytes"])
|
| 213 |
+
except (WebSocketDisconnect, Exception):
|
| 214 |
+
pass
|
| 215 |
+
|
| 216 |
+
async def backend_to_client():
|
| 217 |
+
try:
|
| 218 |
+
async for message in backend_ws:
|
| 219 |
+
if isinstance(message, str):
|
| 220 |
+
await websocket.send_text(message)
|
| 221 |
+
else:
|
| 222 |
+
await websocket.send_bytes(message)
|
| 223 |
+
except (WebSocketDisconnect, Exception):
|
| 224 |
+
pass
|
| 225 |
+
|
| 226 |
+
await asyncio.gather(client_to_backend(), backend_to_client())
|
| 227 |
+
except Exception:
|
| 228 |
+
try:
|
| 229 |
+
await websocket.send_text(json.dumps({"error": f"Cannot connect WebSocket to port {port}"}))
|
| 230 |
+
except Exception:
|
| 231 |
+
pass
|
| 232 |
+
finally:
|
| 233 |
+
try:
|
| 234 |
+
await websocket.close()
|
| 235 |
+
except Exception:
|
| 236 |
+
pass
|
requirements.txt
CHANGED
|
@@ -3,3 +3,4 @@ uvicorn[standard]==0.34.0
|
|
| 3 |
websockets==14.1
|
| 4 |
python-multipart==0.0.18
|
| 5 |
aiofiles==24.1.0
|
|
|
|
|
|
| 3 |
websockets==14.1
|
| 4 |
python-multipart==0.0.18
|
| 5 |
aiofiles==24.1.0
|
| 6 |
+
httpx==0.28.1
|
static/app.js
CHANGED
|
@@ -12,6 +12,7 @@ let termSocket = null;
|
|
| 12 |
let fitAddon = null;
|
| 13 |
let termDataDisposable = null;
|
| 14 |
let termResizeDisposable = null;
|
|
|
|
| 15 |
let promptResolve = null;
|
| 16 |
|
| 17 |
// ── Init ──────────────────────────────────────
|
|
@@ -87,6 +88,7 @@ async function openZone(name) {
|
|
| 87 |
});
|
| 88 |
|
| 89 |
await loadFiles();
|
|
|
|
| 90 |
|
| 91 |
// Auto-connect terminal
|
| 92 |
setTimeout(() => connectTerminal(), 200);
|
|
@@ -418,7 +420,12 @@ function connectTerminal() {
|
|
| 418 |
if (termDataDisposable) { termDataDisposable.dispose(); termDataDisposable = null; }
|
| 419 |
if (termResizeDisposable) { termResizeDisposable.dispose(); termResizeDisposable = null; }
|
| 420 |
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
|
| 423 |
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
| 424 |
termSocket = new WebSocket(`${proto}//${location.host}/ws/terminal/${currentZone}`);
|
|
@@ -539,6 +546,105 @@ function promptUser(title, defaultValue) {
|
|
| 539 |
});
|
| 540 |
}
|
| 541 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
// ── Utility ──────────────────────────────────
|
| 543 |
function escapeHtml(str) {
|
| 544 |
const div = document.createElement("div");
|
|
@@ -586,6 +692,20 @@ function bindEvents() {
|
|
| 586 |
document.getElementById("btn-save-file").addEventListener("click", saveFile);
|
| 587 |
document.getElementById("btn-reconnect").addEventListener("click", reconnectTerminal);
|
| 588 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
document.getElementById("btn-cancel-prompt").addEventListener("click", () => {
|
| 590 |
closeModal("modal-prompt");
|
| 591 |
if (promptResolve) { promptResolve(null); promptResolve = null; }
|
|
|
|
| 12 |
let fitAddon = null;
|
| 13 |
let termDataDisposable = null;
|
| 14 |
let termResizeDisposable = null;
|
| 15 |
+
let termCurrentZone = null; // tracks which zone the terminal is connected to
|
| 16 |
let promptResolve = null;
|
| 17 |
|
| 18 |
// ── Init ──────────────────────────────────────
|
|
|
|
| 88 |
});
|
| 89 |
|
| 90 |
await loadFiles();
|
| 91 |
+
loadPorts();
|
| 92 |
|
| 93 |
// Auto-connect terminal
|
| 94 |
setTimeout(() => connectTerminal(), 200);
|
|
|
|
| 420 |
if (termDataDisposable) { termDataDisposable.dispose(); termDataDisposable = null; }
|
| 421 |
if (termResizeDisposable) { termResizeDisposable.dispose(); termResizeDisposable = null; }
|
| 422 |
|
| 423 |
+
// Only clear the terminal when switching to a different zone
|
| 424 |
+
const switchingZone = termCurrentZone !== currentZone;
|
| 425 |
+
if (switchingZone) {
|
| 426 |
+
term.reset();
|
| 427 |
+
termCurrentZone = currentZone;
|
| 428 |
+
}
|
| 429 |
|
| 430 |
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
| 431 |
termSocket = new WebSocket(`${proto}//${location.host}/ws/terminal/${currentZone}`);
|
|
|
|
| 546 |
});
|
| 547 |
}
|
| 548 |
|
| 549 |
+
// ── Port Management ──────────────────────────
|
| 550 |
+
async function loadPorts() {
|
| 551 |
+
if (!currentZone) return;
|
| 552 |
+
try {
|
| 553 |
+
const ports = await api(`/api/zones/${currentZone}/ports`);
|
| 554 |
+
renderPorts(ports);
|
| 555 |
+
const badge = document.getElementById("port-count");
|
| 556 |
+
if (ports.length > 0) {
|
| 557 |
+
badge.textContent = ports.length;
|
| 558 |
+
badge.style.display = "inline";
|
| 559 |
+
} else {
|
| 560 |
+
badge.style.display = "none";
|
| 561 |
+
}
|
| 562 |
+
} catch (e) {
|
| 563 |
+
// Zone might not exist yet
|
| 564 |
+
}
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
function renderPorts(ports) {
|
| 568 |
+
const list = document.getElementById("port-list");
|
| 569 |
+
if (ports.length === 0) {
|
| 570 |
+
list.innerHTML = `<div class="port-empty">No ports mapped yet</div>`;
|
| 571 |
+
return;
|
| 572 |
+
}
|
| 573 |
+
list.innerHTML = ports.map(p => `
|
| 574 |
+
<div class="port-item">
|
| 575 |
+
<span class="pi-port">:${p.port}</span>
|
| 576 |
+
<span class="pi-label">${escapeHtml(p.label)}</span>
|
| 577 |
+
<span class="pi-actions">
|
| 578 |
+
<button title="Open in new tab" onclick="openPort(${p.port})"><i data-lucide="external-link"></i></button>
|
| 579 |
+
<button title="Copy URL" onclick="copyPortUrl(${p.port})"><i data-lucide="copy"></i></button>
|
| 580 |
+
<button class="pi-del" title="Remove" onclick="removePort(${p.port})"><i data-lucide="trash-2"></i></button>
|
| 581 |
+
</span>
|
| 582 |
+
</div>
|
| 583 |
+
`).join("");
|
| 584 |
+
lucide.createIcons({ nodes: [list] });
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
function togglePortPanel() {
|
| 588 |
+
const panel = document.getElementById("port-panel");
|
| 589 |
+
if (panel.classList.contains("hidden")) {
|
| 590 |
+
// Position panel below the button
|
| 591 |
+
const btn = document.getElementById("btn-toggle-ports");
|
| 592 |
+
const rect = btn.getBoundingClientRect();
|
| 593 |
+
panel.style.top = (rect.bottom + 6) + "px";
|
| 594 |
+
panel.classList.remove("hidden");
|
| 595 |
+
lucide.createIcons({ nodes: [panel] });
|
| 596 |
+
loadPorts();
|
| 597 |
+
} else {
|
| 598 |
+
panel.classList.add("hidden");
|
| 599 |
+
}
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
async function addPort() {
|
| 603 |
+
const portInput = document.getElementById("input-port-number");
|
| 604 |
+
const labelInput = document.getElementById("input-port-label");
|
| 605 |
+
const port = parseInt(portInput.value);
|
| 606 |
+
const label = labelInput.value.trim();
|
| 607 |
+
if (!port) return;
|
| 608 |
+
|
| 609 |
+
const form = new FormData();
|
| 610 |
+
form.append("port", port);
|
| 611 |
+
form.append("label", label);
|
| 612 |
+
try {
|
| 613 |
+
await api(`/api/zones/${currentZone}/ports`, { method: "POST", body: form });
|
| 614 |
+
closeModal("modal-add-port");
|
| 615 |
+
portInput.value = "";
|
| 616 |
+
labelInput.value = "";
|
| 617 |
+
toast(`Port ${port} mapped`, "success");
|
| 618 |
+
await loadPorts();
|
| 619 |
+
} catch (e) {
|
| 620 |
+
toast(e.message, "error");
|
| 621 |
+
}
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
async function removePort(port) {
|
| 625 |
+
if (!confirm(`Remove port ${port} mapping?`)) return;
|
| 626 |
+
try {
|
| 627 |
+
await api(`/api/zones/${currentZone}/ports/${port}`, { method: "DELETE" });
|
| 628 |
+
toast(`Port ${port} removed`, "info");
|
| 629 |
+
await loadPorts();
|
| 630 |
+
} catch (e) {
|
| 631 |
+
toast(e.message, "error");
|
| 632 |
+
}
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
function openPort(port) {
|
| 636 |
+
window.open(`/port/${currentZone}/${port}/`, "_blank");
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
function copyPortUrl(port) {
|
| 640 |
+
const url = `${location.origin}/port/${currentZone}/${port}/`;
|
| 641 |
+
navigator.clipboard.writeText(url).then(() => {
|
| 642 |
+
toast("URL copied", "success");
|
| 643 |
+
}).catch(() => {
|
| 644 |
+
toast(url, "info");
|
| 645 |
+
});
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
// ── Utility ──────────────────────────────────
|
| 649 |
function escapeHtml(str) {
|
| 650 |
const div = document.createElement("div");
|
|
|
|
| 692 |
document.getElementById("btn-save-file").addEventListener("click", saveFile);
|
| 693 |
document.getElementById("btn-reconnect").addEventListener("click", reconnectTerminal);
|
| 694 |
|
| 695 |
+
// Port management
|
| 696 |
+
document.getElementById("btn-toggle-ports").addEventListener("click", togglePortPanel);
|
| 697 |
+
document.getElementById("btn-add-port").addEventListener("click", () => showModal("modal-add-port"));
|
| 698 |
+
document.getElementById("form-add-port").addEventListener("submit", (e) => { e.preventDefault(); addPort(); });
|
| 699 |
+
|
| 700 |
+
// Close port panel when clicking outside
|
| 701 |
+
document.addEventListener("click", (e) => {
|
| 702 |
+
const panel = document.getElementById("port-panel");
|
| 703 |
+
const toggle = document.getElementById("btn-toggle-ports");
|
| 704 |
+
if (!panel.classList.contains("hidden") && !panel.contains(e.target) && !toggle.contains(e.target)) {
|
| 705 |
+
panel.classList.add("hidden");
|
| 706 |
+
}
|
| 707 |
+
});
|
| 708 |
+
|
| 709 |
document.getElementById("btn-cancel-prompt").addEventListener("click", () => {
|
| 710 |
closeModal("modal-prompt");
|
| 711 |
if (promptResolve) { promptResolve(null); promptResolve = null; }
|
static/index.html
CHANGED
|
@@ -89,6 +89,13 @@
|
|
| 89 |
<div class="breadcrumb" id="breadcrumb"></div>
|
| 90 |
</div>
|
| 91 |
<div class="topbar-right">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
<button id="btn-delete-zone" class="icon-btn icon-btn-danger" title="Delete zone">
|
| 93 |
<i data-lucide="trash-2"></i>
|
| 94 |
</button>
|
|
@@ -195,6 +202,43 @@
|
|
| 195 |
</div>
|
| 196 |
</div>
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
<script src="/static/app.js"></script>
|
| 199 |
</body>
|
| 200 |
</html>
|
|
|
|
| 89 |
<div class="breadcrumb" id="breadcrumb"></div>
|
| 90 |
</div>
|
| 91 |
<div class="topbar-right">
|
| 92 |
+
<div class="port-controls" id="port-controls">
|
| 93 |
+
<button class="btn btn-sm btn-ghost" id="btn-toggle-ports" title="Virtual Ports">
|
| 94 |
+
<i data-lucide="network"></i>
|
| 95 |
+
<span>Ports</span>
|
| 96 |
+
<span class="port-count" id="port-count" style="display:none">0</span>
|
| 97 |
+
</button>
|
| 98 |
+
</div>
|
| 99 |
<button id="btn-delete-zone" class="icon-btn icon-btn-danger" title="Delete zone">
|
| 100 |
<i data-lucide="trash-2"></i>
|
| 101 |
</button>
|
|
|
|
| 202 |
</div>
|
| 203 |
</div>
|
| 204 |
|
| 205 |
+
<!-- Port Panel (dropdown) -->
|
| 206 |
+
<div id="port-panel" class="port-panel hidden">
|
| 207 |
+
<div class="port-panel-header">
|
| 208 |
+
<span><i data-lucide="network"></i> Virtual Ports</span>
|
| 209 |
+
<button class="icon-btn-sm" id="btn-add-port" title="Add port"><i data-lucide="plus"></i></button>
|
| 210 |
+
</div>
|
| 211 |
+
<div class="port-panel-hint">
|
| 212 |
+
Run a server in terminal, map its port here, then click Open to view it through HugPanel's single exposed port.
|
| 213 |
+
</div>
|
| 214 |
+
<div id="port-list" class="port-list"></div>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
<!-- Modal: Add Port -->
|
| 218 |
+
<div id="modal-add-port" class="modal-overlay hidden">
|
| 219 |
+
<div class="modal modal-sm">
|
| 220 |
+
<div class="modal-header">
|
| 221 |
+
<h3><i data-lucide="network"></i> Map Port</h3>
|
| 222 |
+
<button class="icon-btn-sm" onclick="closeModal('modal-add-port')"><i data-lucide="x"></i></button>
|
| 223 |
+
</div>
|
| 224 |
+
<form id="form-add-port">
|
| 225 |
+
<div class="form-group">
|
| 226 |
+
<label for="input-port-number">Port number</label>
|
| 227 |
+
<input type="number" id="input-port-number" min="1024" max="65535" placeholder="3000" required>
|
| 228 |
+
<span class="form-hint">The internal port your server listens on (1024–65535)</span>
|
| 229 |
+
</div>
|
| 230 |
+
<div class="form-group">
|
| 231 |
+
<label for="input-port-label">Label <span class="optional">(optional)</span></label>
|
| 232 |
+
<input type="text" id="input-port-label" placeholder="e.g. React Dev Server">
|
| 233 |
+
</div>
|
| 234 |
+
<div class="modal-footer">
|
| 235 |
+
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-add-port')">Cancel</button>
|
| 236 |
+
<button type="submit" class="btn btn-accent"><i data-lucide="plus"></i> Map Port</button>
|
| 237 |
+
</div>
|
| 238 |
+
</form>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
<script src="/static/app.js"></script>
|
| 243 |
</body>
|
| 244 |
</html>
|
static/style.css
CHANGED
|
@@ -719,5 +719,120 @@ body {
|
|
| 719 |
.toast-error svg { color: var(--danger); }
|
| 720 |
.toast-info svg { color: var(--accent); }
|
| 721 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 722 |
/* ── Utility ───────────────── */
|
| 723 |
.hidden { display: none !important; }
|
|
|
|
| 719 |
.toast-error svg { color: var(--danger); }
|
| 720 |
.toast-info svg { color: var(--accent); }
|
| 721 |
|
| 722 |
+
/* ── Port Panel ────────────── */
|
| 723 |
+
.port-controls { position: relative; }
|
| 724 |
+
|
| 725 |
+
.btn-sm {
|
| 726 |
+
padding: 4px 10px;
|
| 727 |
+
font-size: 12px;
|
| 728 |
+
border-radius: var(--radius-sm);
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.btn-sm svg { width: 14px; height: 14px; }
|
| 732 |
+
|
| 733 |
+
.port-count {
|
| 734 |
+
background: var(--accent);
|
| 735 |
+
color: var(--bg-0);
|
| 736 |
+
font-size: 10px;
|
| 737 |
+
font-weight: 700;
|
| 738 |
+
padding: 1px 6px;
|
| 739 |
+
border-radius: 10px;
|
| 740 |
+
min-width: 16px;
|
| 741 |
+
text-align: center;
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
.port-panel {
|
| 745 |
+
position: fixed;
|
| 746 |
+
top: auto;
|
| 747 |
+
right: 16px;
|
| 748 |
+
width: 340px;
|
| 749 |
+
background: var(--bg-2);
|
| 750 |
+
border: 1px solid var(--border);
|
| 751 |
+
border-radius: var(--radius);
|
| 752 |
+
box-shadow: var(--shadow);
|
| 753 |
+
z-index: 900;
|
| 754 |
+
animation: slideUp 150ms ease;
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
.port-panel-header {
|
| 758 |
+
display: flex;
|
| 759 |
+
align-items: center;
|
| 760 |
+
justify-content: space-between;
|
| 761 |
+
padding: 10px 14px;
|
| 762 |
+
border-bottom: 1px solid var(--border);
|
| 763 |
+
font-size: 13px;
|
| 764 |
+
font-weight: 600;
|
| 765 |
+
color: var(--text);
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
.port-panel-header span { display: flex; align-items: center; gap: 6px; }
|
| 769 |
+
.port-panel-header svg { width: 14px; height: 14px; color: var(--accent); }
|
| 770 |
+
|
| 771 |
+
.port-panel-hint {
|
| 772 |
+
padding: 8px 14px;
|
| 773 |
+
font-size: 11px;
|
| 774 |
+
color: var(--text-3);
|
| 775 |
+
line-height: 1.4;
|
| 776 |
+
border-bottom: 1px solid var(--border);
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
.port-list { max-height: 260px; overflow-y: auto; }
|
| 780 |
+
|
| 781 |
+
.port-item {
|
| 782 |
+
display: flex;
|
| 783 |
+
align-items: center;
|
| 784 |
+
gap: 8px;
|
| 785 |
+
padding: 8px 14px;
|
| 786 |
+
border-bottom: 1px solid var(--border);
|
| 787 |
+
transition: background var(--transition);
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
.port-item:last-child { border-bottom: none; }
|
| 791 |
+
.port-item:hover { background: var(--bg-hover); }
|
| 792 |
+
|
| 793 |
+
.port-item .pi-port {
|
| 794 |
+
font-family: var(--font-mono);
|
| 795 |
+
font-size: 13px;
|
| 796 |
+
font-weight: 600;
|
| 797 |
+
color: var(--accent);
|
| 798 |
+
min-width: 48px;
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
.port-item .pi-label {
|
| 802 |
+
flex: 1;
|
| 803 |
+
font-size: 12px;
|
| 804 |
+
color: var(--text-2);
|
| 805 |
+
overflow: hidden;
|
| 806 |
+
text-overflow: ellipsis;
|
| 807 |
+
white-space: nowrap;
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
.port-item .pi-actions {
|
| 811 |
+
display: flex;
|
| 812 |
+
gap: 4px;
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
.port-item .pi-actions button {
|
| 816 |
+
width: 24px; height: 24px;
|
| 817 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 818 |
+
background: transparent;
|
| 819 |
+
border: none;
|
| 820 |
+
border-radius: var(--radius-sm);
|
| 821 |
+
color: var(--text-3);
|
| 822 |
+
cursor: pointer;
|
| 823 |
+
transition: all var(--transition);
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
.port-item .pi-actions button svg { width: 13px; height: 13px; }
|
| 827 |
+
.port-item .pi-actions button:hover { background: var(--bg-3); color: var(--text); }
|
| 828 |
+
.port-item .pi-actions .pi-del:hover { color: var(--danger); }
|
| 829 |
+
|
| 830 |
+
.port-empty {
|
| 831 |
+
padding: 20px 14px;
|
| 832 |
+
text-align: center;
|
| 833 |
+
color: var(--text-3);
|
| 834 |
+
font-size: 12px;
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
/* ── Utility ───────────────── */
|
| 838 |
.hidden { display: none !important; }
|
terminal.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
import asyncio
|
|
|
|
| 2 |
import fcntl
|
|
|
|
| 3 |
import os
|
| 4 |
import pty
|
| 5 |
import select
|
|
@@ -8,12 +10,15 @@ import termios
|
|
| 8 |
from fastapi import WebSocket, WebSocketDisconnect
|
| 9 |
from zones import get_zone_path
|
| 10 |
|
| 11 |
-
#
|
|
|
|
|
|
|
|
|
|
| 12 |
active_terminals: dict[str, dict] = {}
|
| 13 |
|
| 14 |
|
| 15 |
def _spawn_shell(zone_name: str) -> dict:
|
| 16 |
-
"""
|
| 17 |
zone_path = get_zone_path(zone_name)
|
| 18 |
master_fd, slave_fd = pty.openpty()
|
| 19 |
|
|
@@ -37,7 +42,7 @@ def _spawn_shell(zone_name: str) -> dict:
|
|
| 37 |
# Set non-blocking on master
|
| 38 |
flag = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
| 39 |
fcntl.fcntl(master_fd, fcntl.F_SETFL, flag | os.O_NONBLOCK)
|
| 40 |
-
return {"fd": master_fd, "pid": child_pid}
|
| 41 |
|
| 42 |
|
| 43 |
def resize_terminal(zone_name: str, rows: int, cols: int):
|
|
@@ -48,10 +53,56 @@ def resize_terminal(zone_name: str, rows: int, cols: int):
|
|
| 48 |
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def kill_terminal(zone_name: str):
|
| 52 |
-
"""Kill terminal process
|
| 53 |
if zone_name in active_terminals:
|
| 54 |
info = active_terminals.pop(zone_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
try:
|
| 56 |
os.kill(info["pid"], 9)
|
| 57 |
os.waitpid(info["pid"], os.WNOHANG)
|
|
@@ -77,10 +128,10 @@ def _is_alive(zone_name: str) -> bool:
|
|
| 77 |
|
| 78 |
|
| 79 |
async def terminal_ws(websocket: WebSocket, zone_name: str):
|
| 80 |
-
"""WebSocket handler
|
| 81 |
await websocket.accept()
|
| 82 |
|
| 83 |
-
#
|
| 84 |
try:
|
| 85 |
get_zone_path(zone_name)
|
| 86 |
except ValueError as e:
|
|
@@ -88,39 +139,31 @@ async def terminal_ws(websocket: WebSocket, zone_name: str):
|
|
| 88 |
await websocket.close()
|
| 89 |
return
|
| 90 |
|
| 91 |
-
# Spawn
|
| 92 |
if not _is_alive(zone_name):
|
| 93 |
-
kill_terminal(zone_name) # Cleanup
|
| 94 |
try:
|
| 95 |
info = _spawn_shell(zone_name)
|
|
|
|
| 96 |
active_terminals[zone_name] = info
|
|
|
|
|
|
|
|
|
|
| 97 |
except Exception as e:
|
| 98 |
-
await websocket.send_json({"error": f"
|
| 99 |
await websocket.close()
|
| 100 |
return
|
| 101 |
|
| 102 |
-
|
|
|
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
while True:
|
| 109 |
-
await asyncio.sleep(0.02)
|
| 110 |
-
if not _is_alive(zone_name):
|
| 111 |
-
break
|
| 112 |
-
try:
|
| 113 |
-
r, _, _ = select.select([fd], [], [], 0)
|
| 114 |
-
if r:
|
| 115 |
-
data = os.read(fd, 4096)
|
| 116 |
-
if data:
|
| 117 |
-
await websocket.send_bytes(data)
|
| 118 |
-
except (OSError, BlockingIOError):
|
| 119 |
-
pass
|
| 120 |
-
except (WebSocketDisconnect, Exception):
|
| 121 |
-
pass
|
| 122 |
|
| 123 |
-
|
|
|
|
| 124 |
|
| 125 |
try:
|
| 126 |
while True:
|
|
@@ -129,7 +172,6 @@ async def terminal_ws(websocket: WebSocket, zone_name: str):
|
|
| 129 |
break
|
| 130 |
|
| 131 |
if "text" in msg:
|
| 132 |
-
import json
|
| 133 |
data = json.loads(msg["text"])
|
| 134 |
if data.get("type") == "resize":
|
| 135 |
resize_terminal(zone_name, data.get("rows", 24), data.get("cols", 80))
|
|
@@ -142,5 +184,6 @@ async def terminal_ws(websocket: WebSocket, zone_name: str):
|
|
| 142 |
except Exception:
|
| 143 |
pass
|
| 144 |
finally:
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
| 1 |
import asyncio
|
| 2 |
+
import collections
|
| 3 |
import fcntl
|
| 4 |
+
import json
|
| 5 |
import os
|
| 6 |
import pty
|
| 7 |
import select
|
|
|
|
| 10 |
from fastapi import WebSocket, WebSocketDisconnect
|
| 11 |
from zones import get_zone_path
|
| 12 |
|
| 13 |
+
# Max bytes kept in the scrollback ring buffer per zone
|
| 14 |
+
SCROLLBACK_SIZE = 128 * 1024 # 128 KB
|
| 15 |
+
|
| 16 |
+
# Active terminals: {zone_name: {fd, pid, buffer, bg_task}}
|
| 17 |
active_terminals: dict[str, dict] = {}
|
| 18 |
|
| 19 |
|
| 20 |
def _spawn_shell(zone_name: str) -> dict:
|
| 21 |
+
"""Spawn a new PTY shell for a zone."""
|
| 22 |
zone_path = get_zone_path(zone_name)
|
| 23 |
master_fd, slave_fd = pty.openpty()
|
| 24 |
|
|
|
|
| 42 |
# Set non-blocking on master
|
| 43 |
flag = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
| 44 |
fcntl.fcntl(master_fd, fcntl.F_SETFL, flag | os.O_NONBLOCK)
|
| 45 |
+
return {"fd": master_fd, "pid": child_pid, "buffer": collections.deque(), "buffer_size": 0}
|
| 46 |
|
| 47 |
|
| 48 |
def resize_terminal(zone_name: str, rows: int, cols: int):
|
|
|
|
| 53 |
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
| 54 |
|
| 55 |
|
| 56 |
+
def _append_buffer(info: dict, data: bytes):
|
| 57 |
+
"""Append data to the zone's ring buffer, evicting old chunks if needed."""
|
| 58 |
+
info["buffer"].append(data)
|
| 59 |
+
info["buffer_size"] += len(data)
|
| 60 |
+
while info["buffer_size"] > SCROLLBACK_SIZE:
|
| 61 |
+
old = info["buffer"].popleft()
|
| 62 |
+
info["buffer_size"] -= len(old)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _get_buffer(info: dict) -> bytes:
|
| 66 |
+
"""Return the full buffered scrollback as a single bytes object."""
|
| 67 |
+
return b"".join(info["buffer"])
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def _bg_reader(zone_name: str):
|
| 71 |
+
"""Background task: continuously reads PTY output and stores it in the ring buffer.
|
| 72 |
+
This runs for the lifetime of the terminal, regardless of WebSocket connections."""
|
| 73 |
+
info = active_terminals.get(zone_name)
|
| 74 |
+
if not info:
|
| 75 |
+
return
|
| 76 |
+
fd = info["fd"]
|
| 77 |
+
while _is_alive(zone_name):
|
| 78 |
+
await asyncio.sleep(0.02)
|
| 79 |
+
try:
|
| 80 |
+
r, _, _ = select.select([fd], [], [], 0)
|
| 81 |
+
if r:
|
| 82 |
+
data = os.read(fd, 4096)
|
| 83 |
+
if data:
|
| 84 |
+
_append_buffer(info, data)
|
| 85 |
+
# Forward to connected WebSocket if any
|
| 86 |
+
ws = info.get("ws")
|
| 87 |
+
if ws:
|
| 88 |
+
try:
|
| 89 |
+
await ws.send_bytes(data)
|
| 90 |
+
except Exception:
|
| 91 |
+
info["ws"] = None
|
| 92 |
+
except (OSError, BlockingIOError):
|
| 93 |
+
pass
|
| 94 |
+
except Exception:
|
| 95 |
+
break
|
| 96 |
+
|
| 97 |
+
|
| 98 |
def kill_terminal(zone_name: str):
|
| 99 |
+
"""Kill terminal process for a zone."""
|
| 100 |
if zone_name in active_terminals:
|
| 101 |
info = active_terminals.pop(zone_name)
|
| 102 |
+
# Cancel background reader
|
| 103 |
+
bg = info.get("bg_task")
|
| 104 |
+
if bg:
|
| 105 |
+
bg.cancel()
|
| 106 |
try:
|
| 107 |
os.kill(info["pid"], 9)
|
| 108 |
os.waitpid(info["pid"], os.WNOHANG)
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
async def terminal_ws(websocket: WebSocket, zone_name: str):
|
| 131 |
+
"""WebSocket handler for terminal."""
|
| 132 |
await websocket.accept()
|
| 133 |
|
| 134 |
+
# Check zone exists
|
| 135 |
try:
|
| 136 |
get_zone_path(zone_name)
|
| 137 |
except ValueError as e:
|
|
|
|
| 139 |
await websocket.close()
|
| 140 |
return
|
| 141 |
|
| 142 |
+
# Spawn or reuse terminal
|
| 143 |
if not _is_alive(zone_name):
|
| 144 |
+
kill_terminal(zone_name) # Cleanup if needed
|
| 145 |
try:
|
| 146 |
info = _spawn_shell(zone_name)
|
| 147 |
+
info["ws"] = None
|
| 148 |
active_terminals[zone_name] = info
|
| 149 |
+
# Start background reader that persists for lifetime of the terminal
|
| 150 |
+
bg = asyncio.create_task(_bg_reader(zone_name))
|
| 151 |
+
info["bg_task"] = bg
|
| 152 |
except Exception as e:
|
| 153 |
+
await websocket.send_json({"error": f"Cannot create terminal: {e}"})
|
| 154 |
await websocket.close()
|
| 155 |
return
|
| 156 |
|
| 157 |
+
info = active_terminals[zone_name]
|
| 158 |
+
fd = info["fd"]
|
| 159 |
|
| 160 |
+
# Replay buffered scrollback so the user sees previous output
|
| 161 |
+
buf = _get_buffer(info)
|
| 162 |
+
if buf:
|
| 163 |
+
await websocket.send_bytes(buf)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
+
# Register this WebSocket as the active receiver
|
| 166 |
+
info["ws"] = websocket
|
| 167 |
|
| 168 |
try:
|
| 169 |
while True:
|
|
|
|
| 172 |
break
|
| 173 |
|
| 174 |
if "text" in msg:
|
|
|
|
| 175 |
data = json.loads(msg["text"])
|
| 176 |
if data.get("type") == "resize":
|
| 177 |
resize_terminal(zone_name, data.get("rows", 24), data.get("cols", 80))
|
|
|
|
| 184 |
except Exception:
|
| 185 |
pass
|
| 186 |
finally:
|
| 187 |
+
# Unregister WebSocket but keep terminal + bg reader alive
|
| 188 |
+
if zone_name in active_terminals and active_terminals[zone_name].get("ws") is websocket:
|
| 189 |
+
active_terminals[zone_name]["ws"] = None
|