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

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"