| """ |
| app.py β Flask dashboard for Ubuntu Desktop on Hugging Face Spaces |
| Serves: stats API, log viewer, service restart, and embedded desktop |
| """ |
|
|
| import os |
| import subprocess |
| import time |
| import datetime |
| import glob |
| import json |
| from pathlib import Path |
|
|
| import psutil |
| from flask import Flask, jsonify, render_template_string, request |
|
|
| app = Flask(__name__) |
| START_TIME = time.time() |
|
|
| |
| DASHBOARD_HTML = """<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Ubuntu Space Dashboard</title> |
| <style> |
| :root { |
| --bg: #0d0d1a; |
| --surface: #141426; |
| --border: #1e1e3c; |
| --accent: #6366f1; |
| --accent2: #22d3ee; |
| --text: #e2e2f0; |
| --muted: #6b6b90; |
| --green: #34d399; |
| --red: #f87171; |
| --yellow: #fbbf24; |
| --radius: 10px; |
| } |
| * { margin:0; padding:0; box-sizing:border-box; } |
| body { |
| background: var(--bg); |
| color: var(--text); |
| font-family: 'Segoe UI', system-ui, sans-serif; |
| font-size: 14px; |
| min-height: 100vh; |
| } |
| a { color: var(--accent); text-decoration: none; } |
| a:hover { text-decoration: underline; } |
| |
| /* ββ Layout ββ */ |
| .layout { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; } |
| .sidebar { |
| background: var(--surface); |
| border-right: 1px solid var(--border); |
| padding: 1.5rem 1rem; |
| display: flex; flex-direction: column; gap: 0.25rem; |
| } |
| .sidebar h2 { font-size: .75rem; color: var(--muted); text-transform: uppercase; |
| letter-spacing: .1em; margin: 1rem 0 .4rem .5rem; } |
| .sidebar a { |
| display: flex; align-items: center; gap: .6rem; |
| padding: .55rem .75rem; border-radius: 7px; color: var(--text); |
| transition: .15s; |
| } |
| .sidebar a:hover, .sidebar a.active { background: var(--border); text-decoration: none; } |
| .brand { font-size: 1.1rem; font-weight: 700; color: var(--accent); |
| margin-bottom: .5rem; padding: .5rem; } |
| .brand span { color: var(--text); } |
| .main { padding: 1.5rem 2rem; overflow-y: auto; } |
| |
| /* ββ Header ββ */ |
| .page-header { display: flex; align-items: center; justify-content: space-between; |
| margin-bottom: 1.5rem; } |
| .page-header h1 { font-size: 1.25rem; font-weight: 600; } |
| .uptime { font-size: .8rem; color: var(--muted); } |
| |
| /* ββ Cards grid ββ */ |
| .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
| gap: 1rem; margin-bottom: 1.5rem; } |
| .card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 1.1rem 1.3rem; |
| } |
| .card-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .08em; |
| color: var(--muted); margin-bottom: .35rem; } |
| .card-value { font-size: 1.6rem; font-weight: 700; line-height: 1; } |
| .card-sub { font-size: .75rem; color: var(--muted); margin-top: .3rem; } |
| .bar-wrap { margin-top: .6rem; background: var(--border); border-radius: 4px; height: 6px; } |
| .bar-fill { height: 6px; border-radius: 4px; transition: width .4s; } |
| |
| /* ββ Services table ββ */ |
| .section { background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius); margin-bottom: 1.5rem; } |
| .section-header { padding: .9rem 1.3rem; border-bottom: 1px solid var(--border); |
| font-weight: 600; display: flex; align-items: center; gap: .5rem; } |
| table { width: 100%; border-collapse: collapse; } |
| th, td { text-align: left; padding: .75rem 1.3rem; border-bottom: 1px solid var(--border); } |
| th { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } |
| tr:last-child td { border-bottom: none; } |
| tr:hover td { background: rgba(99,102,241,.04); } |
| |
| /* ββ Badges ββ */ |
| .badge { |
| display: inline-flex; align-items: center; gap: .35rem; |
| padding: .2rem .65rem; border-radius: 20px; font-size: .75rem; font-weight: 600; |
| } |
| .badge-green { background: rgba(52,211,153,.12); color: var(--green); } |
| .badge-red { background: rgba(248,113,113,.12); color: var(--red); } |
| .badge-yellow { background: rgba(251,191,36,.12); color: var(--yellow); } |
| .dot { width:7px; height:7px; border-radius:50%; background:currentColor; } |
| |
| /* ββ Buttons ββ */ |
| .btn { |
| padding: .4rem .9rem; border-radius: 6px; border: none; cursor: pointer; |
| font-size: .8rem; font-weight: 600; transition: .15s; |
| } |
| .btn-primary { background: var(--accent); color: #fff; } |
| .btn-primary:hover { background: #5254d4; } |
| .btn-ghost { background: var(--border); color: var(--text); } |
| .btn-ghost:hover { background: #2a2a4e; } |
| .btn-danger { background: rgba(248,113,113,.15); color: var(--red); } |
| .btn-danger:hover { background: rgba(248,113,113,.25); } |
| |
| /* ββ Log viewer ββ */ |
| .log-box { |
| background: #060610; border-radius: 0 0 var(--radius) var(--radius); |
| padding: 1rem 1.3rem; font-family: 'JetBrains Mono', 'Fira Code', monospace; |
| font-size: .78rem; color: #9090c0; height: 280px; |
| overflow-y: auto; white-space: pre-wrap; word-break: break-all; |
| scrollbar-width: thin; scrollbar-color: var(--border) transparent; |
| } |
| .log-controls { padding: .75rem 1.3rem; border-bottom: 1px solid var(--border); |
| display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; } |
| select { background: var(--border); color: var(--text); border: none; |
| border-radius: 5px; padding: .35rem .6rem; font-size: .8rem; } |
| |
| /* ββ Launch bar ββ */ |
| .launch-bar { |
| background: linear-gradient(135deg, rgba(99,102,241,.15), rgba(34,211,238,.08)); |
| border: 1px solid rgba(99,102,241,.3); |
| border-radius: var(--radius); padding: 1.3rem 1.5rem; |
| display: flex; align-items: center; justify-content: space-between; |
| margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; |
| } |
| .launch-bar h2 { font-size: 1rem; font-weight: 600; } |
| .launch-bar p { font-size: .8rem; color: var(--muted); margin-top: .2rem; } |
| |
| /* Responsive */ |
| @media (max-width: 640px) { |
| .layout { grid-template-columns: 1fr; } |
| .sidebar { display: none; } |
| .main { padding: 1rem; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="layout"> |
| |
| <!-- Sidebar --> |
| <aside class="sidebar"> |
| <div class="brand">π§ <span>Ubuntu Space</span></div> |
| <h2>Navigation</h2> |
| <a href="/dashboard" class="active">π Dashboard</a> |
| <a href="/" >π₯ Desktop</a> |
| <a href="/vnc/vnc.html" target="_blank">π noVNC Direct</a> |
| <a href="#logs">π Logs</a> |
| <h2>Info</h2> |
| <a href="https://huggingface.co/docs/hub/spaces" target="_blank">π HF Spaces Docs</a> |
| </aside> |
| |
| <!-- Main --> |
| <main class="main"> |
| <div class="page-header"> |
| <h1>System Dashboard</h1> |
| <span class="uptime" id="uptime-label">Loadingβ¦</span> |
| </div> |
| |
| <!-- Launch bar --> |
| <div class="launch-bar"> |
| <div> |
| <h2>Ubuntu 24.04 XFCE Desktop</h2> |
| <p>PRoot-Distro style isolated environment on Hugging Face Spaces</p> |
| </div> |
| <div style="display:flex;gap:.75rem;flex-wrap:wrap"> |
| <a href="/" class="btn btn-primary">π₯ Open Desktop</a> |
| <button class="btn btn-ghost" onclick="refreshAll()">π Refresh</button> |
| </div> |
| </div> |
| |
| <!-- Stats cards --> |
| <div class="cards" id="stats-cards"> |
| <div class="card"> |
| <div class="card-label">CPU Usage</div> |
| <div class="card-value" id="cpu-val">β</div> |
| <div class="bar-wrap"><div class="bar-fill" id="cpu-bar" style="background:var(--accent)"></div></div> |
| <div class="card-sub" id="cpu-cores">β</div> |
| </div> |
| <div class="card"> |
| <div class="card-label">Memory</div> |
| <div class="card-value" id="ram-val">β</div> |
| <div class="bar-wrap"><div class="bar-fill" id="ram-bar" style="background:var(--accent2)"></div></div> |
| <div class="card-sub" id="ram-sub">β</div> |
| </div> |
| <div class="card"> |
| <div class="card-label">Storage (/data)</div> |
| <div class="card-value" id="disk-val">β</div> |
| <div class="bar-wrap"><div class="bar-fill" id="disk-bar" style="background:var(--green)"></div></div> |
| <div class="card-sub" id="disk-sub">β</div> |
| </div> |
| <div class="card"> |
| <div class="card-label">Session Uptime</div> |
| <div class="card-value" id="uptime-val" style="font-size:1.2rem">β</div> |
| <div class="card-sub">Space started</div> |
| </div> |
| </div> |
| |
| <!-- Services --> |
| <div class="section"> |
| <div class="section-header">βοΈ Services</div> |
| <table> |
| <thead> |
| <tr><th>Service</th><th>Status</th><th>PID</th><th>Action</th></tr> |
| </thead> |
| <tbody id="services-tbody"> |
| <tr><td colspan="4" style="color:var(--muted);text-align:center;padding:1.5rem">Loadingβ¦</td></tr> |
| </tbody> |
| </table> |
| </div> |
| |
| <!-- Log viewer --> |
| <div class="section" id="logs"> |
| <div class="section-header">π Logs</div> |
| <div class="log-controls"> |
| <select id="log-select"> |
| <option value="supervisord">supervisord</option> |
| <option value="xvnc">Xvnc</option> |
| <option value="xfce4">XFCE4</option> |
| <option value="websockify">websockify</option> |
| <option value="dashboard">dashboard</option> |
| <option value="nginx">nginx</option> |
| </select> |
| <button class="btn btn-ghost" onclick="loadLog()">π Reload</button> |
| <label style="display:flex;align-items:center;gap:.4rem;font-size:.8rem;color:var(--muted)"> |
| <input type="checkbox" id="auto-scroll" checked> Auto-scroll |
| </label> |
| <label style="display:flex;align-items:center;gap:.4rem;font-size:.8rem;color:var(--muted)"> |
| <input type="checkbox" id="auto-refresh-log"> Live (5s) |
| </label> |
| </div> |
| <div class="log-box" id="log-box">Select a log file aboveβ¦</div> |
| </div> |
| |
| </main> |
| </div> |
| |
| <script> |
| let logRefreshTimer = null; |
| |
| // ββ Fetch stats ββββββββββββββββββββββββββββββββββββββββββββββ |
| async function fetchStats() { |
| try { |
| const r = await fetch('/api/stats'); |
| const d = await r.json(); |
| |
| // CPU |
| document.getElementById('cpu-val').textContent = d.cpu.percent + '%'; |
| document.getElementById('cpu-bar').style.width = d.cpu.percent + '%'; |
| document.getElementById('cpu-cores').textContent = `${d.cpu.count} cores`; |
| |
| // RAM |
| const ramPct = d.memory.percent; |
| document.getElementById('ram-val').textContent = ramPct + '%'; |
| document.getElementById('ram-bar').style.width = ramPct + '%'; |
| document.getElementById('ram-sub').textContent = |
| `${d.memory.used_gb} / ${d.memory.total_gb} GB`; |
| |
| // Disk |
| const diskPct = d.disk.percent; |
| document.getElementById('disk-val').textContent = diskPct + '%'; |
| document.getElementById('disk-bar').style.width = diskPct + '%'; |
| document.getElementById('disk-sub').textContent = |
| `${d.disk.used_gb} / ${d.disk.total_gb} GB`; |
| |
| // Uptime |
| document.getElementById('uptime-val').textContent = d.uptime.human; |
| document.getElementById('uptime-label').textContent = 'Uptime: ' + d.uptime.human; |
| |
| // Services |
| renderServices(d.services); |
| } catch(e) { |
| console.error('Stats fetch failed:', e); |
| } |
| } |
| |
| function badgeFor(state) { |
| if (state === 'RUNNING') return '<span class="badge badge-green"><span class="dot"></span>Running</span>'; |
| if (state === 'STOPPED') return '<span class="badge badge-red"><span class="dot"></span>Stopped</span>'; |
| return `<span class="badge badge-yellow"><span class="dot"></span>${state}</span>`; |
| } |
| |
| function renderServices(services) { |
| const tbody = document.getElementById('services-tbody'); |
| tbody.innerHTML = services.map(s => ` |
| <tr> |
| <td><strong>${s.name}</strong><br><small style="color:var(--muted)">${s.description}</small></td> |
| <td>${badgeFor(s.state)}</td> |
| <td style="font-family:monospace;color:var(--muted)">${s.pid || 'β'}</td> |
| <td> |
| <button class="btn btn-ghost" onclick="restartService('${s.name}')">βΊ Restart</button> |
| </td> |
| </tr> |
| `).join(''); |
| } |
| |
| async function restartService(name) { |
| if (!confirm(`Restart ${name}?`)) return; |
| try { |
| const r = await fetch(`/api/restart/${name}`, { method: 'POST' }); |
| const d = await r.json(); |
| alert(d.message || d.error); |
| setTimeout(fetchStats, 1500); |
| } catch(e) { |
| alert('Restart failed: ' + e); |
| } |
| } |
| |
| // ββ Fetch logs βββββββββββββββββββββββββββββββββββββββββββββββ |
| async function loadLog() { |
| const name = document.getElementById('log-select').value; |
| try { |
| const r = await fetch(`/api/logs/${name}`); |
| const d = await r.json(); |
| const box = document.getElementById('log-box'); |
| box.textContent = d.content || '(empty)'; |
| if (document.getElementById('auto-scroll').checked) |
| box.scrollTop = box.scrollHeight; |
| } catch(e) { |
| document.getElementById('log-box').textContent = 'Error loading log: ' + e; |
| } |
| } |
| |
| document.getElementById('log-select').addEventListener('change', loadLog); |
| document.getElementById('auto-refresh-log').addEventListener('change', function() { |
| clearInterval(logRefreshTimer); |
| if (this.checked) logRefreshTimer = setInterval(loadLog, 5000); |
| }); |
| |
| function refreshAll() { fetchStats(); loadLog(); } |
| |
| // ββ Poll stats every 5 s ββββββββββββββββββββββββββββββββββββββ |
| fetchStats(); |
| loadLog(); |
| setInterval(fetchStats, 5000); |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| |
|
|
| def _supervisor_ctl(*args) -> str: |
| try: |
| result = subprocess.run( |
| ["supervisorctl"] + list(args), |
| capture_output=True, text=True, timeout=10 |
| ) |
| return result.stdout + result.stderr |
| except Exception as e: |
| return str(e) |
|
|
|
|
| def _supervisor_status() -> list[dict]: |
| """Parse `supervisorctl status` into structured list.""" |
| output = _supervisor_ctl("status") |
| services = [] |
| descriptions = { |
| "xvnc": "Virtual framebuffer + VNC server", |
| "xfce4": "XFCE4 desktop session", |
| "websockify": "noVNC β VNC WebSocket bridge", |
| "dashboard": "Flask stats dashboard", |
| "nginx": "Reverse proxy (port 7860)", |
| } |
| for line in output.strip().splitlines(): |
| if not line.strip(): |
| continue |
| parts = line.split() |
| if len(parts) < 2: |
| continue |
| name = parts[0] |
| state = parts[1] |
| pid = None |
| for p in parts: |
| if p.startswith("pid"): |
| try: |
| pid = parts[parts.index(p) + 1].rstrip(",") |
| except Exception: |
| pass |
| services.append({ |
| "name": name, |
| "state": state, |
| "pid": pid, |
| "description": descriptions.get(name, ""), |
| }) |
| return services |
|
|
|
|
| def _human_uptime(seconds: float) -> str: |
| d = int(seconds // 86400) |
| h = int((seconds % 86400) // 3600) |
| m = int((seconds % 3600) // 60) |
| s = int(seconds % 60) |
| if d: |
| return f"{d}d {h}h {m}m" |
| if h: |
| return f"{h}h {m}m {s}s" |
| return f"{m}m {s}s" |
|
|
|
|
| |
|
|
| @app.route("/dashboard") |
| def dashboard(): |
| return render_template_string(DASHBOARD_HTML) |
|
|
|
|
| @app.route("/health") |
| def health(): |
| return jsonify({"status": "ok", "ts": datetime.datetime.utcnow().isoformat()}) |
|
|
|
|
| @app.route("/api/stats") |
| def api_stats(): |
| cpu = psutil.cpu_percent(interval=0.3) |
| mem = psutil.virtual_memory() |
| try: |
| disk = psutil.disk_usage("/data") |
| disk_data = { |
| "percent": disk.percent, |
| "used_gb": round(disk.used / 1e9, 2), |
| "total_gb": round(disk.total / 1e9, 2), |
| "free_gb": round(disk.free / 1e9, 2), |
| } |
| except Exception: |
| disk_data = {"percent": 0, "used_gb": 0, "total_gb": 0, "free_gb": 0} |
|
|
| uptime_secs = time.time() - START_TIME |
|
|
| return jsonify({ |
| "cpu": { |
| "percent": round(cpu, 1), |
| "count": psutil.cpu_count(), |
| }, |
| "memory": { |
| "percent": mem.percent, |
| "used_gb": round(mem.used / 1e9, 2), |
| "total_gb": round(mem.total / 1e9, 2), |
| "free_gb": round(mem.available / 1e9, 2), |
| }, |
| "disk": disk_data, |
| "uptime": { |
| "seconds": round(uptime_secs), |
| "human": _human_uptime(uptime_secs), |
| }, |
| "services": _supervisor_status(), |
| "ts": datetime.datetime.utcnow().isoformat(), |
| }) |
|
|
|
|
| @app.route("/api/logs/<name>") |
| def api_logs(name: str): |
| |
| allowed = {"supervisord", "xvnc", "xfce4", "websockify", "dashboard", "nginx", |
| "xvnc_err", "xfce4_err", "websockify_err", "dashboard_err", "nginx_err"} |
| if name not in allowed: |
| return jsonify({"error": "Unknown log name"}), 400 |
|
|
| log_path = Path(f"/data/logs/{name}.log") |
| if not log_path.exists(): |
| return jsonify({"content": f"Log file not found: {log_path}"}) |
|
|
| try: |
| |
| lines = log_path.read_text(errors="replace").splitlines() |
| return jsonify({"content": "\n".join(lines[-100:])}) |
| except Exception as e: |
| return jsonify({"error": str(e)}), 500 |
|
|
|
|
| @app.route("/api/restart/<name>", methods=["POST"]) |
| def api_restart(name: str): |
| allowed = {"xvnc", "xfce4", "websockify", "dashboard", "nginx"} |
| if name not in allowed: |
| return jsonify({"error": "Unknown service"}), 400 |
| out = _supervisor_ctl("restart", name) |
| return jsonify({"message": out.strip()}) |
|
|
|
|
| |
| if __name__ == "__main__": |
| app.run(host="127.0.0.1", port=5000, debug=False, threaded=True) |
|
|