Spaces:
Running
Running
| # VVPS Observatory app -- docs/StoxoMDs/Conv_2026-06-07_HF_CPU_Spaces_Exploration.md#space-a-vvps-observatory | |
| """VVPS Observatory β Gradio dashboard + background polling scheduler. | |
| Private HF Space that externally monitors the Stoxopia VPS. | |
| Polls REST endpoints (zero new attack surface), alerts via Telegram, | |
| logs history to HF Dataset. | |
| """ | |
| import threading | |
| import time | |
| import logging | |
| from datetime import datetime, timezone | |
| import gradio as gr | |
| import config | |
| from poller import Poller | |
| from alerts import AlertEngine | |
| from history import HistoryLogger | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # --- Global instances --- | |
| poller = Poller() | |
| alert_engine = AlertEngine() | |
| history_logger = HistoryLogger() | |
| # --- Background polling thread --- | |
| def _polling_loop(): | |
| """Runs in a daemon thread, polls every POLL_INTERVAL_SECONDS.""" | |
| logger.info( | |
| "Polling loop started (interval=%ds, target=%s)", | |
| config.POLL_INTERVAL_SECONDS, | |
| config.VPS_BASE_URL, | |
| ) | |
| while True: | |
| try: | |
| snapshot = poller.poll_once() | |
| # Evaluate alerts | |
| alert_engine.evaluate(snapshot) | |
| alert_engine.evaluate_freshness( | |
| snapshot.freshness, poller.consecutive_failures | |
| ) | |
| # Buffer for history | |
| history_logger.append(snapshot) | |
| history_logger.maybe_flush() | |
| except Exception as e: | |
| logger.error("Polling loop error: %s", e) | |
| time.sleep(config.POLL_INTERVAL_SECONDS) | |
| # Start polling in background | |
| _poll_thread = threading.Thread(target=_polling_loop, daemon=True) | |
| _poll_thread.start() | |
| # --- Gradio Dashboard --- | |
| def _status_tab(): | |
| """Current health status display.""" | |
| snapshot = poller.latest | |
| if snapshot is None: | |
| return "β³ Waiting for first poll...", "", "", "" | |
| ts = datetime.fromtimestamp(snapshot.timestamp, tz=timezone.utc) | |
| time_str = ts.strftime("%Y-%m-%d %H:%M:%S UTC") | |
| # Status indicator | |
| status_map = { | |
| "healthy": "π’ HEALTHY", | |
| "degraded": "π‘ DEGRADED", | |
| "unreachable": "π΄ UNREACHABLE", | |
| } | |
| status_display = status_map.get(snapshot.health_status, "βͺ UNKNOWN") | |
| # Components table | |
| comp_lines = [] | |
| for comp, st in snapshot.components.items(): | |
| icon = "π’" if st == "healthy" else "π΄" | |
| comp_lines.append(f" {icon} {comp}: {st}") | |
| components_str = "\n".join(comp_lines) if comp_lines else " No data yet" | |
| # Latency | |
| latency_str = f" Response time: {snapshot.latency_ms:.0f}ms" | |
| if snapshot.error: | |
| latency_str += f"\n Error: {snapshot.error}" | |
| return ( | |
| f"## {status_display}\n\nLast check: {time_str}", | |
| f"### Components\n```\n{components_str}\n```", | |
| f"### Latency\n```\n{latency_str}\n```", | |
| f"### Active Alerts: {len(alert_engine.active_alerts)}", | |
| ) | |
| def _freshness_tab(): | |
| """Per-section freshness display.""" | |
| snapshot = poller.latest | |
| if snapshot is None or not snapshot.freshness: | |
| return "β³ Waiting for freshness data..." | |
| freshness = snapshot.freshness | |
| sources = freshness.get("sources", []) | |
| if not sources: | |
| return "No freshness sources returned." | |
| lines = [ | |
| "| Source | Status | Last Sync | Records |", | |
| "|--------|--------|-----------|---------|", | |
| ] | |
| for src in sources: | |
| name = src.get("source_name", "unknown") | |
| status = src.get("status", "unknown") | |
| icon = "π’" if status == "healthy" else "π‘" if status == "stale" else "π΄" | |
| last_sync = src.get("last_sync", "never") | |
| records = src.get("records_updated", "?") | |
| lines.append(f"| {icon} {name} | {status} | {last_sync} | {records} |") | |
| return "\n".join(lines) | |
| def _history_tab(): | |
| """7-day uptime and latency summary.""" | |
| history = poller.history | |
| if not history: | |
| return "β³ No history yet (accumulates over time)..." | |
| total = len(history) | |
| healthy = sum(1 for s in history if s.health_status == "healthy") | |
| uptime_pct = (healthy / total * 100) if total > 0 else 0 | |
| latencies = [s.latency_ms for s in history if s.latency_ms > 0] | |
| avg_latency = sum(latencies) / len(latencies) if latencies else 0 | |
| max_latency = max(latencies) if latencies else 0 | |
| unreachable_count = sum(1 for s in history if s.health_status == "unreachable") | |
| hours_covered = (total * config.POLL_INTERVAL_SECONDS) / 3600 | |
| summary = f"""### Uptime Summary ({hours_covered:.1f}h window) | |
| | Metric | Value | | |
| |--------|-------| | |
| | Uptime | {uptime_pct:.1f}% ({healthy}/{total} checks) | | |
| | Avg Latency | {avg_latency:.0f}ms | | |
| | Max Latency | {max_latency:.0f}ms | | |
| | Unreachable Events | {unreachable_count} | | |
| | Total Checks | {total} | | |
| """ | |
| return summary | |
| def _alerts_tab(): | |
| """Recent alert log.""" | |
| log = alert_engine.alert_log | |
| if not log: | |
| return "### No alerts fired yet β " | |
| lines = [ | |
| "### Recent Alerts\n", | |
| "| Time | Condition | Resolved |", | |
| "|------|-----------|----------|", | |
| ] | |
| for alert in reversed(log[-20:]): | |
| ts = datetime.fromtimestamp(alert.fired_at, tz=timezone.utc) | |
| time_str = ts.strftime("%m-%d %H:%M UTC") | |
| resolved = "β " if alert.resolved else "π΄ Active" | |
| lines.append(f"| {time_str} | {alert.condition} | {resolved} |") | |
| return "\n".join(lines) | |
| # --- Build Gradio app --- | |
| with gr.Blocks( | |
| title="VVPS Observatory", | |
| theme=gr.themes.Soft(), | |
| ) as app: | |
| gr.Markdown("# π VVPS Observatory\nExternal health monitor for Stoxopia VPS") | |
| with gr.Tabs(): | |
| with gr.TabItem("Status"): | |
| status_out = gr.Markdown() | |
| components_out = gr.Markdown() | |
| latency_out = gr.Markdown() | |
| alerts_summary = gr.Markdown() | |
| def refresh_status(): | |
| return _status_tab() | |
| app.load( | |
| refresh_status, | |
| outputs=[status_out, components_out, latency_out, alerts_summary], | |
| ) | |
| # Auto-refresh every 60s | |
| refresh_btn = gr.Button("π Refresh", size="sm") | |
| refresh_btn.click( | |
| refresh_status, | |
| outputs=[status_out, components_out, latency_out, alerts_summary], | |
| ) | |
| with gr.TabItem("Freshness"): | |
| freshness_out = gr.Markdown() | |
| def refresh_freshness(): | |
| return _freshness_tab() | |
| app.load(refresh_freshness, outputs=[freshness_out]) | |
| refresh_btn2 = gr.Button("π Refresh", size="sm") | |
| refresh_btn2.click(refresh_freshness, outputs=[freshness_out]) | |
| with gr.TabItem("History"): | |
| history_out = gr.Markdown() | |
| def refresh_history(): | |
| return _history_tab() | |
| app.load(refresh_history, outputs=[history_out]) | |
| refresh_btn3 = gr.Button("π Refresh", size="sm") | |
| refresh_btn3.click(refresh_history, outputs=[history_out]) | |
| with gr.TabItem("Alerts"): | |
| alerts_out = gr.Markdown() | |
| def refresh_alerts(): | |
| return _alerts_tab() | |
| app.load(refresh_alerts, outputs=[alerts_out]) | |
| refresh_btn4 = gr.Button("π Refresh", size="sm") | |
| refresh_btn4.click(refresh_alerts, outputs=[alerts_out]) | |
| gr.Markdown( | |
| f"---\n*Polling every {config.POLL_INTERVAL_SECONDS}s | " | |
| f"Target: {config.VPS_BASE_URL} | " | |
| f"History flush: every {config.HISTORY_WRITE_INTERVAL_SECONDS}s*" | |
| ) | |
| if __name__ == "__main__": | |
| auth = None | |
| if config.DASHBOARD_PASSWORD: | |
| auth = (config.DASHBOARD_USERNAME, config.DASHBOARD_PASSWORD) | |
| # ssr_mode=False required: Gradio 5 SSR health check can't authenticate | |
| app.launch(server_name="0.0.0.0", server_port=7860, auth=auth, ssr_mode=False) | |