kokokoasd commited on
Commit
5cf5e60
·
verified ·
1 Parent(s): 914be63

Upload 10 files

Browse files
Files changed (7) hide show
  1. app.py +41 -1
  2. proxy.py +236 -0
  3. requirements.txt +1 -0
  4. static/app.js +121 -1
  5. static/index.html +44 -0
  6. static/style.css +115 -0
  7. 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
- term.reset();
 
 
 
 
 
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
- # Lưu các terminal process đang chạy: {zone_name: {fd, pid}}
 
 
 
12
  active_terminals: dict[str, dict] = {}
13
 
14
 
15
  def _spawn_shell(zone_name: str) -> dict:
16
- """Tạo PTY shell mới cho zone."""
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 cho zone."""
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 cho terminal."""
81
  await websocket.accept()
82
 
83
- # Kiểm tra zone tồn tại
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 hoặc reuse terminal
92
  if not _is_alive(zone_name):
93
- kill_terminal(zone_name) # Cleanup nếu cần
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"Không tạo được terminal: {e}"})
99
  await websocket.close()
100
  return
101
 
102
- fd = active_terminals[zone_name]["fd"]
 
103
 
104
- async def read_output():
105
- """Đọc output từ PTY và gửi qua WebSocket."""
106
- loop = asyncio.get_event_loop()
107
- try:
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
- reader_task = asyncio.create_task(read_output())
 
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
- reader_task.cancel()
146
- # Không kill terminal khi disconnect giữ nó chạy
 
 
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