feat: land dashboard groundwork

This commit is contained in:
truetype committed 2026-02-05 10:34:34 +01:00
commit d381c02946
12 files changed
+987

No files matched your search

+93
View File
@@ -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.
+69
View File
@@ -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/<pid>/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.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+89
View File
@@ -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()
+171
View File
@@ -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())
+229
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 = "<p>Nessuna porta in ascolto trovata.</p>";
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
? `<span class="mono">${escapeHtml(port.command)}</span>`
: "-";
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 `
<div class="row">
<div class="cell">${port.port}</div>
<div class="cell">${protocol}</div>
<div class="cell">${service}</div>
<div class="cell">${user}</div>
<div class="cell ${commandClass}">${commandCell}</div>
<div class="cell">${container}</div>
<div class="cell">${local}</div>
<div class="cell">
<span class="status-pill ${busy ? "status-busy" : "status-free"}">${statusLabel}</span>
</div>
<div class="cell">
<button data-port="${port.port}" class="start-button" ${busy ? "disabled" : ""}>Avvia tunnel</button>
</div>
</div>
`;
})
.join("");
portsTable.innerHTML = `
<div class="row header">
<div class="cell">Porta</div>
<div class="cell">Protocollo</div>
<div class="cell">Servizio</div>
<div class="cell">Utente</div>
<div class="cell">Comando</div>
<div class="cell">Container</div>
<div class="cell">Indirizzo locale</div>
<div class="cell">Tunnel</div>
<div class="cell">Azioni</div>
</div>
${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 = "<p>Nessun tunnel attivo.</p>";
return;
}
const rows = tunnels
.map((tunnel) => {
const hasCommand = Boolean(tunnel.command);
const commandCell = hasCommand
? `<span class="mono">${escapeHtml(tunnel.command)}</span>`
: "-";
const commandClass = hasCommand ? "" : "dim";
const url = tunnel.url
? `<a href="${escapeHtml(tunnel.url)}" target="_blank" rel="noreferrer">${escapeHtml(
tunnel.url,
)}</a>`
: "In attesa...";
const lastOutput = escapeHtml(tunnel.last_output || "-");
const status = escapeHtml(tunnel.status || "running");
return `
<div class="row">
<div class="cell">${tunnel.port}</div>
<div class="cell">${tunnel.pid || "-"}</div>
<div class="cell">${status}</div>
<div class="cell">${url}</div>
<div class="cell">${lastOutput}</div>
<div class="cell ${commandClass}">${commandCell}</div>
<div class="cell">
<button data-port="${tunnel.port}" class="logs-button">Log</button>
<button data-port="${tunnel.port}" class="stop-button">Ferma</button>
</div>
</div>
`;
})
.join("");
tunnelsTable.innerHTML = `
<div class="row header">
<div class="cell">Porta</div>
<div class="cell">PID</div>
<div class="cell">Stato</div>
<div class="cell">URL</div>
<div class="cell">Ultimo log</div>
<div class="cell">Comando</div>
<div class="cell">Azioni</div>
</div>
${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 = `<p>Errore: ${error.message}</p>`;
tunnelsTable.innerHTML = `<p>Errore: ${error.message}</p>`;
}
}
refreshPortsButton.addEventListener("click", refreshState);
refreshTunnelsButton.addEventListener("click", refreshState);
document.getElementById("debug-close").addEventListener("click", hideDebugPanel);
refreshState();
setInterval(refreshState, REFRESH_INTERVAL_MS);
+160
View File
@@ -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;
}
}
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gradio Tunnel Dashboard</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<main class="page">
<header>
<h1>Gradio Tunnel Dashboard</h1>
<p>Scansione porte attive e gestione tunnel Gradio.</p>
</header>
<section class="card">
<div class="card-header">
<h2>Porte in ascolto</h2>
<button id="refresh-ports">Aggiorna</button>
</div>
<div class="table ports" id="ports-table"></div>
</section>
<section class="card">
<div class="card-header">
<h2>Tunnel attivi</h2>
<button id="refresh-tunnels">Aggiorna</button>
</div>
<div class="table tunnels" id="tunnels-table"></div>
</section>
<section class="card debug-panel" id="debug-panel">
<div class="card-header">
<h2 id="debug-title"></h2>
<button id="debug-close">Chiudi</button>
</div>
<pre id="debug-content" class="debug-content"></pre>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>
+129
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.0
uvicorn==0.30.6
psutil==6.0.0
jinja2==3.1.4