Coverage for agentos/dashboard/server.py: 0%
117 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-02 09:59 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-02 09:59 +0800
1"""
2Dashboard HTTP 服务器 — 内嵌单页 HTML,无需外部依赖。
4启动: agentos dashboard
5访问: http://localhost:18500
6"""
8from __future__ import annotations
10import json
11import http.server
12import os
13import queue
14import threading
15import webbrowser
16from pathlib import Path
17from urllib.parse import urlparse, parse_qs
19from agentos.dashboard.tracker import Tracker
21PORT = 18500
23# SSE 事件队列(线程安全,用于实时推送)
24_sse_queues: list[queue.Queue] = []
25_sse_lock = threading.Lock()
27def _sse_broadcast(event_type: str, data: dict):
28 """广播事件到所有 SSE 连接。"""
29 payload = json.dumps({"type": event_type, "data": data}, ensure_ascii=False)
30 with _sse_lock:
31 dead = []
32 for q in _sse_queues:
33 try:
34 q.put_nowait(payload)
35 except queue.Full:
36 dead.append(q)
37 for q in dead:
38 _sse_queues.remove(q)
40# ============================================================
41# 内嵌前端(纯 HTML/CSS/JS,零外部依赖)
42# ============================================================
44DASHBOARD_HTML = r"""<!DOCTYPE html>
45<html lang="zh-CN">
46<head>
47<meta charset="UTF-8">
48<meta name="viewport" content="width=device-width, initial-scale=1.0">
49<title>AgentOS Dashboard — 追踪面板</title>
50<style>
51*{margin:0;padding:0;box-sizing:border-box}
52body{
53 font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
54 background:#0d1117;color:#c9d1d9;min-height:100vh;
55}
56.header{
57 background:#161b22;border-bottom:1px solid #30363d;padding:16px 24px;
58 display:flex;align-items:center;justify-content:space-between;
59}
60.header h1{
61 font-size:20px;font-weight:600;
62 background:linear-gradient(90deg,#58a6ff,#bc8cff);
63 -webkit-background-clip:text;-webkit-text-fill-color:transparent;
64}
65.header .stats{font-size:13px;color:#8b949e}
66.main{padding:20px 24px;max-width:1200px;margin:0 auto}
67.sessions{display:flex;flex-direction:column;gap:12px}
68.session-card{
69 background:#161b22;border:1px solid #30363d;border-radius:8px;
70 padding:16px 20px;cursor:pointer;transition:border-color .15s;
71}
72.session-card:hover{border-color:#58a6ff}
73.session-card .top{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
74.session-card .task{font-size:15px;font-weight:600;color:#e6edf3}
75.session-card .id{font-size:11px;color:#484f58;font-family:monospace}
76.session-card .meta{font-size:12px;color:#8b949e;display:flex;gap:16px}
77.session-card .status{
78 display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;
79}
80.status-completed{background:#1a3a2a;color:#3fb950}
81.status-running{background:#1a2a3a;color:#58a6ff}
82.status-error{background:#3a1a1a;color:#f85149}
83.status-cancelled{background:#3a3a1a;color:#d29922}
85/* 详情面板 */
86.detail-panel{
87 background:#161b22;border:1px solid #30363d;border-radius:8px;margin-top:16px;
88}
89.detail-header{
90 display:flex;justify-content:space-between;align-items:center;
91 padding:12px 20px;border-bottom:1px solid #30363d;
92}
93.detail-header h3{font-size:16px;color:#e6edf3}
94.detail-header button{
95 background:none;border:1px solid #30363d;color:#8b949e;padding:4px 12px;
96 border-radius:6px;cursor:pointer;font-size:12px;
97}
98.detail-header button:hover{color:#e6edf3;border-color:#58a6ff}
100/* 时间线 */
101.timeline{position:relative;padding:16px 20px}
102.timeline::before{
103 content:'';position:absolute;left:26px;top:30px;bottom:30px;
104 width:2px;background:#30363d;
105}
106.step{
107 position:relative;padding:8px 0 8px 48px;display:flex;gap:12px;
108}
109.step-dot{
110 position:absolute;left:20px;top:14px;width:14px;height:14px;
111 border-radius:50%;border:2px solid #30363d;background:#0d1117;z-index:1;
112}
113.step-dot.thinking{border-color:#58a6ff;background:#0d2847}
114.step-dot.tool_call{border-color:#d29922;background:#2a2010}
115.step-dot.tool_result{border-color:#3fb950;background:#102a18}
116.step-dot.final_answer{border-color:#bc8cff;background:#281a3a}
117.step-content{flex:1}
118.step-type{
119 font-size:11px;font-weight:600;text-transform:uppercase;margin-bottom:4px;
120}
121.step-type.thinking{color:#58a6ff}
122.step-type.tool_call{color:#d29922}
123.step-type.tool_result{color:#3fb950}
124.step-type.final_answer{color:#bc8cff}
125.step-detail{font-size:13px;color:#c9d1d9;word-break:break-word}
126.step-meta{font-size:11px;color:#484f58;margin-top:4px}
128/* 统计卡片 */
129.stat-row{display:flex;gap:16px;margin-bottom:20px}
130.stat-card{
131 flex:1;background:#161b22;border:1px solid #30363d;border-radius:8px;
132 padding:16px;text-align:center;
133}
134.stat-card .value{font-size:28px;font-weight:700;color:#e6edf3}
135.stat-card .label{font-size:12px;color:#8b949e;margin-top:4px}
136.stat-card .value.cost{color:#3fb950}
137.stat-card .value.tokens{color:#58a6ff}
139.empty{
140 text-align:center;padding:60px 20px;color:#484f58;
141}
142.empty p{font-size:14px;margin-bottom:8px}
143.empty code{
144 background:#161b22;border:1px solid #30363d;border-radius:4px;
145 padding:2px 8px;font-size:13px;
146}
147.refresh-btn{
148 background:#21262d;border:1px solid #30363d;color:#c9d1d9;
149 padding:6px 16px;border-radius:6px;cursor:pointer;font-size:12px;
150 margin-left:8px;
151}
152.refresh-btn:hover{border-color:#58a6ff}
153</style>
154</head>
155<body>
156<div class="header">
157 <div>
158 <h1>AgentOS Dashboard</h1>
159 <div class="stats" id="stats-bar">正在加载...</div>
160 </div>
161 <button class="refresh-btn" onclick="loadSessions()">刷新</button>
162</div>
163<div class="main">
164 <div class="stat-row" id="stat-cards"></div>
165 <div class="sessions" id="session-list"></div>
166 <div id="detail-area"></div>
167</div>
168<script>
169const API = '/api';
171async function loadSessions() {
172 const resp = await fetch(API + '/sessions');
173 const data = await resp.json();
174 renderStats(data.sessions || []);
175 renderSessions(data.sessions || []);
176}
178function renderStats(sessions) {
179 const total = sessions.length;
180 const completed = sessions.filter(s => s.status === 'completed').length;
181 let totalTokens = 0, totalCost = 0;
182 sessions.forEach(s => { totalTokens += s.total_tokens || 0; totalCost += s.total_cost_usd || 0; });
183 document.getElementById('stats-bar').textContent =
184 `${total} 次运行 · ${completed} 完成`;
185 document.getElementById('stat-cards').innerHTML =
186 `<div class="stat-card"><div class="value">${total}</div><div class="label">总运行次数</div></div>
187 <div class="stat-card"><div class="value">${completed}</div><div class="label">成功完成</div></div>
188 <div class="stat-card"><div class="value tokens">${(totalTokens/1000).toFixed(1)}K</div><div class="label">Token 消耗</div></div>
189 <div class="stat-card"><div class="value cost">$${totalCost.toFixed(4)}</div><div class="label">总成本</div></div>`;
190}
192function renderSessions(sessions) {
193 const el = document.getElementById('session-list');
194 if (!sessions.length) {
195 el.innerHTML = `<div class="empty">
196 <p>尚无运行记录</p>
197 <p>运行 <code>agentos "你的任务"</code> 后会自动记录到这里</p>
198 </div>`;
199 return;
200 }
201 el.innerHTML = sessions.map(s => {
202 const dur = s.finished_at ? ((s.finished_at - s.started_at) / 1000).toFixed(1) + 's' : '进行中';
203 const ts = new Date(s.started_at * 1000).toLocaleString('zh-CN');
204 const statusClass = 'status-' + (s.status || 'running');
205 const statusLabel = {completed:'已完成',running:'运行中',error:'错误',cancelled:'已取消'}[s.status] || s.status;
206 return `<div class="session-card" onclick="showDetail('${s.session_id}')">
207 <div class="top">
208 <span class="task">${esc(s.task)}</span>
209 <span class="id">${s.session_id.slice(0,12)}</span>
210 </div>
211 <div class="meta">
212 <span>${dur}</span>
213 <span>${s.total_tokens || 0} tokens</span>
214 <span>$${(s.total_cost_usd || 0).toFixed(4)}</span>
215 <span>${ts}</span>
216 <span>${s.model || s.provider || ''}</span>
217 <span class="status ${statusClass}">${statusLabel}</span>
218 </div>
219 </div>`;
220 }).join('');
221}
223async function showDetail(sid) {
224 const resp = await fetch(API + '/sessions/' + sid);
225 const s = await resp.json();
226 if (!s) return;
227 const dur = s.finished_at ? ((s.finished_at - s.started_at) / 1000).toFixed(1) + 's' : '进行中';
228 const steps = s.steps || [];
229 const timeline = steps.length ? steps.map(st => {
230 const dotClass = st.step_type || '';
231 return `<div class="step">
232 <div class="step-dot ${dotClass}"></div>
233 <div class="step-content">
234 <div class="step-type ${dotClass}">${st.step_type}</div>
235 <div class="step-detail">${esc(st.detail)}</div>
236 <div class="step-meta">#${st.step_index} · ${(st.duration_ms||0).toFixed(0)}ms · ${st.tokens||0} tokens</div>
237 </div>
238 </div>`;
239 }).join('') : '<div class="empty"><p>无步骤记录</p></div>';
241 document.getElementById('detail-area').innerHTML = `
242 <div class="detail-panel">
243 <div class="detail-header">
244 <h3>${esc(s.task)}</h3>
245 <button onclick="document.getElementById('detail-area').innerHTML=''">关闭</button>
246 </div>
247 <div class="stat-row" style="padding:16px 20px 0">
248 <div class="stat-card"><div class="value">${dur}</div><div class="label">耗时</div></div>
249 <div class="stat-card"><div class="value tokens">${s.total_tokens||0}</div><div class="label">Tokens</div></div>
250 <div class="stat-card"><div class="value cost">$${(s.total_cost_usd||0).toFixed(4)}</div><div class="label">成本</div></div>
251 <div class="stat-card"><div class="value">${steps.length}</div><div class="label">步骤数</div></div>
252 </div>
253 <div class="timeline">${timeline}</div>
254 </div>`;
255}
257function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
258loadSessions();
260// SSE 实时推送
261var es = new EventSource('/api/events');
262es.addEventListener('connected', function(e) { console.log('[dashboard] SSE connected'); });
263es.addEventListener('message', function(e) {
264 try {
265 var msg = JSON.parse(e.data);
266 if (msg.type === 'step' || msg.type === 'session_done') {
267 loadSessions();
268 }
269 } catch (_) {}
270});
271es.onerror = function() { console.log('[dashboard] SSE disconnected, retrying...'); };
272</script>
273</body>
274</html>"""
276# ============================================================
277# HTTP Handler
278# ============================================================
280class DashboardHandler(http.server.BaseHTTPRequestHandler):
281 """Dashboard HTTP 请求处理器。"""
283 def log_message(self, format, *args):
284 pass # 静默日志
286 def do_GET(self):
287 parsed = urlparse(self.path)
288 path = parsed.path.rstrip("/") or "/"
290 # 主页
291 if path == "/":
292 self._html(200, DASHBOARD_HTML)
293 return
295 # API: 列出所有会话
296 if path == "/api/sessions":
297 tracker = Tracker.get()
298 sessions = tracker.list_sessions()
299 self._json({"sessions": sessions})
300 return
302 # API: 单个会话详情
303 if path.startswith("/api/sessions/"):
304 sid = path.split("/api/sessions/")[-1]
305 tracker = Tracker.get()
306 session = tracker.get_session(sid)
307 if session is None:
308 self._json({"error": "not found"}, 404)
309 else:
310 self._json(session)
311 return
313 # API: SSE 实时事件流
314 if path == "/api/events":
315 self._handle_sse()
316 return
318 # API: 健康检查
319 if path == "/api/health":
320 tracker = Tracker.get()
321 self._json({
322 "status": "ok",
323 "active_sessions": len(tracker._active),
324 "sse_clients": len(_sse_queues),
325 })
326 return
328 self._html(404, "<h1>404</h1>")
330 def _json(self, data, status=200):
331 body = json.dumps(data, ensure_ascii=False).encode("utf-8")
332 self.send_response(status)
333 self.send_header("Content-Type", "application/json; charset=utf-8")
334 self.send_header("Content-Length", str(len(body)))
335 self.send_header("Access-Control-Allow-Origin", "*")
336 self.end_headers()
337 self.wfile.write(body)
339 def _html(self, status, content):
340 body = content.encode("utf-8")
341 self.send_response(status)
342 self.send_header("Content-Type", "text/html; charset=utf-8")
343 self.send_header("Content-Length", str(len(body)))
344 self.end_headers()
345 self.wfile.write(body)
347 def _handle_sse(self):
348 """SSE (Server-Sent Events) 实时推送。"""
349 self.send_response(200)
350 self.send_header("Content-Type", "text/event-stream")
351 self.send_header("Cache-Control", "no-cache")
352 self.send_header("Connection", "keep-alive")
353 self.send_header("Access-Control-Allow-Origin", "*")
354 self.end_headers()
356 q: queue.Queue = queue.Queue(maxsize=256)
357 with _sse_lock:
358 _sse_queues.append(q)
360 try:
361 # 发送初始连接事件
362 self.wfile.write(b"event: connected\ndata: {}\n\n")
363 self.wfile.flush()
364 while True:
365 try:
366 payload = q.get(timeout=30) # 30s 心跳
367 self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
368 self.wfile.flush()
369 except queue.Empty:
370 # 心跳保活
371 self.wfile.write(b": heartbeat\n\n")
372 self.wfile.flush()
373 except (BrokenPipeError, ConnectionResetError, OSError):
374 pass
375 finally:
376 with _sse_lock:
377 if q in _sse_queues:
378 _sse_queues.remove(q)
381class DashboardServer:
382 """Dashboard HTTP 服务器。"""
384 def __init__(self, port: int = PORT):
385 self.port = port
386 self._server: http.server.HTTPServer | None = None
388 def start(self, open_browser: bool = True):
389 self._server = http.server.HTTPServer(("0.0.0.0", self.port), DashboardHandler)
390 # 注册 Tracker → SSE 桥接
391 Tracker.get().subscribe(_sse_broadcast)
392 url = f"http://localhost:{self.port}"
393 print(f"AgentOS Dashboard → {url}")
394 if open_browser:
395 try:
396 webbrowser.open(url)
397 except Exception:
398 pass
399 try:
400 self._server.serve_forever()
401 except KeyboardInterrupt:
402 print("\nDashboard stopped.")
405def start_dashboard(port: int = PORT, open_browser: bool = True):
406 """便捷启动函数。"""
407 srv = DashboardServer(port=port)
408 srv.start(open_browser=open_browser)