From d381c02946a9ec06d0a9c6a9a78c489866e5a255 Mon Sep 17 00:00:00 2001 From: truetype Date: Thu, 5 Feb 2026 10:34:34 +0100 Subject: [PATCH] feat: land dashboard groundwork --- DASH.MD | 93 +++++++ README.MD | 69 ++++++ README.pdf | Bin 0 -> 39509 bytes app/__pycache__/main.cpython-313.pyc | Bin 0 -> 7774 bytes .../tunnel_manager.cpython-313.pyc | Bin 0 -> 7542 bytes app/main.py | 89 +++++++ app/services.py | 171 +++++++++++++ app/static/app.js | 229 ++++++++++++++++++ app/static/styles.css | 160 ++++++++++++ app/templates/index.html | 43 ++++ app/tunnel_manager.py | 129 ++++++++++ requirements.txt | 4 + 12 files changed, 987 insertions(+) create mode 100644 DASH.MD create mode 100644 README.MD create mode 100644 README.pdf create mode 100644 app/__pycache__/main.cpython-313.pyc create mode 100644 app/__pycache__/tunnel_manager.cpython-313.pyc create mode 100644 app/main.py create mode 100644 app/services.py create mode 100644 app/static/app.js create mode 100644 app/static/styles.css create mode 100644 app/templates/index.html create mode 100644 app/tunnel_manager.py create mode 100644 requirements.txt diff --git a/DASH.MD b/DASH.MD new file mode 100644 index 0000000..f1e4ccd --- /dev/null +++ b/DASH.MD @@ -0,0 +1,93 @@ +# Gradio Tunnel Dashboard (studio progetto) + +Questo repository contiene una proposta di studio per un servizio Linux che: + +- **scansiona le porte in uso** sul server (inclusi i servizi Docker); +- **mostra i risultati in una dashboard**; +- **permette di selezionare un servizio/porta** e **attivare un tunnel** basato su [gradio-tunneling](https://github.com/arkohut/gradio-tunneling); +- **espone l'URL del tunnel in dashboard**, con controlli per **mantenere** o **spegnere** il tunnel e liberare la risorsa. + +## Obiettivo del progetto + +Costruire un servizio che renda semplice e sicuro l'esposizione temporanea di servizi locali (es. container Docker) tramite tunnel, con un'interfaccia web centralizzata. + +## Architettura proposta + +### 1) Scanner delle porte + +- **Input**: porte in ascolto sul server (TCP/UDP), con informazioni su processo, PID, utente. +- **Fonti consigliate**: + - `ss -ltnp` / `ss -lunp` per porte e processi. + - Docker: `docker ps` + `docker inspect` per mappature porte/servizi. +- **Output**: lista normalizzata con: + - porta, protocollo, PID/processo, + - container (se applicabile), + - metadati (nome servizio, descrizione, tag). + +### 2) Service registry (modello dati) + +- **Store**: SQLite o PostgreSQL. +- **Entità principali**: + - `Service` (nome, porta, protocollo, tipo, metadata) + - `Tunnel` (service_id, status, public_url, created_at, updated_at) + +### 3) Orchestratore Tunnel + +- **Driver**: integrazione con `gradio-tunneling`. +- **Funzioni**: + - `start_tunnel(service)` + - `stop_tunnel(tunnel)` + - `health_check(tunnel)` +- **Process supervision**: + - `systemd` (service unit) oppure job manager interno (process/thread pool). +- **Persistence**: + - mantenere stato e URL pubblico nel DB. + +### 4) Dashboard web + +- **Funzionalità**: + - elenco porte/servizi disponibili + - filtri (per container, protocollo, status tunnel) + - azioni: avvia/termina tunnel + - mostra URL pubblico del tunnel con pulsante copia +- **Stack possibili**: + - Backend: FastAPI/Flask + - Frontend: React/Vue, oppure UI server-side (Jinja2) o Gradio stesso + +### 5) Sicurezza e policy + +- Whitelist di porte consentite. +- Autenticazione (OAuth, basic auth o SSO). +- Audit log per avvio/stop tunnel. +- Rate limiting per creare tunnel. + +## Flusso operativo (happy path) + +1. Scanner rileva porte attive e aggiorna il registry. +2. L'utente apre la dashboard e seleziona un servizio. +3. Il backend avvia `gradio-tunneling` sulla porta selezionata. +4. L'URL pubblico del tunnel viene visualizzato in dashboard. +5. L'utente può interrompere il tunnel e liberare la risorsa. + +## Milestone suggerite + +1. **MVP scanner**: lista porte + metadati base. +2. **MVP dashboard**: tabella + pulsanti avvio/stop. +3. **Tunnel orchestration**: integrazione `gradio-tunneling`. +4. **Stabilità**: gestione processi, retry, health checks. +5. **Security**: auth + policy + audit. + +## Note implementative + +- Per Docker: associare porta -> container tramite inspect o naming convention. +- Prevedere un layer di caching per evitare scansioni continue troppo costose. +- In caso di reboot, ripristinare lo stato dei tunnel o marcarli come inattivi. + +## Prossimi passi + +Se vuoi, posso: + +- generare una struttura iniziale del progetto (backend + frontend); +- creare un prototipo funzionante con API di scansione e avvio tunnel; +- aggiungere un esempio di integrazione con `gradio-tunneling`; +- scrivere i container Docker per sviluppo locale. diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..3219c98 --- /dev/null +++ b/README.MD @@ -0,0 +1,69 @@ +# Gradio Tunnel Dashboard + +Dashboard web per un server Linux che individua le porte in ascolto, permette di avviare e fermare tunnel tramite `gradio-tunneling` e mostra l'URL generato, tutto dal browser. + +## Funzionalità principali + +- **Scanner portale intelligente** con caching TTL, riconoscimento del protocollo, utente, comando completo e link al container Docker quando disponibile. +- **Dashboard unificata**: la sezione Porte mostra lo stato di ciascun port, mentre la tabella Tunnel permette di leggere l'output, aprire i log e interrompere i tunnel attivi. +- **Controllo dei tunnel**: pulsanti Avvia/Ferma sincronizzati con lo stato del tunnel, badge visivi per sapere se una porta è occupata e aggiornamenti automatici ogni 4 secondi. +- **Debug integrato**: pannello log per ogni tunnel con pulsante dedicato, e l'URL pubblico viene estratto dai log del processo. + +## Scanner porte e metadati + +Il modulo `app/services.py` utilizza `psutil` per enumerare le connessioni `LISTEN`, deduce protocollo/utente/processo/comando e abbina i PID ai container Docker tramite `/proc//cgroup`. I risultati vengono memorizzati per 2 secondi per ridurre la pressione sulla CPU. + +La risposta `/api/ports` include ora: + +- `port`, `protocol`, `service`, `user`, `pid`, `command`, `container`, `local_address` + +Questi campi alimentano la tabella Porte e aiutano nelle decisioni manuali prima di aprire un tunnel. + +## Interfaccia web + +- La tabella Porte mostra metadati completi e un badge Tunnel che indica se la porta è già in uso. Il pulsante **Avvia tunnel** rimane disabilitato quando un tunnel è presente. +- La tabella Tunnel mostra il PID, lo stato, l'URL pubblico, l'ultimo log e il comando invocato. I pulsanti **Log** e **Ferma** permettono debug e cleanup rapidi. +- Il pannello inferiore espone i log del tunnel selezionato per trovare errori di connessione o capire quando viene stampato l'URL pubblico. + +## Avvio rapido + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +Apri `http://localhost:8000`. + +## Configurazione + +Il comando usato per avviare i tunnel è configurabile con la variabile d'ambiente `TUNNEL_COMMAND`. + +Esempio: + +```bash +export TUNNEL_COMMAND="python -m gradio_tunneling --port {port}" +``` + +Se il comando `python -m gradio_tunneling` non funziona, usa la CLI installata dal pacchetto: + +```bash +export TUNNEL_COMMAND="gradio-tunneling --port {port}" +``` + +L'URL viene rilevato dalla prima stringa che contiene un link HTTP/HTTPS nel log del tunnel. + +La dashboard aggiorna automaticamente lo stato dei tunnel e mostra l'ultimo log per aiutare il debug quando l'URL non è disponibile. + +## Debug + +Nella sezione dei tunnel puoi aprire il pannello Log per vedere le ultime righe prodotte dal processo `gradio_tunneling`. Questo aiuta a capire se il tunnel non parte o se l'URL non viene stampato. + +## Rilevamento servizi Docker + +La tabella delle porte mostra anche il nome del container Docker quando disponibile. Questo richiede che il processo abbia accesso al socket Docker locale. + +## Dipendenze tunneling + +Il progetto usa `gradio-tunneling` (installato da GitHub tramite `requirements.txt`). Se stai usando un ambiente offline, installa manualmente il pacchetto nella venv prima di avviare il servizio. diff --git a/README.pdf b/README.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2cf4321f2afe97e54b41f8ed200893ebf4df5bd0 GIT binary patch literal 39509 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 EKjz4$wEzGB literal 0 HcmV?d00001 diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..38da2f2a579cefa79ab710e94cb74181c19e8c20 GIT binary patch literal 7774 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 literal 0 HcmV?d00001 diff --git a/app/__pycache__/tunnel_manager.cpython-313.pyc b/app/__pycache__/tunnel_manager.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3750a25fed3052d673b9f802f237cde78a4bbcb9 GIT binary patch literal 7542 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 literal 0 HcmV?d00001 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..256b99d --- /dev/null +++ b/app/main.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import dataclasses +import subprocess + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel + +from app.services import PortScanner +from app.tunnel_manager import TunnelManager + +app = FastAPI(title="Gradio Tunnel Dashboard") +manager = TunnelManager() +scanner = PortScanner() + +app.mount("/static", StaticFiles(directory="app/static"), name="static") +templates = Jinja2Templates(directory="app/templates") + + +class TunnelRequest(BaseModel): + port: int + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request) -> HTMLResponse: + return templates.TemplateResponse("index.html", {"request": request}) + + +@app.get("/api/ports") +async def list_ports() -> list[dict[str, str | int | None]]: + records = scanner.scan() + return [dataclasses.asdict(record) for record in sorted(records, key=lambda item: item.port)] + + +@app.get("/api/tunnels") +async def list_tunnels() -> list[dict[str, str | int | None]]: + tunnels = manager.list_tunnels() + return [ + { + "port": info.port, + "command": info.command, + "pid": info.pid, + "url": info.url, + "status": info.status, + "exit_code": info.exit_code, + "last_output": info.last_output, + } + for info in tunnels.values() + ] + + +@app.get("/api/tunnels/{port}/logs") +async def tunnel_logs(port: int) -> dict[str, list[str]]: + logs = manager.get_logs(port) + if logs is None: + raise HTTPException(status_code=404, detail="Tunnel not running.") + return {"logs": logs} + + +@app.post("/api/tunnels") +async def start_tunnel(payload: TunnelRequest) -> dict[str, str | int | None]: + if payload.port <= 0 or payload.port > 65535: + raise HTTPException(status_code=400, detail="Invalid port.") + info = manager.start_tunnel(payload.port) + return { + "port": info.port, + "command": info.command, + "pid": info.pid, + "url": info.url, + "status": info.status, + "exit_code": info.exit_code, + "last_output": info.last_output, + } + + +@app.delete("/api/tunnels/{port}") +async def stop_tunnel(port: int) -> dict[str, bool]: + stopped = manager.stop_tunnel(port) + if not stopped: + raise HTTPException(status_code=404, detail="Tunnel not running.") + return {"stopped": True} + + +@app.on_event("shutdown") +def shutdown_event() -> None: + manager.shutdown() diff --git a/app/services.py b/app/services.py new file mode 100644 index 0000000..5392c6f --- /dev/null +++ b/app/services.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import shlex +import socket +import subprocess +import time +from dataclasses import dataclass +from typing import Iterable, List, Optional + +import psutil + + +@dataclass +class ServiceRecord: + port: int + protocol: str + service: str + pid: Optional[int] + user: Optional[str] + command: Optional[str] + container: Optional[str] + local_address: str + + +class PortScanner: + def __init__(self, cache_ttl: float = 2.0) -> None: + self._cache_ttl = cache_ttl + self._last_scan = 0.0 + self._cache: List[ServiceRecord] = [] + + def scan(self) -> List[ServiceRecord]: + now = time.monotonic() + if self._cache and now - self._last_scan < self._cache_ttl: + return self._cache + self._cache = self._perform_scan() + self._last_scan = now + return self._cache + + def _perform_scan(self) -> List[ServiceRecord]: + connections = psutil.net_connections(kind="inet") + container_names = _load_container_names() + process_cache: dict[int, tuple[str, Optional[str], list[str]]] = {} + seen_ports: set[int] = set() + results: List[ServiceRecord] = [] + + for conn in connections: + if conn.status != psutil.CONN_LISTEN: + continue + if not conn.laddr: + continue + port = conn.laddr.port + if port in seen_ports: + continue + seen_ports.add(port) + protocol = _protocol_for_conn(conn.type) + process_name = "unknown" + process_user: Optional[str] = None + process_command: Optional[str] = None + pid = conn.pid + + if pid and pid > 0: + cache_entry = process_cache.get(pid) + if cache_entry is None: + cache_entry = _describe_process(pid) + process_cache[pid] = cache_entry + process_name, process_user, raw_command = cache_entry + if raw_command: + process_command = _format_commandline(raw_command) + local_address = f"{conn.laddr.ip}:{conn.laddr.port}" + container = _container_name_for_pid(pid, container_names) if pid else None + + results.append( + ServiceRecord( + port=port, + protocol=protocol, + service=process_name, + pid=pid, + user=process_user, + command=process_command, + container=container, + local_address=local_address, + ) + ) + + return results + + +def _protocol_for_conn(sock_type: int) -> str: + if sock_type == socket.SOCK_STREAM: + return "tcp" + if sock_type == socket.SOCK_DGRAM: + return "udp" + return "inet" + + +def _describe_process(pid: int) -> tuple[str, Optional[str], list[str]]: + try: + process = psutil.Process(pid) + name = process.name() or "unknown" + user = None + try: + user = process.username() + except psutil.Error: + user = None + command = process.cmdline() + except psutil.Error: + return "unknown", None, [] + return name, user, command + + +def _format_commandline(cmdline: Iterable[str]) -> str: + command = " ".join(shlex.quote(part) for part in cmdline if part) + if len(command) > 150: + return command[:150].rstrip() + "…" + return command + + +def _load_container_names() -> dict[str, str]: + try: + result = subprocess.run( + [ + "docker", + "ps", + "--no-trunc", + "--format", + "{{.ID}} {{.Names}}", + ], + capture_output=True, + text=True, + check=True, + ) + except (subprocess.SubprocessError, FileNotFoundError): + return {} + containers: dict[str, str] = {} + for line in result.stdout.splitlines(): + parts = line.strip().split(maxsplit=1) + if len(parts) != 2: + continue + container_id, name = parts + containers[container_id.lower()] = name + return containers + + +def _container_name_for_pid(pid: Optional[int], container_names: dict[str, str]) -> Optional[str]: + if not pid: + return None + try: + with open(f"/proc/{pid}/cgroup", "r", encoding="utf-8") as handle: + content = handle.read() + except OSError: + return None + for line in content.splitlines(): + lower = line.lower() + if not any(keyword in lower for keyword in ("docker", "containerd", "kubepods", "podman")): + continue + for part in line.split("/"): + part = part.strip() + if not part: + continue + if _looks_like_container_id(part): + for container_id, name in container_names.items(): + if container_id.startswith(part.lower()): + return name + return None + + +def _looks_like_container_id(value: str) -> bool: + value = value.strip() + if len(value) < 12: + return False + return all(ch in "0123456789abcdef" for ch in value[:12].lower()) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..fb8ac2a --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,229 @@ +const portsTable = document.getElementById("ports-table"); +const tunnelsTable = document.getElementById("tunnels-table"); +const refreshPortsButton = document.getElementById("refresh-ports"); +const refreshTunnelsButton = document.getElementById("refresh-tunnels"); +const debugPanel = document.getElementById("debug-panel"); +const debugTitle = document.getElementById("debug-title"); +const debugContent = document.getElementById("debug-content"); + +const REFRESH_INTERVAL_MS = 4000; +let latestTunnels = []; + +async function fetchJson(url, options) { + const response = await fetch(url, options); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + const detail = payload.detail || "Errore inatteso."; + throw new Error(detail); + } + return response.json(); +} + +function escapeHtml(value) { + if (!value) { + return ""; + } + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +async function loadTunnelLogs(port) { + try { + const payload = await fetchJson(`/api/tunnels/${port}/logs`); + debugTitle.textContent = `Log tunnel ${port}`; + debugContent.textContent = payload.logs.join("\n") || "Nessun log disponibile."; + debugPanel.classList.add("visible"); + } catch (error) { + debugTitle.textContent = `Log tunnel ${port}`; + debugContent.textContent = `Errore: ${error.message}`; + debugPanel.classList.add("visible"); + } +} + +function hideDebugPanel() { + debugPanel.classList.remove("visible"); + debugTitle.textContent = ""; + debugContent.textContent = ""; +} + +function renderPorts(ports, tunnels) { + if (!ports.length) { + portsTable.innerHTML = "

Nessuna porta in ascolto trovata.

"; + return; + } + + const tunnelMap = new Map(); + tunnels.forEach((tunnel) => { + tunnelMap.set(tunnel.port, tunnel); + }); + + const rows = ports + .map((port) => { + const tunnel = tunnelMap.get(port.port); + const busy = Boolean(tunnel && tunnel.status !== "stopped"); + const statusLabel = busy ? (tunnel.status || "running") : "libero"; + const hasCommand = Boolean(port.command); + const commandCell = hasCommand + ? `${escapeHtml(port.command)}` + : "-"; + const commandClass = hasCommand ? "" : "dim"; + const user = escapeHtml(port.user || "-"); + const container = escapeHtml(port.container || "-"); + const local = escapeHtml(port.local_address || "-"); + const service = escapeHtml(port.service || "-"); + const protocol = escapeHtml((port.protocol || "tcp").toUpperCase()); + + return ` +
+
${port.port}
+
${protocol}
+
${service}
+
${user}
+
${commandCell}
+
${container}
+
${local}
+
+ ${statusLabel} +
+
+ +
+
+ `; + }) + .join(""); + + portsTable.innerHTML = ` +
+
Porta
+
Protocollo
+
Servizio
+
Utente
+
Comando
+
Container
+
Indirizzo locale
+
Tunnel
+
Azioni
+
+ ${rows} + `; + + portsTable.querySelectorAll(".start-button").forEach((button) => { + button.addEventListener("click", async () => { + const port = Number(button.dataset.port); + button.disabled = true; + try { + await fetchJson("/api/tunnels", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ port }), + }); + await refreshState(); + } catch (error) { + alert(error.message); + } finally { + button.disabled = false; + } + }); + }); +} + +function renderTunnels(tunnels) { + if (!tunnels.length) { + tunnelsTable.innerHTML = "

Nessun tunnel attivo.

"; + return; + } + + const rows = tunnels + .map((tunnel) => { + const hasCommand = Boolean(tunnel.command); + const commandCell = hasCommand + ? `${escapeHtml(tunnel.command)}` + : "-"; + const commandClass = hasCommand ? "" : "dim"; + const url = tunnel.url + ? `${escapeHtml( + tunnel.url, + )}` + : "In attesa..."; + const lastOutput = escapeHtml(tunnel.last_output || "-"); + const status = escapeHtml(tunnel.status || "running"); + + return ` +
+
${tunnel.port}
+
${tunnel.pid || "-"}
+
${status}
+
${url}
+
${lastOutput}
+
${commandCell}
+
+ + +
+
+ `; + }) + .join(""); + + tunnelsTable.innerHTML = ` +
+
Porta
+
PID
+
Stato
+
URL
+
Ultimo log
+
Comando
+
Azioni
+
+ ${rows} + `; + + tunnelsTable.querySelectorAll(".logs-button").forEach((button) => { + button.addEventListener("click", async () => { + const port = Number(button.dataset.port); + await loadTunnelLogs(port); + }); + }); + + tunnelsTable.querySelectorAll(".stop-button").forEach((button) => { + button.addEventListener("click", async () => { + const port = Number(button.dataset.port); + button.disabled = true; + try { + await fetchJson(`/api/tunnels/${port}`, { method: "DELETE" }); + await refreshState(); + } catch (error) { + alert(error.message); + } finally { + button.disabled = false; + } + }); + }); +} + +async function refreshState() { + try { + const [ports, tunnels] = await Promise.all([ + fetchJson("/api/ports"), + fetchJson("/api/tunnels"), + ]); + latestTunnels = tunnels; + renderPorts(ports, tunnels); + renderTunnels(tunnels); + } catch (error) { + portsTable.innerHTML = `

Errore: ${error.message}

`; + tunnelsTable.innerHTML = `

Errore: ${error.message}

`; + } +} + +refreshPortsButton.addEventListener("click", refreshState); +refreshTunnelsButton.addEventListener("click", refreshState); +document.getElementById("debug-close").addEventListener("click", hideDebugPanel); + +refreshState(); +setInterval(refreshState, REFRESH_INTERVAL_MS); diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..de6e6d5 --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,160 @@ +:root { + color-scheme: light; + font-family: "Inter", "Segoe UI", sans-serif; + background: #f4f6fb; + color: #1a1d2b; +} + +body { + margin: 0; +} + +.page { + max-width: 1100px; + margin: 0 auto; + padding: 32px 24px 48px; +} + +header { + margin-bottom: 24px; +} + +header h1 { + margin: 0 0 8px; + font-size: 2rem; +} + +header p { + margin: 0; + color: #3a3f55; +} + +.card { + background: #ffffff; + border-radius: 16px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 14px 40px rgba(31, 39, 67, 0.08); +} + +.debug-panel { + display: none; +} + +.debug-panel.visible { + display: block; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.card-header h2 { + margin: 0; + font-size: 1.25rem; +} + +button { + border: none; + border-radius: 8px; + padding: 8px 14px; + background: #4c6fff; + color: white; + font-weight: 600; + cursor: pointer; +} + +.logs-button { + background: #2f3b5b; + margin-right: 8px; +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.table { + display: grid; + gap: 8px; +} + +.row { + display: grid; + gap: 12px; + padding: 10px 12px; + border-radius: 10px; + background: #f9fafc; +} + +.table.ports .row { + grid-template-columns: 0.6fr 0.7fr 1.3fr 0.9fr 2fr 1fr 1fr 0.8fr 0.9fr; + align-items: center; +} + +.table.tunnels .row { + grid-template-columns: 0.8fr 1fr 1fr 2fr 2fr 1.5fr 1fr; + align-items: center; +} + +.status-pill { + padding: 4px 10px; + border-radius: 999px; + font-size: 0.85rem; + text-transform: capitalize; + display: inline-block; +} + +.status-free { + background: rgba(76, 111, 255, 0.15); + color: #4751ef; +} + +.status-busy { + background: rgba(239, 68, 68, 0.15); + color: #dc2626; +} + +.mono { + font-family: \"JetBrains Mono\", \"JetBrains Mono\", Menlo, Consolas, monospace; + font-size: 0.9rem; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 16rem; +} + +.dim { + color: #64748b; +} + +.row.header { + background: transparent; + font-weight: 700; +} + +.cell a { + color: #2d5bff; + text-decoration: none; +} + +.debug-content { + background: #0f172a; + color: #e2e8f0; + padding: 16px; + border-radius: 12px; + font-size: 0.85rem; + max-height: 320px; + overflow: auto; + white-space: pre-wrap; +} + +@media (max-width: 720px) { + .row { + grid-template-columns: 1fr; + } +} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..f426d41 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,43 @@ + + + + + + Gradio Tunnel Dashboard + + + +
+
+

Gradio Tunnel Dashboard

+

Scansione porte attive e gestione tunnel Gradio.

+
+ +
+
+

Porte in ascolto

+ +
+
+
+ +
+
+

Tunnel attivi

+ +
+
+
+ +
+
+

+ +
+

+      
+
+ + + + diff --git a/app/tunnel_manager.py b/app/tunnel_manager.py new file mode 100644 index 0000000..9a034e6 --- /dev/null +++ b/app/tunnel_manager.py @@ -0,0 +1,129 @@ +import os +import re +import shlex +import subprocess +import threading +import time +from collections import deque +from dataclasses import dataclass, field +from typing import Deque, Dict, Optional + +URL_REGEX = re.compile(r"https?://\S+") + + +@dataclass +class TunnelInfo: + port: int + command: str + pid: int + url: Optional[str] = None + status: str = "running" + exit_code: Optional[int] = None + last_output: Optional[str] = None + started_at: float = field(default_factory=time.time) + logs: Deque[str] = field(default_factory=lambda: deque(maxlen=200)) + + +class TunnelManager: + def __init__(self) -> None: + self._processes: Dict[int, subprocess.Popen[str]] = {} + self._info: Dict[int, TunnelInfo] = {} + self._lock = threading.Lock() + + def list_tunnels(self) -> Dict[int, TunnelInfo]: + with self._lock: + return dict(self._info) + + def get_logs(self, port: int) -> Optional[list[str]]: + with self._lock: + info = self._info.get(port) + if not info: + return None + return list(info.logs) + + def start_tunnel(self, port: int) -> TunnelInfo: + with self._lock: + if port in self._processes and self._processes[port].poll() is None: + return self._info[port] + + command_template = os.environ.get( + "TUNNEL_COMMAND", + "gradio-tunneling --port {port}", + ) + command = command_template.format(port=port) + args = shlex.split(command) + + process = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env={ + **os.environ, + "PYTHONUNBUFFERED": "1", + }, + ) + info = TunnelInfo(port=port, command=command, pid=process.pid) + self._processes[port] = process + self._info[port] = info + + if process.stdout: + threading.Thread( + target=self._capture_output, + args=(port, process), + daemon=True, + ).start() + threading.Thread( + target=self._watch_process, + args=(port, process), + daemon=True, + ).start() + + return info + + def stop_tunnel(self, port: int) -> bool: + with self._lock: + process = self._processes.get(port) + if not process: + return False + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + self._processes.pop(port, None) + self._info.pop(port, None) + return True + + def shutdown(self) -> None: + with self._lock: + ports = list(self._processes.keys()) + for port in ports: + self.stop_tunnel(port) + + def _capture_output(self, port: int, process: subprocess.Popen[str]) -> None: + if not process.stdout: + return + for line in process.stdout: + match = URL_REGEX.search(line) + if match: + with self._lock: + info = self._info.get(port) + if info: + info.url = match.group(0).rstrip(".") + with self._lock: + info = self._info.get(port) + if info: + info.last_output = line.rstrip() + info.logs.append(line.rstrip()) + + def _watch_process(self, port: int, process: subprocess.Popen[str]) -> None: + exit_code = process.wait() + with self._lock: + info = self._info.get(port) + if info: + info.exit_code = exit_code + if info.status == "running": + info.status = "stopped" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..303a66e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +psutil==6.0.0 +jinja2==3.1.4