openclaw-dashboard 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dashboard.py ADDED
@@ -0,0 +1,2517 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ OpenClaw Dashboard โ€” See your agent think ๐Ÿฆž
4
+
5
+ Real-time observability dashboard for OpenClaw/Moltbot AI agents.
6
+ Single-file Flask app with zero config โ€” auto-detects your setup.
7
+
8
+ Usage:
9
+ openclaw-dashboard # Auto-detect everything
10
+ openclaw-dashboard --port 9000 # Custom port
11
+ openclaw-dashboard --workspace ~/bot # Custom workspace
12
+ OPENCLAW_HOME=~/bot openclaw-dashboard
13
+
14
+ https://github.com/vivekchand/openclaw-dashboard
15
+ MIT License โ€” Built by Vivek Chand
16
+ """
17
+
18
+ import os
19
+ import sys
20
+ import glob
21
+ import json
22
+ import socket
23
+ import argparse
24
+ import subprocess
25
+ import time
26
+ import threading
27
+ from datetime import datetime, timezone, timedelta
28
+ from flask import Flask, render_template_string, request, jsonify, Response
29
+
30
+ # Optional: OpenTelemetry protobuf support for OTLP receiver
31
+ _HAS_OTEL_PROTO = False
32
+ try:
33
+ from opentelemetry.proto.collector.metrics.v1 import metrics_service_pb2
34
+ from opentelemetry.proto.collector.traces.v1 import trace_service_pb2
35
+ _HAS_OTEL_PROTO = True
36
+ except ImportError:
37
+ metrics_service_pb2 = None
38
+ trace_service_pb2 = None
39
+
40
+ __version__ = "0.2.0"
41
+
42
+ app = Flask(__name__)
43
+
44
+ # โ”€โ”€ Configuration (auto-detected, overridable via CLI/env) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
45
+ WORKSPACE = None
46
+ MEMORY_DIR = None
47
+ LOG_DIR = None
48
+ SESSIONS_DIR = None
49
+ USER_NAME = None
50
+ CET = timezone(timedelta(hours=1))
51
+
52
+ # โ”€โ”€ OTLP Metrics Store โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
53
+ METRICS_FILE = None # Set via CLI/env, defaults to {WORKSPACE}/.openclaw-dashboard-metrics.json
54
+ _metrics_lock = threading.Lock()
55
+ _otel_last_received = 0 # timestamp of last OTLP data received
56
+
57
+ metrics_store = {
58
+ "tokens": [], # [{timestamp, input, output, total, model, channel, provider}]
59
+ "cost": [], # [{timestamp, usd, model, channel, provider}]
60
+ "runs": [], # [{timestamp, duration_ms, model, channel}]
61
+ "messages": [], # [{timestamp, channel, outcome, duration_ms}]
62
+ "webhooks": [], # [{timestamp, channel, type}]
63
+ }
64
+ MAX_STORE_ENTRIES = 10_000
65
+ STORE_RETENTION_DAYS = 14
66
+
67
+
68
+ def _metrics_file_path():
69
+ """Get the path to the metrics persistence file."""
70
+ if METRICS_FILE:
71
+ return METRICS_FILE
72
+ if WORKSPACE:
73
+ return os.path.join(WORKSPACE, '.openclaw-dashboard-metrics.json')
74
+ return os.path.expanduser('~/.openclaw-dashboard-metrics.json')
75
+
76
+
77
+ def _load_metrics_from_disk():
78
+ """Load persisted metrics on startup."""
79
+ global metrics_store, _otel_last_received
80
+ path = _metrics_file_path()
81
+ if not os.path.exists(path):
82
+ return
83
+ try:
84
+ with open(path, 'r') as f:
85
+ data = json.load(f)
86
+ if isinstance(data, dict):
87
+ for key in metrics_store:
88
+ if key in data and isinstance(data[key], list):
89
+ metrics_store[key] = data[key][-MAX_STORE_ENTRIES:]
90
+ _otel_last_received = data.get('_last_received', 0)
91
+ _expire_old_entries()
92
+ except Exception:
93
+ pass
94
+
95
+
96
+ def _save_metrics_to_disk():
97
+ """Persist metrics store to JSON file."""
98
+ path = _metrics_file_path()
99
+ try:
100
+ d = os.path.dirname(path)
101
+ if d:
102
+ os.makedirs(d, exist_ok=True)
103
+ data = {}
104
+ with _metrics_lock:
105
+ for k in metrics_store:
106
+ data[k] = list(metrics_store[k])
107
+ data['_last_received'] = _otel_last_received
108
+ data['_saved_at'] = time.time()
109
+ tmp = path + '.tmp'
110
+ with open(tmp, 'w') as f:
111
+ json.dump(data, f)
112
+ os.replace(tmp, path)
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ def _expire_old_entries():
118
+ """Remove entries older than STORE_RETENTION_DAYS."""
119
+ cutoff = time.time() - (STORE_RETENTION_DAYS * 86400)
120
+ with _metrics_lock:
121
+ for key in metrics_store:
122
+ metrics_store[key] = [
123
+ e for e in metrics_store[key]
124
+ if e.get('timestamp', 0) > cutoff
125
+ ][-MAX_STORE_ENTRIES:]
126
+
127
+
128
+ def _add_metric(category, entry):
129
+ """Add an entry to the metrics store (thread-safe)."""
130
+ global _otel_last_received
131
+ with _metrics_lock:
132
+ metrics_store[category].append(entry)
133
+ if len(metrics_store[category]) > MAX_STORE_ENTRIES:
134
+ metrics_store[category] = metrics_store[category][-MAX_STORE_ENTRIES:]
135
+ _otel_last_received = time.time()
136
+
137
+
138
+ def _metrics_flush_loop():
139
+ """Background thread: save metrics to disk every 60 seconds."""
140
+ while True:
141
+ time.sleep(60)
142
+ try:
143
+ _expire_old_entries()
144
+ _save_metrics_to_disk()
145
+ except Exception:
146
+ pass
147
+
148
+
149
+ def _start_metrics_flush_thread():
150
+ """Start the background metrics flush thread."""
151
+ t = threading.Thread(target=_metrics_flush_loop, daemon=True)
152
+ t.start()
153
+
154
+
155
+ def _has_otel_data():
156
+ """Check if we have any OTLP metrics data."""
157
+ return any(len(metrics_store[k]) > 0 for k in metrics_store)
158
+
159
+
160
+ # โ”€โ”€ OTLP Protobuf Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
161
+
162
+ def _otel_attr_value(val):
163
+ """Convert an OTel AnyValue to a Python value."""
164
+ if val.HasField('string_value'):
165
+ return val.string_value
166
+ if val.HasField('int_value'):
167
+ return val.int_value
168
+ if val.HasField('double_value'):
169
+ return val.double_value
170
+ if val.HasField('bool_value'):
171
+ return val.bool_value
172
+ return str(val)
173
+
174
+
175
+ def _get_data_points(metric):
176
+ """Extract data points from a metric regardless of type."""
177
+ if metric.HasField('sum'):
178
+ return metric.sum.data_points
179
+ elif metric.HasField('gauge'):
180
+ return metric.gauge.data_points
181
+ elif metric.HasField('histogram'):
182
+ return metric.histogram.data_points
183
+ elif metric.HasField('summary'):
184
+ return metric.summary.data_points
185
+ return []
186
+
187
+
188
+ def _get_dp_value(dp):
189
+ """Extract the numeric value from a data point."""
190
+ if hasattr(dp, 'as_double') and dp.as_double:
191
+ return dp.as_double
192
+ if hasattr(dp, 'as_int') and dp.as_int:
193
+ return dp.as_int
194
+ if hasattr(dp, 'sum') and dp.sum:
195
+ return dp.sum
196
+ if hasattr(dp, 'count') and dp.count:
197
+ return dp.count
198
+ return 0
199
+
200
+
201
+ def _get_dp_attrs(dp):
202
+ """Extract attributes from a data point."""
203
+ attrs = {}
204
+ for attr in dp.attributes:
205
+ attrs[attr.key] = _otel_attr_value(attr.value)
206
+ return attrs
207
+
208
+
209
+ def _process_otlp_metrics(pb_data):
210
+ """Decode OTLP metrics protobuf and store relevant data."""
211
+ req = metrics_service_pb2.ExportMetricsServiceRequest()
212
+ req.ParseFromString(pb_data)
213
+
214
+ for resource_metrics in req.resource_metrics:
215
+ resource_attrs = {}
216
+ if resource_metrics.resource:
217
+ for attr in resource_metrics.resource.attributes:
218
+ resource_attrs[attr.key] = _otel_attr_value(attr.value)
219
+
220
+ for scope_metrics in resource_metrics.scope_metrics:
221
+ for metric in scope_metrics.metrics:
222
+ name = metric.name
223
+ ts = time.time()
224
+
225
+ if name == 'openclaw.tokens':
226
+ for dp in _get_data_points(metric):
227
+ attrs = _get_dp_attrs(dp)
228
+ _add_metric('tokens', {
229
+ 'timestamp': ts,
230
+ 'input': attrs.get('input_tokens', 0),
231
+ 'output': attrs.get('output_tokens', 0),
232
+ 'total': _get_dp_value(dp),
233
+ 'model': attrs.get('model', resource_attrs.get('model', '')),
234
+ 'channel': attrs.get('channel', resource_attrs.get('channel', '')),
235
+ 'provider': attrs.get('provider', resource_attrs.get('provider', '')),
236
+ })
237
+ elif name == 'openclaw.cost.usd':
238
+ for dp in _get_data_points(metric):
239
+ attrs = _get_dp_attrs(dp)
240
+ _add_metric('cost', {
241
+ 'timestamp': ts,
242
+ 'usd': _get_dp_value(dp),
243
+ 'model': attrs.get('model', resource_attrs.get('model', '')),
244
+ 'channel': attrs.get('channel', resource_attrs.get('channel', '')),
245
+ 'provider': attrs.get('provider', resource_attrs.get('provider', '')),
246
+ })
247
+ elif name == 'openclaw.run.duration_ms':
248
+ for dp in _get_data_points(metric):
249
+ attrs = _get_dp_attrs(dp)
250
+ _add_metric('runs', {
251
+ 'timestamp': ts,
252
+ 'duration_ms': _get_dp_value(dp),
253
+ 'model': attrs.get('model', resource_attrs.get('model', '')),
254
+ 'channel': attrs.get('channel', resource_attrs.get('channel', '')),
255
+ })
256
+ elif name == 'openclaw.context.tokens':
257
+ for dp in _get_data_points(metric):
258
+ attrs = _get_dp_attrs(dp)
259
+ _add_metric('tokens', {
260
+ 'timestamp': ts,
261
+ 'input': _get_dp_value(dp),
262
+ 'output': 0,
263
+ 'total': _get_dp_value(dp),
264
+ 'model': attrs.get('model', resource_attrs.get('model', '')),
265
+ 'channel': attrs.get('channel', resource_attrs.get('channel', '')),
266
+ 'provider': attrs.get('provider', resource_attrs.get('provider', '')),
267
+ })
268
+ elif name in ('openclaw.message.processed', 'openclaw.message.queued', 'openclaw.message.duration_ms'):
269
+ for dp in _get_data_points(metric):
270
+ attrs = _get_dp_attrs(dp)
271
+ outcome = 'processed' if 'processed' in name else ('queued' if 'queued' in name else 'duration')
272
+ _add_metric('messages', {
273
+ 'timestamp': ts,
274
+ 'channel': attrs.get('channel', resource_attrs.get('channel', '')),
275
+ 'outcome': outcome,
276
+ 'duration_ms': _get_dp_value(dp) if 'duration' in name else 0,
277
+ })
278
+ elif name in ('openclaw.webhook.received', 'openclaw.webhook.error', 'openclaw.webhook.duration_ms'):
279
+ for dp in _get_data_points(metric):
280
+ attrs = _get_dp_attrs(dp)
281
+ wtype = 'received' if 'received' in name else ('error' if 'error' in name else 'duration')
282
+ _add_metric('webhooks', {
283
+ 'timestamp': ts,
284
+ 'channel': attrs.get('channel', resource_attrs.get('channel', '')),
285
+ 'type': wtype,
286
+ })
287
+
288
+
289
+ def _process_otlp_traces(pb_data):
290
+ """Decode OTLP traces protobuf and extract relevant span data."""
291
+ req = trace_service_pb2.ExportTraceServiceRequest()
292
+ req.ParseFromString(pb_data)
293
+
294
+ for resource_spans in req.resource_spans:
295
+ resource_attrs = {}
296
+ if resource_spans.resource:
297
+ for attr in resource_spans.resource.attributes:
298
+ resource_attrs[attr.key] = _otel_attr_value(attr.value)
299
+
300
+ for scope_spans in resource_spans.scope_spans:
301
+ for span in scope_spans.spans:
302
+ attrs = {}
303
+ for attr in span.attributes:
304
+ attrs[attr.key] = _otel_attr_value(attr.value)
305
+
306
+ ts = time.time()
307
+ duration_ns = span.end_time_unix_nano - span.start_time_unix_nano
308
+ duration_ms = duration_ns / 1_000_000
309
+
310
+ span_name = span.name.lower()
311
+ if 'run' in span_name or 'completion' in span_name:
312
+ _add_metric('runs', {
313
+ 'timestamp': ts,
314
+ 'duration_ms': duration_ms,
315
+ 'model': attrs.get('model', resource_attrs.get('model', '')),
316
+ 'channel': attrs.get('channel', resource_attrs.get('channel', '')),
317
+ })
318
+ elif 'message' in span_name:
319
+ _add_metric('messages', {
320
+ 'timestamp': ts,
321
+ 'channel': attrs.get('channel', resource_attrs.get('channel', '')),
322
+ 'outcome': 'processed',
323
+ 'duration_ms': duration_ms,
324
+ })
325
+
326
+
327
+ def _get_otel_usage_data():
328
+ """Aggregate OTLP metrics into usage data for the Usage tab."""
329
+ today = datetime.now()
330
+ today_start = today.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
331
+ week_start = (today - timedelta(days=today.weekday())).replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
332
+ month_start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0).timestamp()
333
+
334
+ daily_tokens = {}
335
+ daily_cost = {}
336
+ model_usage = {}
337
+
338
+ with _metrics_lock:
339
+ for entry in metrics_store['tokens']:
340
+ ts = entry.get('timestamp', 0)
341
+ day = datetime.fromtimestamp(ts).strftime('%Y-%m-%d')
342
+ total = entry.get('total', 0)
343
+ daily_tokens[day] = daily_tokens.get(day, 0) + total
344
+ model = entry.get('model', 'unknown') or 'unknown'
345
+ model_usage[model] = model_usage.get(model, 0) + total
346
+
347
+ for entry in metrics_store['cost']:
348
+ ts = entry.get('timestamp', 0)
349
+ day = datetime.fromtimestamp(ts).strftime('%Y-%m-%d')
350
+ daily_cost[day] = daily_cost.get(day, 0) + entry.get('usd', 0)
351
+
352
+ days = []
353
+ for i in range(13, -1, -1):
354
+ d = today - timedelta(days=i)
355
+ ds = d.strftime('%Y-%m-%d')
356
+ days.append({
357
+ 'date': ds,
358
+ 'tokens': daily_tokens.get(ds, 0),
359
+ 'cost': daily_cost.get(ds, 0),
360
+ })
361
+
362
+ today_str = today.strftime('%Y-%m-%d')
363
+ today_tok = daily_tokens.get(today_str, 0)
364
+ week_tok = sum(v for k, v in daily_tokens.items()
365
+ if _safe_date_ts(k) >= week_start)
366
+ month_tok = sum(v for k, v in daily_tokens.items()
367
+ if _safe_date_ts(k) >= month_start)
368
+ today_cost_val = daily_cost.get(today_str, 0)
369
+ week_cost_val = sum(v for k, v in daily_cost.items()
370
+ if _safe_date_ts(k) >= week_start)
371
+ month_cost_val = sum(v for k, v in daily_cost.items()
372
+ if _safe_date_ts(k) >= month_start)
373
+
374
+ run_durations = []
375
+ with _metrics_lock:
376
+ for entry in metrics_store['runs']:
377
+ run_durations.append(entry.get('duration_ms', 0))
378
+ avg_run_ms = sum(run_durations) / len(run_durations) if run_durations else 0
379
+
380
+ msg_count = len(metrics_store['messages'])
381
+
382
+ return {
383
+ 'source': 'otlp',
384
+ 'days': days,
385
+ 'today': today_tok,
386
+ 'week': week_tok,
387
+ 'month': month_tok,
388
+ 'todayCost': round(today_cost_val, 4),
389
+ 'weekCost': round(week_cost_val, 4),
390
+ 'monthCost': round(month_cost_val, 4),
391
+ 'avgRunMs': round(avg_run_ms, 1),
392
+ 'messageCount': msg_count,
393
+ 'modelBreakdown': [
394
+ {'model': k, 'tokens': v}
395
+ for k, v in sorted(model_usage.items(), key=lambda x: -x[1])
396
+ ],
397
+ }
398
+
399
+
400
+ def _safe_date_ts(date_str):
401
+ """Parse a YYYY-MM-DD date string to a timestamp, returning 0 on failure."""
402
+ try:
403
+ return datetime.strptime(date_str, '%Y-%m-%d').timestamp()
404
+ except Exception:
405
+ return 0
406
+
407
+
408
+ def detect_config(args=None):
409
+ """Auto-detect OpenClaw/Moltbot paths, with CLI and env overrides."""
410
+ global WORKSPACE, MEMORY_DIR, LOG_DIR, SESSIONS_DIR, USER_NAME
411
+
412
+ # 1. Workspace โ€” where agent files live (SOUL.md, MEMORY.md, memory/, etc.)
413
+ if args and args.workspace:
414
+ WORKSPACE = os.path.expanduser(args.workspace)
415
+ elif os.environ.get("OPENCLAW_HOME"):
416
+ WORKSPACE = os.path.expanduser(os.environ["OPENCLAW_HOME"])
417
+ elif os.environ.get("OPENCLAW_WORKSPACE"):
418
+ WORKSPACE = os.path.expanduser(os.environ["OPENCLAW_WORKSPACE"])
419
+ else:
420
+ # Auto-detect: check common locations
421
+ candidates = [
422
+ _detect_workspace_from_config(),
423
+ os.path.expanduser("~/.clawdbot/workspace"),
424
+ os.path.expanduser("~/clawd"),
425
+ os.path.expanduser("~/openclaw"),
426
+ os.getcwd(),
427
+ ]
428
+ for c in candidates:
429
+ if c and os.path.isdir(c) and (
430
+ os.path.exists(os.path.join(c, "SOUL.md")) or
431
+ os.path.exists(os.path.join(c, "AGENTS.md")) or
432
+ os.path.exists(os.path.join(c, "MEMORY.md")) or
433
+ os.path.isdir(os.path.join(c, "memory"))
434
+ ):
435
+ WORKSPACE = c
436
+ break
437
+ if not WORKSPACE:
438
+ WORKSPACE = os.getcwd()
439
+
440
+ MEMORY_DIR = os.path.join(WORKSPACE, "memory")
441
+
442
+ # 2. Log directory
443
+ if args and args.log_dir:
444
+ LOG_DIR = os.path.expanduser(args.log_dir)
445
+ elif os.environ.get("OPENCLAW_LOG_DIR"):
446
+ LOG_DIR = os.path.expanduser(os.environ["OPENCLAW_LOG_DIR"])
447
+ else:
448
+ candidates = ["/tmp/moltbot", "/tmp/openclaw", os.path.expanduser("~/.clawdbot/logs")]
449
+ LOG_DIR = next((d for d in candidates if os.path.isdir(d)), "/tmp/moltbot")
450
+
451
+ # 3. Sessions directory (transcript .jsonl files)
452
+ if args and getattr(args, 'sessions_dir', None):
453
+ SESSIONS_DIR = os.path.expanduser(args.sessions_dir)
454
+ elif os.environ.get("OPENCLAW_SESSIONS_DIR"):
455
+ SESSIONS_DIR = os.path.expanduser(os.environ["OPENCLAW_SESSIONS_DIR"])
456
+ else:
457
+ candidates = [
458
+ os.path.expanduser('~/.clawdbot/agents/main/sessions'),
459
+ os.path.join(WORKSPACE, 'sessions') if WORKSPACE else None,
460
+ os.path.expanduser('~/.clawdbot/sessions'),
461
+ ]
462
+ # Also scan ~/.clawdbot/agents/*/sessions/
463
+ agents_base = os.path.expanduser('~/.clawdbot/agents')
464
+ if os.path.isdir(agents_base):
465
+ for agent in os.listdir(agents_base):
466
+ p = os.path.join(agents_base, agent, 'sessions')
467
+ if p not in candidates:
468
+ candidates.append(p)
469
+ SESSIONS_DIR = next((d for d in candidates if d and os.path.isdir(d)), candidates[0] if candidates else None)
470
+
471
+ # 4. User name (shown in Flow visualization)
472
+ if args and args.name:
473
+ USER_NAME = args.name
474
+ elif os.environ.get("OPENCLAW_USER"):
475
+ USER_NAME = os.environ["OPENCLAW_USER"]
476
+ else:
477
+ USER_NAME = "You"
478
+
479
+
480
+ def _detect_workspace_from_config():
481
+ """Try to read workspace from Moltbot/OpenClaw agent config."""
482
+ config_paths = [
483
+ os.path.expanduser("~/.clawdbot/agents/main/config.json"),
484
+ os.path.expanduser("~/.clawdbot/config.json"),
485
+ ]
486
+ for cp in config_paths:
487
+ try:
488
+ with open(cp) as f:
489
+ data = json.load(f)
490
+ ws = data.get("workspace") or data.get("workspaceDir")
491
+ if ws:
492
+ return os.path.expanduser(ws)
493
+ except (FileNotFoundError, json.JSONDecodeError, KeyError):
494
+ pass
495
+ return None
496
+
497
+
498
+ def get_local_ip():
499
+ """Get the machine's LAN IP address."""
500
+ try:
501
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
502
+ s.connect(("8.8.8.8", 80))
503
+ ip = s.getsockname()[0]
504
+ s.close()
505
+ return ip
506
+ except Exception:
507
+ return "127.0.0.1"
508
+
509
+
510
+ # โ”€โ”€ HTML Template โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
511
+
512
+ DASHBOARD_HTML = r"""
513
+ <!DOCTYPE html>
514
+ <html lang="en">
515
+ <head>
516
+ <meta charset="UTF-8">
517
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
518
+ <title>OpenClaw Dashboard ๐Ÿฆž</title>
519
+ <style>
520
+ * { box-sizing: border-box; margin: 0; padding: 0; }
521
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a14; color: #e0e0e0; min-height: 100vh; }
522
+
523
+ .nav { background: #12122a; border-bottom: 1px solid #2a2a4a; padding: 12px 20px; display: flex; align-items: center; gap: 16px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
524
+ .nav h1 { font-size: 20px; color: #fff; white-space: nowrap; }
525
+ .nav h1 span { color: #f0c040; }
526
+ .nav-tabs { display: flex; gap: 4px; margin-left: auto; }
527
+ .nav-tab { padding: 8px 16px; border-radius: 8px; background: transparent; border: 1px solid #2a2a4a; color: #888; cursor: pointer; font-size: 13px; font-weight: 600; white-space: nowrap; transition: all 0.15s; }
528
+ .nav-tab:hover { background: #1a1a35; color: #ccc; }
529
+ .nav-tab.active { background: #f0c040; color: #000; border-color: #f0c040; }
530
+
531
+ .page { display: none; padding: 16px; max-width: 1200px; margin: 0 auto; }
532
+ .page.active { display: block; }
533
+
534
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; margin-bottom: 16px; }
535
+ .card { background: #141428; border: 1px solid #2a2a4a; border-radius: 12px; padding: 16px; }
536
+ .card-title { font-size: 12px; text-transform: uppercase; color: #666; letter-spacing: 1px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
537
+ .card-title .icon { font-size: 16px; }
538
+ .card-value { font-size: 28px; font-weight: 700; color: #fff; }
539
+ .card-sub { font-size: 12px; color: #555; margin-top: 4px; }
540
+
541
+ .stat-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #1a1a30; }
542
+ .stat-row:last-child { border-bottom: none; }
543
+ .stat-label { color: #888; font-size: 13px; }
544
+ .stat-val { color: #fff; font-size: 13px; font-weight: 600; }
545
+ .stat-val.green { color: #27ae60; }
546
+ .stat-val.yellow { color: #f0c040; }
547
+ .stat-val.red { color: #e74c3c; }
548
+
549
+ .session-item { padding: 12px; border-bottom: 1px solid #1a1a30; }
550
+ .session-item:last-child { border-bottom: none; }
551
+ .session-name { font-weight: 600; font-size: 14px; color: #fff; }
552
+ .session-meta { font-size: 12px; color: #666; margin-top: 4px; display: flex; gap: 12px; flex-wrap: wrap; }
553
+ .session-meta span { display: flex; align-items: center; gap: 4px; }
554
+
555
+ .cron-item { padding: 12px; border-bottom: 1px solid #1a1a30; }
556
+ .cron-item:last-child { border-bottom: none; }
557
+ .cron-name { font-weight: 600; font-size: 14px; color: #fff; }
558
+ .cron-schedule { font-size: 12px; color: #f0c040; margin-top: 2px; font-family: monospace; }
559
+ .cron-meta { font-size: 12px; color: #666; margin-top: 4px; }
560
+ .cron-status { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
561
+ .cron-status.ok { background: #1a3a2a; color: #27ae60; }
562
+ .cron-status.error { background: #3a1a1a; color: #e74c3c; }
563
+ .cron-status.pending { background: #2a2a1a; color: #f0c040; }
564
+
565
+ .log-viewer { background: #0a0a14; border: 1px solid #2a2a4a; border-radius: 8px; font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.6; padding: 12px; max-height: 500px; overflow-y: auto; -webkit-overflow-scrolling: touch; white-space: pre-wrap; word-break: break-all; }
566
+ .log-line { padding: 1px 0; }
567
+ .log-line .ts { color: #666; }
568
+ .log-line .info { color: #60a0ff; }
569
+ .log-line .warn { color: #f0c040; }
570
+ .log-line .err { color: #e74c3c; }
571
+ .log-line .msg { color: #ccc; }
572
+
573
+ .memory-item { padding: 10px 12px; border-bottom: 1px solid #1a1a30; display: flex; justify-content: space-between; align-items: center; cursor: pointer; transition: background 0.15s; }
574
+ .memory-item:hover { background: #1a1a35; }
575
+ .memory-item:last-child { border-bottom: none; }
576
+ .file-viewer { background: #0d0d1a; border: 1px solid #2a2a4a; border-radius: 12px; padding: 16px; margin-top: 16px; display: none; }
577
+ .file-viewer-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
578
+ .file-viewer-title { font-size: 14px; font-weight: 600; color: #f0c040; }
579
+ .file-viewer-close { background: #2a2a4a; border: none; color: #ccc; padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }
580
+ .file-viewer-close:hover { background: #3a3a5a; }
581
+ .file-viewer-content { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; color: #ccc; white-space: pre-wrap; word-break: break-word; max-height: 60vh; overflow-y: auto; line-height: 1.5; }
582
+ .memory-name { font-weight: 600; font-size: 14px; color: #60a0ff; cursor: pointer; }
583
+ .memory-name:hover { text-decoration: underline; }
584
+ .memory-size { font-size: 12px; color: #555; }
585
+
586
+ .refresh-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
587
+ .refresh-btn { padding: 8px 16px; background: #2a2a4a; border: none; border-radius: 6px; color: #e0e0e0; cursor: pointer; font-size: 13px; font-weight: 600; }
588
+ .refresh-btn:hover { background: #3a3a5a; }
589
+ .refresh-time { font-size: 12px; color: #555; }
590
+ .pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #27ae60; animation: pulse 1.5s infinite; }
591
+ @keyframes pulse { 0%,100% { opacity: 1; box-shadow: 0 0 4px #27ae60; } 50% { opacity: 0.3; box-shadow: none; } }
592
+ .live-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; background: #1a3a2a; color: #27ae60; font-size: 11px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; animation: pulse 1.5s infinite; }
593
+
594
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
595
+ .badge.model { background: #1a2a3a; color: #60a0ff; }
596
+ .badge.channel { background: #2a1a3a; color: #a060ff; }
597
+ .badge.tokens { background: #1a3a2a; color: #60ff80; }
598
+
599
+ .full-width { grid-column: 1 / -1; }
600
+ .section-title { font-size: 16px; font-weight: 700; color: #fff; margin: 20px 0 12px; display: flex; align-items: center; gap: 8px; }
601
+
602
+ /* === Flow Visualization === */
603
+ .flow-container { width: 100%; overflow-x: auto; overflow-y: hidden; position: relative; }
604
+ .flow-stats { display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
605
+ .flow-stat { background: #141428; border: 1px solid #2a2a4a; border-radius: 8px; padding: 8px 14px; flex: 1; min-width: 100px; }
606
+ .flow-stat-label { font-size: 10px; text-transform: uppercase; color: #555; letter-spacing: 1px; display: block; }
607
+ .flow-stat-value { font-size: 20px; font-weight: 700; color: #fff; display: block; margin-top: 2px; }
608
+ #flow-svg { width: 100%; min-width: 800px; height: auto; display: block; }
609
+ #flow-svg text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 13px; font-weight: 600; fill: #d0d0d0; text-anchor: middle; dominant-baseline: central; pointer-events: none; }
610
+ .flow-node rect { rx: 12; ry: 12; stroke-width: 1.5; transition: all 0.3s ease; }
611
+ .flow-node-channel rect { fill: #161630; stroke: #6a40bf; }
612
+ .flow-node-gateway rect { fill: #141830; stroke: #4080e0; }
613
+ .flow-node-session rect { fill: #142818; stroke: #40c060; }
614
+ .flow-node-brain rect { fill: #221c08; stroke: #f0c040; stroke-width: 2.5; }
615
+ .flow-node-tool rect { fill: #1e1414; stroke: #c05030; }
616
+ .flow-node-channel.active rect { filter: drop-shadow(0 0 10px rgba(106,64,191,0.7)); stroke-width: 2.5; }
617
+ .flow-node-gateway.active rect { filter: drop-shadow(0 0 10px rgba(64,128,224,0.7)); stroke-width: 2.5; }
618
+ .flow-node-session.active rect { filter: drop-shadow(0 0 10px rgba(64,192,96,0.7)); stroke-width: 2.5; }
619
+ .flow-node-tool.active rect { filter: drop-shadow(0 0 10px rgba(224,96,64,0.8)); stroke: #ff8050; stroke-width: 2.5; }
620
+ .flow-path { fill: none; stroke: #1a1a36; stroke-width: 2; stroke-linecap: round; transition: stroke 0.4s, opacity 0.4s; }
621
+ .flow-path.glow-blue { stroke: #4080e0; filter: drop-shadow(0 0 6px rgba(64,128,224,0.6)); }
622
+ .flow-path.glow-yellow { stroke: #f0c040; filter: drop-shadow(0 0 6px rgba(240,192,64,0.6)); }
623
+ .flow-path.glow-green { stroke: #50e080; filter: drop-shadow(0 0 6px rgba(80,224,128,0.6)); }
624
+ .flow-path.glow-red { stroke: #e04040; filter: drop-shadow(0 0 6px rgba(224,64,64,0.6)); }
625
+ @keyframes brainPulse { 0%,100% { filter: drop-shadow(0 0 6px rgba(240,192,64,0.25)); } 50% { filter: drop-shadow(0 0 22px rgba(240,192,64,0.7)); } }
626
+ .brain-group { animation: brainPulse 2.2s ease-in-out infinite; }
627
+ .tool-indicator { opacity: 0.2; transition: opacity 0.3s ease; }
628
+ .tool-indicator.active { opacity: 1; }
629
+ .flow-label { font-size: 9px !important; fill: #333 !important; font-weight: 400 !important; }
630
+ .flow-node-human circle { transition: all 0.3s ease; }
631
+ .flow-node-human.active circle { filter: drop-shadow(0 0 12px rgba(176,128,255,0.7)); }
632
+ @keyframes humanGlow { 0%,100% { filter: drop-shadow(0 0 3px rgba(160,112,224,0.15)); } 50% { filter: drop-shadow(0 0 10px rgba(160,112,224,0.45)); } }
633
+ .flow-node-human { animation: humanGlow 3.5s ease-in-out infinite; }
634
+ .flow-ground { stroke: #20203a; stroke-width: 1; stroke-dasharray: 8 4; }
635
+ .flow-ground-label { font-size: 10px !important; fill: #1e1e38 !important; font-weight: 600 !important; letter-spacing: 4px; }
636
+ .flow-node-infra rect { rx: 6; ry: 6; stroke-width: 2; stroke-dasharray: 5 2; transition: all 0.3s ease; }
637
+ .flow-node-infra text { font-size: 12px !important; }
638
+ .flow-node-infra .infra-sub { font-size: 9px !important; fill: #444 !important; font-weight: 400 !important; }
639
+ .flow-node-runtime rect { fill: #10182a; stroke: #4a7090; }
640
+ .flow-node-machine rect { fill: #141420; stroke: #606880; }
641
+ .flow-node-storage rect { fill: #1a1810; stroke: #806a30; }
642
+ .flow-node-network rect { fill: #0e1c20; stroke: #308080; }
643
+ .flow-node-runtime.active rect { filter: drop-shadow(0 0 10px rgba(74,112,144,0.7)); stroke-dasharray: none; stroke-width: 2.5; }
644
+ .flow-node-machine.active rect { filter: drop-shadow(0 0 10px rgba(96,104,128,0.7)); stroke-dasharray: none; stroke-width: 2.5; }
645
+ .flow-node-storage.active rect { filter: drop-shadow(0 0 10px rgba(128,106,48,0.7)); stroke-dasharray: none; stroke-width: 2.5; }
646
+ .flow-node-network.active rect { filter: drop-shadow(0 0 10px rgba(48,128,128,0.7)); stroke-dasharray: none; stroke-width: 2.5; }
647
+ .flow-path-infra { stroke-dasharray: 6 3; opacity: 0.3; }
648
+ .flow-path.glow-cyan { stroke: #40a0b0; filter: drop-shadow(0 0 6px rgba(64,160,176,0.6)); stroke-dasharray: none; opacity: 1; }
649
+ .flow-path.glow-purple { stroke: #b080ff; filter: drop-shadow(0 0 6px rgba(176,128,255,0.6)); }
650
+
651
+ /* === Activity Heatmap === */
652
+ .heatmap-wrap { overflow-x: auto; padding: 8px 0; }
653
+ .heatmap-grid { display: grid; grid-template-columns: 60px repeat(24, 1fr); gap: 2px; min-width: 650px; }
654
+ .heatmap-label { font-size: 11px; color: #666; display: flex; align-items: center; padding-right: 8px; justify-content: flex-end; }
655
+ .heatmap-hour-label { font-size: 10px; color: #555; text-align: center; padding-bottom: 4px; }
656
+ .heatmap-cell { aspect-ratio: 1; border-radius: 3px; min-height: 16px; transition: all 0.15s; cursor: default; position: relative; }
657
+ .heatmap-cell:hover { transform: scale(1.3); z-index: 2; outline: 1px solid #f0c040; }
658
+ .heatmap-cell[title]:hover::after { content: attr(title); position: absolute; bottom: 120%; left: 50%; transform: translateX(-50%); background: #222; color: #eee; padding: 3px 8px; border-radius: 4px; font-size: 10px; white-space: nowrap; z-index: 10; pointer-events: none; }
659
+ .heatmap-legend { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 11px; color: #666; }
660
+ .heatmap-legend-cell { width: 14px; height: 14px; border-radius: 3px; }
661
+
662
+ /* === Health Checks === */
663
+ .health-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
664
+ .health-item { background: #141428; border: 1px solid #2a2a4a; border-radius: 10px; padding: 14px 16px; display: flex; align-items: center; gap: 12px; transition: border-color 0.3s; }
665
+ .health-item.healthy { border-left: 3px solid #27ae60; }
666
+ .health-item.warning { border-left: 3px solid #f0c040; }
667
+ .health-item.critical { border-left: 3px solid #e74c3c; }
668
+ .health-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
669
+ .health-dot.green { background: #27ae60; box-shadow: 0 0 8px rgba(39,174,96,0.5); }
670
+ .health-dot.yellow { background: #f0c040; box-shadow: 0 0 8px rgba(240,192,64,0.5); }
671
+ .health-dot.red { background: #e74c3c; box-shadow: 0 0 8px rgba(231,76,60,0.5); }
672
+ .health-info { flex: 1; }
673
+ .health-name { font-size: 13px; font-weight: 600; color: #ccc; }
674
+ .health-detail { font-size: 11px; color: #666; margin-top: 2px; }
675
+
676
+ /* === Usage/Token Charts === */
677
+ .usage-chart { display: flex; align-items: flex-end; gap: 6px; height: 200px; padding: 16px 8px 32px; position: relative; }
678
+ .usage-bar-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; position: relative; }
679
+ .usage-bar { width: 100%; min-width: 20px; max-width: 48px; border-radius: 4px 4px 0 0; background: linear-gradient(180deg, #f0c040, #c09020); transition: height 0.4s ease; position: relative; cursor: default; }
680
+ .usage-bar:hover { filter: brightness(1.25); }
681
+ .usage-bar-label { font-size: 9px; color: #555; margin-top: 6px; text-align: center; white-space: nowrap; }
682
+ .usage-bar-value { font-size: 9px; color: #888; text-align: center; position: absolute; top: -16px; width: 100%; white-space: nowrap; }
683
+ .usage-grid-line { position: absolute; left: 0; right: 0; border-top: 1px dashed #1a1a30; }
684
+ .usage-grid-label { position: absolute; right: 100%; padding-right: 8px; font-size: 10px; color: #444; white-space: nowrap; }
685
+ .usage-table { width: 100%; border-collapse: collapse; }
686
+ .usage-table th { text-align: left; font-size: 12px; color: #666; padding: 8px 12px; border-bottom: 1px solid #2a2a4a; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
687
+ .usage-table td { padding: 8px 12px; font-size: 13px; color: #ccc; border-bottom: 1px solid #1a1a30; }
688
+ .usage-table tr:last-child td { border-bottom: none; font-weight: 700; color: #f0c040; }
689
+
690
+ /* === Transcript Viewer === */
691
+ .transcript-item { padding: 12px 16px; border-bottom: 1px solid #1a1a30; cursor: pointer; transition: background 0.15s; display: flex; justify-content: space-between; align-items: center; }
692
+ .transcript-item:hover { background: #1a1a35; }
693
+ .transcript-item:last-child { border-bottom: none; }
694
+ .transcript-name { font-weight: 600; font-size: 14px; color: #60a0ff; }
695
+ .transcript-meta-row { font-size: 12px; color: #666; margin-top: 4px; display: flex; gap: 12px; flex-wrap: wrap; }
696
+ .transcript-viewer-meta { background: #141428; border: 1px solid #2a2a4a; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
697
+ .transcript-viewer-meta .stat-row { padding: 6px 0; }
698
+ .chat-messages { display: flex; flex-direction: column; gap: 10px; padding: 8px 0; }
699
+ .chat-msg { max-width: 85%; padding: 12px 16px; border-radius: 16px; font-size: 13px; line-height: 1.5; word-wrap: break-word; position: relative; }
700
+ .chat-msg.user { background: #1a2a4a; border: 1px solid #2a4a7a; color: #c0d8ff; align-self: flex-end; border-bottom-right-radius: 4px; }
701
+ .chat-msg.assistant { background: #1a3a2a; border: 1px solid #2a5a3a; color: #c0ffc0; align-self: flex-start; border-bottom-left-radius: 4px; }
702
+ .chat-msg.system { background: #2a2a1a; border: 1px solid #4a4a2a; color: #f0e0a0; align-self: center; font-size: 12px; font-style: italic; max-width: 90%; }
703
+ .chat-msg.tool { background: #1a1a24; border: 1px solid #2a2a3a; color: #a0a0b0; align-self: flex-start; font-family: 'SF Mono', monospace; font-size: 12px; border-left: 3px solid #555; }
704
+ .chat-role { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; opacity: 0.7; }
705
+ .chat-ts { font-size: 10px; color: #555; margin-top: 6px; text-align: right; }
706
+ .chat-expand { display: inline-block; color: #f0c040; font-size: 11px; cursor: pointer; margin-top: 4px; }
707
+ .chat-expand:hover { text-decoration: underline; }
708
+ .chat-content-truncated { max-height: 200px; overflow: hidden; position: relative; }
709
+ .chat-content-truncated::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 40px; background: linear-gradient(transparent, rgba(26,42,74,0.9)); pointer-events: none; }
710
+ .chat-msg.assistant .chat-content-truncated::after { background: linear-gradient(transparent, rgba(26,58,42,0.9)); }
711
+ .chat-msg.tool .chat-content-truncated::after { background: linear-gradient(transparent, rgba(26,26,36,0.9)); }
712
+
713
+ @media (max-width: 768px) {
714
+ .nav { padding: 10px 12px; gap: 8px; }
715
+ .nav h1 { font-size: 16px; }
716
+ .nav-tab { padding: 6px 12px; font-size: 12px; }
717
+ .page { padding: 12px; }
718
+ .grid { grid-template-columns: 1fr; gap: 12px; }
719
+ .card-value { font-size: 22px; }
720
+ .flow-stats { gap: 8px; }
721
+ .flow-stat { min-width: 70px; padding: 6px 10px; }
722
+ .flow-stat-value { font-size: 16px; }
723
+ #flow-svg { min-width: 600px; }
724
+ .heatmap-grid { min-width: 500px; }
725
+ .chat-msg { max-width: 95%; }
726
+ .usage-chart { height: 150px; }
727
+ }
728
+ </style>
729
+ </head>
730
+ <body>
731
+ <div class="nav">
732
+ <h1><span>๐Ÿฆž</span> OpenClaw</h1>
733
+ <div class="nav-tabs">
734
+ <div class="nav-tab active" onclick="switchTab('overview')">Overview</div>
735
+ <div class="nav-tab" onclick="switchTab('usage')">๐Ÿ“Š Usage</div>
736
+ <div class="nav-tab" onclick="switchTab('sessions')">Sessions</div>
737
+ <div class="nav-tab" onclick="switchTab('crons')">Crons</div>
738
+ <div class="nav-tab" onclick="switchTab('logs')">Logs</div>
739
+ <div class="nav-tab" onclick="switchTab('memory')">Memory</div>
740
+ <div class="nav-tab" onclick="switchTab('transcripts')">๐Ÿ“œ Transcripts</div>
741
+ <div class="nav-tab" onclick="switchTab('flow')">Flow</div>
742
+ </div>
743
+ </div>
744
+
745
+ <!-- OVERVIEW -->
746
+ <div class="page active" id="page-overview">
747
+ <div class="refresh-bar">
748
+ <button class="refresh-btn" onclick="loadAll()">โ†ป Refresh</button>
749
+ <span class="pulse"></span>
750
+ <span class="live-badge">LIVE</span>
751
+ <span class="refresh-time" id="refresh-time">Loading...</span>
752
+ </div>
753
+ <div class="grid">
754
+ <div class="card">
755
+ <div class="card-title"><span class="icon">๐Ÿง </span> Model</div>
756
+ <div class="card-value" id="ov-model">โ€”</div>
757
+ <div class="card-sub" id="ov-model-sub"></div>
758
+ </div>
759
+ <div class="card">
760
+ <div class="card-title"><span class="icon">๐Ÿ’ฌ</span> Active Sessions</div>
761
+ <div class="card-value" id="ov-sessions">โ€”</div>
762
+ <div class="card-sub" id="ov-sessions-sub"></div>
763
+ </div>
764
+ <div class="card">
765
+ <div class="card-title"><span class="icon">โฐ</span> Cron Jobs</div>
766
+ <div class="card-value" id="ov-crons">โ€”</div>
767
+ <div class="card-sub" id="ov-crons-sub"></div>
768
+ </div>
769
+ <div class="card">
770
+ <div class="card-title"><span class="icon">๐Ÿ“Š</span> Context Tokens</div>
771
+ <div class="card-value" id="ov-tokens">โ€”</div>
772
+ <div class="card-sub" id="ov-tokens-sub"></div>
773
+ </div>
774
+ <div class="card">
775
+ <div class="card-title"><span class="icon">๐Ÿ’พ</span> Memory Files</div>
776
+ <div class="card-value" id="ov-memory">โ€”</div>
777
+ <div class="card-sub" id="ov-memory-sub"></div>
778
+ </div>
779
+ <div class="card">
780
+ <div class="card-title"><span class="icon">๐Ÿ’ป</span> System</div>
781
+ <div id="ov-system"></div>
782
+ </div>
783
+ </div>
784
+ <div class="section-title">โค๏ธ System Health</div>
785
+ <div class="health-grid" id="health-grid">
786
+ <div class="health-item" id="health-gateway"><div class="health-dot" id="health-dot-gateway"></div><div class="health-info"><div class="health-name">Gateway</div><div class="health-detail" id="health-detail-gateway">Checking...</div></div></div>
787
+ <div class="health-item" id="health-disk"><div class="health-dot" id="health-dot-disk"></div><div class="health-info"><div class="health-name">Disk Space</div><div class="health-detail" id="health-detail-disk">Checking...</div></div></div>
788
+ <div class="health-item" id="health-memory"><div class="health-dot" id="health-dot-memory"></div><div class="health-info"><div class="health-name">Memory</div><div class="health-detail" id="health-detail-memory">Checking...</div></div></div>
789
+ <div class="health-item" id="health-uptime"><div class="health-dot" id="health-dot-uptime"></div><div class="health-info"><div class="health-name">Uptime</div><div class="health-detail" id="health-detail-uptime">Checking...</div></div></div>
790
+ <div class="health-item" id="health-otel"><div class="health-dot" id="health-dot-otel"></div><div class="health-info"><div class="health-name">๐Ÿ“ก OTLP Metrics</div><div class="health-detail" id="health-detail-otel">Checking...</div></div></div>
791
+ </div>
792
+
793
+ <div class="section-title">๐Ÿ”ฅ Activity Heatmap <span style="font-size:12px;color:#666;font-weight:400;">(7 days)</span></div>
794
+ <div class="card">
795
+ <div class="heatmap-wrap">
796
+ <div class="heatmap-grid" id="heatmap-grid">Loading...</div>
797
+ </div>
798
+ <div class="heatmap-legend" id="heatmap-legend"></div>
799
+ </div>
800
+
801
+ <div class="section-title">๐Ÿ“‹ Recent Logs</div>
802
+ <div class="log-viewer" id="ov-logs" style="max-height:300px;">Loading...</div>
803
+ </div>
804
+
805
+ <!-- USAGE -->
806
+ <div class="page" id="page-usage">
807
+ <div class="refresh-bar"><button class="refresh-btn" onclick="loadUsage()">โ†ป Refresh</button></div>
808
+ <div class="grid">
809
+ <div class="card">
810
+ <div class="card-title"><span class="icon">๐Ÿ“Š</span> Today</div>
811
+ <div class="card-value" id="usage-today">โ€”</div>
812
+ <div class="card-sub" id="usage-today-cost"></div>
813
+ </div>
814
+ <div class="card">
815
+ <div class="card-title"><span class="icon">๐Ÿ“…</span> This Week</div>
816
+ <div class="card-value" id="usage-week">โ€”</div>
817
+ <div class="card-sub" id="usage-week-cost"></div>
818
+ </div>
819
+ <div class="card">
820
+ <div class="card-title"><span class="icon">๐Ÿ“†</span> This Month</div>
821
+ <div class="card-value" id="usage-month">โ€”</div>
822
+ <div class="card-sub" id="usage-month-cost"></div>
823
+ </div>
824
+ </div>
825
+ <div class="section-title">๐Ÿ“Š Token Usage (14 days)</div>
826
+ <div class="card">
827
+ <div class="usage-chart" id="usage-chart">Loading...</div>
828
+ </div>
829
+ <div class="section-title">๐Ÿ’ฐ Cost Breakdown</div>
830
+ <div class="card"><table class="usage-table" id="usage-cost-table"><tbody><tr><td colspan="3" style="color:#666;">Loading...</td></tr></tbody></table></div>
831
+ <div id="otel-extra-sections" style="display:none;">
832
+ <div class="grid" style="margin-top:16px;">
833
+ <div class="card">
834
+ <div class="card-title"><span class="icon">โฑ๏ธ</span> Avg Run Duration</div>
835
+ <div class="card-value" id="usage-avg-run">โ€”</div>
836
+ <div class="card-sub">from OTLP openclaw.run.duration_ms</div>
837
+ </div>
838
+ <div class="card">
839
+ <div class="card-title"><span class="icon">๐Ÿ’ฌ</span> Messages Processed</div>
840
+ <div class="card-value" id="usage-msg-count">โ€”</div>
841
+ <div class="card-sub">from OTLP openclaw.message.processed</div>
842
+ </div>
843
+ </div>
844
+ <div class="section-title">๐Ÿค– Model Breakdown</div>
845
+ <div class="card"><table class="usage-table" id="usage-model-table"><tbody><tr><td colspan="2" style="color:#666;">No model data</td></tr></tbody></table></div>
846
+ <div style="margin-top:12px;padding:8px 12px;background:#1a3a2a;border:1px solid #2a5a3a;border-radius:8px;font-size:12px;color:#60ff80;">๐Ÿ“ก Data source: OpenTelemetry OTLP โ€” real-time metrics from OpenClaw</div>
847
+ </div>
848
+ </div>
849
+
850
+ <!-- SESSIONS -->
851
+ <div class="page" id="page-sessions">
852
+ <div class="refresh-bar"><button class="refresh-btn" onclick="loadSessions()">โ†ป Refresh</button></div>
853
+ <div class="card" id="sessions-list">Loading...</div>
854
+ </div>
855
+
856
+ <!-- CRONS -->
857
+ <div class="page" id="page-crons">
858
+ <div class="refresh-bar"><button class="refresh-btn" onclick="loadCrons()">โ†ป Refresh</button></div>
859
+ <div class="card" id="crons-list">Loading...</div>
860
+ </div>
861
+
862
+ <!-- LOGS -->
863
+ <div class="page" id="page-logs">
864
+ <div class="refresh-bar">
865
+ <button class="refresh-btn" onclick="loadLogs()">โ†ป Refresh</button>
866
+ <select id="log-lines" onchange="loadLogs()" style="background:#1a1a35;color:#e0e0e0;border:1px solid #2a2a4a;padding:6px;border-radius:6px;font-size:13px;">
867
+ <option value="50">50 lines</option>
868
+ <option value="100" selected>100 lines</option>
869
+ <option value="300">300 lines</option>
870
+ <option value="500">500 lines</option>
871
+ </select>
872
+ </div>
873
+ <div class="log-viewer" id="logs-full" style="max-height:calc(100vh - 140px);">Loading...</div>
874
+ </div>
875
+
876
+ <!-- MEMORY -->
877
+ <div class="page" id="page-memory">
878
+ <div class="refresh-bar">
879
+ <button class="refresh-btn" onclick="loadMemory()">โ†ป Refresh</button>
880
+ </div>
881
+ <div class="card" id="memory-list">Loading...</div>
882
+ <div class="file-viewer" id="file-viewer">
883
+ <div class="file-viewer-header">
884
+ <span class="file-viewer-title" id="file-viewer-title"></span>
885
+ <button class="file-viewer-close" onclick="closeFileViewer()">โœ• Close</button>
886
+ </div>
887
+ <div class="file-viewer-content" id="file-viewer-content"></div>
888
+ </div>
889
+ </div>
890
+
891
+ <!-- TRANSCRIPTS -->
892
+ <div class="page" id="page-transcripts">
893
+ <div class="refresh-bar">
894
+ <button class="refresh-btn" onclick="loadTranscripts()">โ†ป Refresh</button>
895
+ <button class="refresh-btn" id="transcript-back-btn" style="display:none" onclick="showTranscriptList()">โ† Back to list</button>
896
+ </div>
897
+ <div class="card" id="transcript-list">Loading...</div>
898
+ <div id="transcript-viewer" style="display:none">
899
+ <div class="transcript-viewer-meta" id="transcript-meta"></div>
900
+ <div class="chat-messages" id="transcript-messages"></div>
901
+ </div>
902
+ </div>
903
+
904
+ <!-- FLOW -->
905
+ <div class="page" id="page-flow">
906
+ <div class="flow-stats">
907
+ <div class="flow-stat"><span class="flow-stat-label">Msgs / min</span><span class="flow-stat-value" id="flow-msg-rate">0</span></div>
908
+ <div class="flow-stat"><span class="flow-stat-label">Events</span><span class="flow-stat-value" id="flow-event-count">0</span></div>
909
+ <div class="flow-stat"><span class="flow-stat-label">Active Tools</span><span class="flow-stat-value" id="flow-active-tools">&mdash;</span></div>
910
+ <div class="flow-stat"><span class="flow-stat-label">Tokens</span><span class="flow-stat-value" id="flow-tokens">&mdash;</span></div>
911
+ </div>
912
+ <div class="flow-container">
913
+ <svg id="flow-svg" viewBox="0 0 1200 950" preserveAspectRatio="xMidYMid meet">
914
+ <defs>
915
+ <pattern id="flow-grid" width="40" height="40" patternUnits="userSpaceOnUse">
916
+ <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#111128" stroke-width="0.5"/>
917
+ </pattern>
918
+ </defs>
919
+ <rect width="1200" height="950" fill="url(#flow-grid)"/>
920
+
921
+ <!-- Human โ†’ Channel paths -->
922
+ <path class="flow-path" id="path-human-tg" d="M 100 76 C 116 115, 116 152, 100 178"/>
923
+ <path class="flow-path" id="path-human-sig" d="M 100 76 C 98 155, 98 275, 100 328"/>
924
+ <path class="flow-path" id="path-human-wa" d="M 100 76 C 82 190, 82 410, 100 478"/>
925
+
926
+ <!-- Connection Paths -->
927
+ <path class="flow-path" id="path-tg-gw" d="M 165 200 C 210 200, 220 310, 260 335"/>
928
+ <path class="flow-path" id="path-sig-gw" d="M 165 350 L 260 350"/>
929
+ <path class="flow-path" id="path-wa-gw" d="M 165 500 C 210 500, 220 390, 260 365"/>
930
+ <path class="flow-path" id="path-gw-brain" d="M 380 350 C 425 350, 440 365, 480 365"/>
931
+ <path class="flow-path" id="path-brain-session" d="M 570 310 L 570 185"/>
932
+ <path class="flow-path" id="path-brain-exec" d="M 660 335 C 720 310, 770 160, 810 150"/>
933
+ <path class="flow-path" id="path-brain-browser" d="M 660 350 C 760 340, 880 260, 920 255"/>
934
+ <path class="flow-path" id="path-brain-search" d="M 660 370 C 790 370, 920 380, 960 380"/>
935
+ <path class="flow-path" id="path-brain-cron" d="M 660 385 C 760 400, 880 500, 920 510"/>
936
+ <path class="flow-path" id="path-brain-tts" d="M 660 400 C 720 450, 770 570, 810 585"/>
937
+ <path class="flow-path" id="path-brain-memory" d="M 610 420 C 630 520, 660 600, 670 620"/>
938
+
939
+ <!-- Infrastructure paths -->
940
+ <path class="flow-path flow-path-infra" id="path-gw-network" d="M 320 377 C 320 570, 720 710, 960 785"/>
941
+ <path class="flow-path flow-path-infra" id="path-brain-runtime" d="M 540 420 C 520 570, 310 710, 260 785"/>
942
+ <path class="flow-path flow-path-infra" id="path-brain-machine" d="M 570 420 C 570 570, 510 710, 500 785"/>
943
+ <path class="flow-path flow-path-infra" id="path-memory-storage" d="M 725 639 C 730 695, 738 750, 740 785"/>
944
+
945
+ <!-- Human Origin -->
946
+ <g class="flow-node flow-node-human" id="node-human">
947
+ <circle cx="100" cy="48" r="28" fill="#0e0c22" stroke="#b080ff" stroke-width="2"/>
948
+ <circle cx="100" cy="40" r="7" fill="#9070d0" opacity="0.45"/>
949
+ <path d="M 86 56 Q 86 65 100 65 Q 114 65 114 56" fill="#9070d0" opacity="0.3"/>
950
+ <text x="100" y="92" style="font-size:13px;fill:#c0a8f0;font-weight:700;" id="flow-human-name">You</text>
951
+ <text x="100" y="106" style="font-size:9px;fill:#3a3a5a;">origin</text>
952
+ </g>
953
+
954
+ <!-- Channel Nodes -->
955
+ <g class="flow-node flow-node-channel" id="node-telegram">
956
+ <rect x="35" y="178" width="130" height="44"/>
957
+ <text x="100" y="203">&#x1F4F1; Telegram</text>
958
+ </g>
959
+ <g class="flow-node flow-node-channel" id="node-signal">
960
+ <rect x="35" y="328" width="130" height="44"/>
961
+ <text x="100" y="353">&#x1F4E1; Signal</text>
962
+ </g>
963
+ <g class="flow-node flow-node-channel" id="node-whatsapp">
964
+ <rect x="35" y="478" width="130" height="44"/>
965
+ <text x="100" y="503">&#x1F4AC; WhatsApp</text>
966
+ </g>
967
+
968
+ <!-- Gateway -->
969
+ <g class="flow-node flow-node-gateway" id="node-gateway">
970
+ <rect x="260" y="323" width="120" height="54"/>
971
+ <text x="320" y="354">&#x1F500; Gateway</text>
972
+ </g>
973
+
974
+ <!-- Session / Context -->
975
+ <g class="flow-node flow-node-session" id="node-session">
976
+ <rect x="495" y="132" width="150" height="50"/>
977
+ <text x="570" y="160">&#x1F4BE; Session</text>
978
+ </g>
979
+
980
+ <!-- Brain -->
981
+ <g class="flow-node flow-node-brain brain-group" id="node-brain">
982
+ <rect x="480" y="310" width="180" height="110"/>
983
+ <text x="570" y="345" style="font-size:24px;">&#x1F9E0;</text>
984
+ <text x="570" y="374" style="font-size:14px;font-weight:700;fill:#f0c040;" id="brain-model-label">Claude</text>
985
+ <text x="570" y="394" style="font-size:10px;fill:#777;" id="brain-model-text">AI Model</text>
986
+ <circle cx="570" cy="410" r="4" fill="#e04040">
987
+ <animate attributeName="r" values="3;5;3" dur="1.1s" repeatCount="indefinite"/>
988
+ <animate attributeName="opacity" values="0.5;1;0.5" dur="1.1s" repeatCount="indefinite"/>
989
+ </circle>
990
+ </g>
991
+
992
+ <!-- Tool Nodes -->
993
+ <g class="flow-node flow-node-tool" id="node-exec">
994
+ <rect x="810" y="131" width="100" height="38"/>
995
+ <text x="860" y="153">&#x26A1; exec</text>
996
+ <circle class="tool-indicator" id="ind-exec" cx="905" cy="137" r="4" fill="#e06040"/>
997
+ </g>
998
+ <g class="flow-node flow-node-tool" id="node-browser">
999
+ <rect x="920" y="236" width="110" height="38"/>
1000
+ <text x="975" y="258">&#x1F310; browser</text>
1001
+ <circle class="tool-indicator" id="ind-browser" cx="1025" cy="242" r="4" fill="#e06040"/>
1002
+ </g>
1003
+ <g class="flow-node flow-node-tool" id="node-search">
1004
+ <rect x="960" y="361" width="130" height="38"/>
1005
+ <text x="1025" y="383">&#x1F50D; web_search</text>
1006
+ <circle class="tool-indicator" id="ind-search" cx="1085" cy="367" r="4" fill="#e06040"/>
1007
+ </g>
1008
+ <g class="flow-node flow-node-tool" id="node-cron">
1009
+ <rect x="920" y="491" width="100" height="38"/>
1010
+ <text x="970" y="513">&#x23F0; cron</text>
1011
+ <circle class="tool-indicator" id="ind-cron" cx="1015" cy="497" r="4" fill="#e06040"/>
1012
+ </g>
1013
+ <g class="flow-node flow-node-tool" id="node-tts">
1014
+ <rect x="810" y="566" width="100" height="38"/>
1015
+ <text x="860" y="588">&#x1F50A; tts</text>
1016
+ <circle class="tool-indicator" id="ind-tts" cx="905" cy="572" r="4" fill="#e06040"/>
1017
+ </g>
1018
+ <g class="flow-node flow-node-tool" id="node-memory">
1019
+ <rect x="670" y="601" width="110" height="38"/>
1020
+ <text x="725" y="623">&#x1F4DD; memory</text>
1021
+ <circle class="tool-indicator" id="ind-memory" cx="775" cy="607" r="4" fill="#e06040"/>
1022
+ </g>
1023
+
1024
+ <!-- Flow direction labels -->
1025
+ <text class="flow-label" x="195" y="255">inbound</text>
1026
+ <text class="flow-label" x="420" y="342">dispatch</text>
1027
+ <text class="flow-label" x="548" y="250">context</text>
1028
+ <text class="flow-label" x="750" y="320">tools</text>
1029
+
1030
+ <!-- Infrastructure Layer -->
1031
+ <line class="flow-ground" x1="80" y1="755" x2="1120" y2="755"/>
1032
+ <text class="flow-ground-label" x="600" y="772" style="text-anchor:middle;">I N F R A S T R U C T U R E</text>
1033
+
1034
+ <g class="flow-node flow-node-infra flow-node-runtime" id="node-runtime">
1035
+ <rect x="165" y="785" width="190" height="55"/>
1036
+ <text x="260" y="808" style="font-size:13px !important;">&#x2699;&#xFE0F; Runtime</text>
1037
+ <text class="infra-sub" x="260" y="826" id="infra-runtime-text">Node.js ยท Linux</text>
1038
+ </g>
1039
+ <g class="flow-node flow-node-infra flow-node-machine" id="node-machine">
1040
+ <rect x="405" y="785" width="190" height="55"/>
1041
+ <text x="500" y="808" style="font-size:13px !important;">&#x1F5A5;&#xFE0F; Machine</text>
1042
+ <text class="infra-sub" x="500" y="826" id="infra-machine-text">Host</text>
1043
+ </g>
1044
+ <g class="flow-node flow-node-infra flow-node-storage" id="node-storage">
1045
+ <rect x="645" y="785" width="190" height="55"/>
1046
+ <text x="740" y="808" style="font-size:13px !important;">&#x1F4BF; Storage</text>
1047
+ <text class="infra-sub" x="740" y="826" id="infra-storage-text">Disk</text>
1048
+ </g>
1049
+ <g class="flow-node flow-node-infra flow-node-network" id="node-network">
1050
+ <rect x="885" y="785" width="190" height="55"/>
1051
+ <text x="980" y="808" style="font-size:13px !important;">&#x1F310; Network</text>
1052
+ <text class="infra-sub" x="980" y="826" id="infra-network-text">LAN</text>
1053
+ </g>
1054
+
1055
+ <!-- Infra labels -->
1056
+ <text class="flow-label" x="440" y="680">runtime</text>
1057
+ <text class="flow-label" x="570" y="650">host</text>
1058
+ <text class="flow-label" x="720" y="710">disk I/O</text>
1059
+ <text class="flow-label" x="870" y="660">network</text>
1060
+ </svg>
1061
+ </div>
1062
+ </div>
1063
+
1064
+ <script>
1065
+ function switchTab(name) {
1066
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
1067
+ document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
1068
+ document.getElementById('page-' + name).classList.add('active');
1069
+ event.target.classList.add('active');
1070
+ if (name === 'usage') loadUsage();
1071
+ if (name === 'sessions') loadSessions();
1072
+ if (name === 'crons') loadCrons();
1073
+ if (name === 'logs') loadLogs();
1074
+ if (name === 'memory') loadMemory();
1075
+ if (name === 'transcripts') loadTranscripts();
1076
+ if (name === 'flow') initFlow();
1077
+ }
1078
+
1079
+ function timeAgo(ms) {
1080
+ if (!ms) return 'never';
1081
+ var diff = Date.now() - ms;
1082
+ if (diff < 60000) return Math.floor(diff/1000) + 's ago';
1083
+ if (diff < 3600000) return Math.floor(diff/60000) + 'm ago';
1084
+ if (diff < 86400000) return Math.floor(diff/3600000) + 'h ago';
1085
+ return Math.floor(diff/86400000) + 'd ago';
1086
+ }
1087
+
1088
+ function formatTime(ms) {
1089
+ if (!ms) return 'โ€”';
1090
+ return new Date(ms).toLocaleString('en-GB', {hour:'2-digit',minute:'2-digit',day:'numeric',month:'short'});
1091
+ }
1092
+
1093
+ async function loadAll() {
1094
+ var [overview, logs] = await Promise.all([
1095
+ fetch('/api/overview').then(r => r.json()),
1096
+ fetch('/api/logs?lines=30').then(r => r.json())
1097
+ ]);
1098
+
1099
+ document.getElementById('ov-model').textContent = overview.model || 'โ€”';
1100
+ document.getElementById('ov-model-sub').textContent = 'Provider: ' + (overview.provider || 'anthropic');
1101
+ document.getElementById('ov-sessions').textContent = overview.sessionCount;
1102
+ document.getElementById('ov-sessions-sub').textContent = 'Main: ' + timeAgo(overview.mainSessionUpdated);
1103
+ document.getElementById('ov-crons').textContent = overview.cronCount;
1104
+ document.getElementById('ov-crons-sub').textContent = overview.cronEnabled + ' enabled, ' + overview.cronDisabled + ' disabled';
1105
+ document.getElementById('ov-tokens').textContent = (overview.mainTokens / 1000).toFixed(0) + 'K';
1106
+ document.getElementById('ov-tokens-sub').textContent = 'of ' + (overview.contextWindow / 1000) + 'K context window (' + ((overview.mainTokens/overview.contextWindow)*100).toFixed(0) + '% used)';
1107
+ document.getElementById('ov-memory').textContent = overview.memoryCount;
1108
+ document.getElementById('ov-memory-sub').textContent = (overview.memorySize / 1024).toFixed(1) + ' KB total';
1109
+
1110
+ var sysHtml = '';
1111
+ overview.system.forEach(function(s) {
1112
+ sysHtml += '<div class="stat-row"><span class="stat-label">' + s[0] + '</span><span class="stat-val ' + (s[2]||'') + '">' + s[1] + '</span></div>';
1113
+ });
1114
+ document.getElementById('ov-system').innerHTML = sysHtml;
1115
+
1116
+ renderLogs('ov-logs', logs.lines);
1117
+ document.getElementById('refresh-time').textContent = 'Updated ' + new Date().toLocaleTimeString();
1118
+
1119
+ // Load health checks and heatmap
1120
+ loadHealth();
1121
+ loadHeatmap();
1122
+
1123
+ // Update flow infra details
1124
+ if (overview.infra) {
1125
+ var i = overview.infra;
1126
+ if (i.runtime) document.getElementById('infra-runtime-text').textContent = i.runtime;
1127
+ if (i.machine) document.getElementById('infra-machine-text').textContent = i.machine;
1128
+ if (i.storage) document.getElementById('infra-storage-text').textContent = i.storage;
1129
+ if (i.network) document.getElementById('infra-network-text').textContent = 'LAN ' + i.network;
1130
+ if (i.userName) document.getElementById('flow-human-name').textContent = i.userName;
1131
+ }
1132
+ }
1133
+
1134
+ function renderLogs(elId, lines) {
1135
+ var html = '';
1136
+ lines.forEach(function(l) {
1137
+ var cls = 'msg';
1138
+ var display = l;
1139
+ try {
1140
+ var obj = JSON.parse(l);
1141
+ var ts = '';
1142
+ if (obj.time || (obj._meta && obj._meta.date)) {
1143
+ var d = new Date(obj.time || obj._meta.date);
1144
+ ts = d.toLocaleTimeString('en-GB', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
1145
+ }
1146
+ var level = (obj.logLevelName || obj.level || 'info').toLowerCase();
1147
+ if (level === 'error' || level === 'fatal') cls = 'err';
1148
+ else if (level === 'warn' || level === 'warning') cls = 'warn';
1149
+ else if (level === 'debug') cls = 'msg';
1150
+ else cls = 'info';
1151
+ var msg = obj.msg || obj.message || obj.name || '';
1152
+ var extras = [];
1153
+ if (obj["0"]) extras.push(obj["0"]);
1154
+ if (obj["1"]) extras.push(obj["1"]);
1155
+ if (msg && extras.length) display = msg + ' | ' + extras.join(' ');
1156
+ else if (extras.length) display = extras.join(' ');
1157
+ else if (!msg) display = l.substring(0, 200);
1158
+ else display = msg;
1159
+ if (ts) display = '<span class="ts">' + ts + '</span> ' + escHtml(display);
1160
+ else display = escHtml(display);
1161
+ } catch(e) {
1162
+ if (l.includes('Error') || l.includes('failed')) cls = 'err';
1163
+ else if (l.includes('WARN')) cls = 'warn';
1164
+ display = escHtml(l.substring(0, 300));
1165
+ }
1166
+ html += '<div class="log-line"><span class="' + cls + '">' + display + '</span></div>';
1167
+ });
1168
+ document.getElementById(elId).innerHTML = html || '<span style="color:#555">No logs</span>';
1169
+ document.getElementById(elId).scrollTop = document.getElementById(elId).scrollHeight;
1170
+ }
1171
+
1172
+ function escHtml(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
1173
+
1174
+ async function viewFile(path) {
1175
+ var viewer = document.getElementById('file-viewer');
1176
+ var title = document.getElementById('file-viewer-title');
1177
+ var content = document.getElementById('file-viewer-content');
1178
+ title.textContent = path;
1179
+ content.textContent = 'Loading...';
1180
+ viewer.style.display = 'block';
1181
+ try {
1182
+ var data = await fetch('/api/file?path=' + encodeURIComponent(path)).then(r => r.json());
1183
+ if (data.error) { content.textContent = 'Error: ' + data.error; return; }
1184
+ content.textContent = data.content;
1185
+ } catch(e) {
1186
+ content.textContent = 'Failed to load: ' + e.message;
1187
+ }
1188
+ viewer.scrollIntoView({behavior:'smooth'});
1189
+ }
1190
+
1191
+ function closeFileViewer() {
1192
+ document.getElementById('file-viewer').style.display = 'none';
1193
+ }
1194
+
1195
+ async function loadSessions() {
1196
+ var data = await fetch('/api/sessions').then(r => r.json());
1197
+ var html = '';
1198
+ data.sessions.forEach(function(s) {
1199
+ html += '<div class="session-item">';
1200
+ html += '<div class="session-name">' + escHtml(s.displayName || s.key) + '</div>';
1201
+ html += '<div class="session-meta">';
1202
+ html += '<span><span class="badge model">' + (s.model||'default') + '</span></span>';
1203
+ if (s.channel !== 'unknown') html += '<span><span class="badge channel">' + s.channel + '</span></span>';
1204
+ html += '<span><span class="badge tokens">' + (s.totalTokens/1000).toFixed(0) + 'K tokens</span></span>';
1205
+ html += '<span>Updated ' + timeAgo(s.updatedAt) + '</span>';
1206
+ html += '</div></div>';
1207
+ });
1208
+ document.getElementById('sessions-list').innerHTML = html || 'No sessions';
1209
+ }
1210
+
1211
+ async function loadCrons() {
1212
+ var data = await fetch('/api/crons').then(r => r.json());
1213
+ var html = '';
1214
+ data.jobs.forEach(function(j) {
1215
+ var status = j.state && j.state.lastStatus ? j.state.lastStatus : 'pending';
1216
+ html += '<div class="cron-item">';
1217
+ html += '<div style="display:flex;justify-content:space-between;align-items:center;">';
1218
+ html += '<div class="cron-name">' + escHtml(j.name || j.id) + '</div>';
1219
+ html += '<span class="cron-status ' + status + '">' + status + '</span>';
1220
+ html += '</div>';
1221
+ html += '<div class="cron-schedule">' + formatSchedule(j.schedule) + '</div>';
1222
+ html += '<div class="cron-meta">';
1223
+ if (j.state && j.state.lastRunAtMs) html += 'Last: ' + timeAgo(j.state.lastRunAtMs);
1224
+ if (j.state && j.state.nextRunAtMs) html += ' ยท Next: ' + formatTime(j.state.nextRunAtMs);
1225
+ if (j.state && j.state.lastDurationMs) html += ' ยท Took: ' + (j.state.lastDurationMs/1000).toFixed(1) + 's';
1226
+ html += '</div></div>';
1227
+ });
1228
+ document.getElementById('crons-list').innerHTML = html || 'No cron jobs';
1229
+ }
1230
+
1231
+ function formatSchedule(s) {
1232
+ if (s.kind === 'cron') return 'cron: ' + s.expr + (s.tz ? ' (' + s.tz + ')' : '');
1233
+ if (s.kind === 'every') return 'every ' + (s.everyMs/60000) + ' min';
1234
+ if (s.kind === 'at') return 'once at ' + formatTime(s.atMs);
1235
+ return JSON.stringify(s);
1236
+ }
1237
+
1238
+ async function loadLogs() {
1239
+ var lines = document.getElementById('log-lines').value;
1240
+ var data = await fetch('/api/logs?lines=' + lines).then(r => r.json());
1241
+ renderLogs('logs-full', data.lines);
1242
+ }
1243
+
1244
+ async function loadMemory() {
1245
+ var data = await fetch('/api/memory-files').then(r => r.json());
1246
+ var html = '';
1247
+ data.forEach(function(f) {
1248
+ var size = f.size > 1024 ? (f.size/1024).toFixed(1) + ' KB' : f.size + ' B';
1249
+ html += '<div class="memory-item" onclick="viewFile(\'' + escHtml(f.path) + '\')">';
1250
+ html += '<span class="memory-name" style="color:#60a0ff;">' + escHtml(f.path) + '</span>';
1251
+ html += '<span class="memory-size">' + size + '</span>';
1252
+ html += '</div>';
1253
+ });
1254
+ document.getElementById('memory-list').innerHTML = html || 'No memory files';
1255
+ }
1256
+
1257
+ // ===== Health Checks =====
1258
+ async function loadHealth() {
1259
+ try {
1260
+ var data = await fetch('/api/health').then(r => r.json());
1261
+ data.checks.forEach(function(c) {
1262
+ var dotEl = document.getElementById('health-dot-' + c.id);
1263
+ var detailEl = document.getElementById('health-detail-' + c.id);
1264
+ var itemEl = document.getElementById('health-' + c.id);
1265
+ if (dotEl) { dotEl.className = 'health-dot ' + c.color; }
1266
+ if (detailEl) { detailEl.textContent = c.detail; }
1267
+ if (itemEl) { itemEl.className = 'health-item ' + c.status; }
1268
+ });
1269
+ } catch(e) {}
1270
+ }
1271
+
1272
+ // Health SSE auto-refresh
1273
+ var healthStream = null;
1274
+ function startHealthStream() {
1275
+ if (healthStream) healthStream.close();
1276
+ healthStream = new EventSource('/api/health-stream');
1277
+ healthStream.onmessage = function(e) {
1278
+ try {
1279
+ var data = JSON.parse(e.data);
1280
+ data.checks.forEach(function(c) {
1281
+ var dotEl = document.getElementById('health-dot-' + c.id);
1282
+ var detailEl = document.getElementById('health-detail-' + c.id);
1283
+ var itemEl = document.getElementById('health-' + c.id);
1284
+ if (dotEl) { dotEl.className = 'health-dot ' + c.color; }
1285
+ if (detailEl) { detailEl.textContent = c.detail; }
1286
+ if (itemEl) { itemEl.className = 'health-item ' + c.status; }
1287
+ });
1288
+ } catch(ex) {}
1289
+ };
1290
+ healthStream.onerror = function() { setTimeout(startHealthStream, 30000); };
1291
+ }
1292
+ startHealthStream();
1293
+
1294
+ // ===== Activity Heatmap =====
1295
+ async function loadHeatmap() {
1296
+ try {
1297
+ var data = await fetch('/api/heatmap').then(r => r.json());
1298
+ var grid = document.getElementById('heatmap-grid');
1299
+ var maxVal = Math.max(1, data.max);
1300
+ var html = '<div class="heatmap-label"></div>';
1301
+ for (var h = 0; h < 24; h++) { html += '<div class="heatmap-hour-label">' + (h < 10 ? '0' : '') + h + '</div>'; }
1302
+ data.days.forEach(function(day) {
1303
+ html += '<div class="heatmap-label">' + day.label + '</div>';
1304
+ day.hours.forEach(function(val, hi) {
1305
+ var intensity = val / maxVal;
1306
+ var color;
1307
+ if (val === 0) color = '#12122a';
1308
+ else if (intensity < 0.25) color = '#1a3a2a';
1309
+ else if (intensity < 0.5) color = '#2a6a3a';
1310
+ else if (intensity < 0.75) color = '#4a9a2a';
1311
+ else color = '#6adb3a';
1312
+ html += '<div class="heatmap-cell" style="background:' + color + ';" title="' + day.label + ' ' + (hi < 10 ? '0' : '') + hi + ':00 โ€” ' + val + ' events"></div>';
1313
+ });
1314
+ });
1315
+ grid.innerHTML = html;
1316
+ var legend = document.getElementById('heatmap-legend');
1317
+ legend.innerHTML = 'Less <div class="heatmap-legend-cell" style="background:#12122a"></div><div class="heatmap-legend-cell" style="background:#1a3a2a"></div><div class="heatmap-legend-cell" style="background:#2a6a3a"></div><div class="heatmap-legend-cell" style="background:#4a9a2a"></div><div class="heatmap-legend-cell" style="background:#6adb3a"></div> More';
1318
+ } catch(e) {
1319
+ document.getElementById('heatmap-grid').innerHTML = '<span style="color:#555">No activity data</span>';
1320
+ }
1321
+ }
1322
+
1323
+ // ===== Usage / Token Tracking =====
1324
+ async function loadUsage() {
1325
+ try {
1326
+ var data = await fetch('/api/usage').then(r => r.json());
1327
+ function fmtTokens(n) { return n >= 1000000 ? (n/1000000).toFixed(1) + 'M' : n >= 1000 ? (n/1000).toFixed(0) + 'K' : String(n); }
1328
+ function fmtCost(c) { return c >= 0.01 ? '$' + c.toFixed(2) : c > 0 ? '<$0.01' : '$0.00'; }
1329
+ document.getElementById('usage-today').textContent = fmtTokens(data.today);
1330
+ document.getElementById('usage-today-cost').textContent = 'โ‰ˆ ' + fmtCost(data.todayCost);
1331
+ document.getElementById('usage-week').textContent = fmtTokens(data.week);
1332
+ document.getElementById('usage-week-cost').textContent = 'โ‰ˆ ' + fmtCost(data.weekCost);
1333
+ document.getElementById('usage-month').textContent = fmtTokens(data.month);
1334
+ document.getElementById('usage-month-cost').textContent = 'โ‰ˆ ' + fmtCost(data.monthCost);
1335
+ // Bar chart
1336
+ var maxTokens = Math.max.apply(null, data.days.map(function(d){return d.tokens;})) || 1;
1337
+ var chartHtml = '';
1338
+ data.days.forEach(function(d) {
1339
+ var pct = Math.max(1, (d.tokens / maxTokens) * 100);
1340
+ var label = d.date.substring(5);
1341
+ var val = d.tokens >= 1000 ? (d.tokens/1000).toFixed(0) + 'K' : d.tokens;
1342
+ chartHtml += '<div class="usage-bar-wrap"><div class="usage-bar" style="height:' + pct + '%"><div class="usage-bar-value">' + (d.tokens > 0 ? val : '') + '</div></div><div class="usage-bar-label">' + label + '</div></div>';
1343
+ });
1344
+ document.getElementById('usage-chart').innerHTML = chartHtml;
1345
+ // Cost table
1346
+ var costLabel = data.source === 'otlp' ? 'Cost' : 'Est. Cost';
1347
+ var tableHtml = '<thead><tr><th>Period</th><th>Tokens</th><th>' + costLabel + '</th></tr></thead><tbody>';
1348
+ tableHtml += '<tr><td>Today</td><td>' + fmtTokens(data.today) + '</td><td>' + fmtCost(data.todayCost) + '</td></tr>';
1349
+ tableHtml += '<tr><td>This Week</td><td>' + fmtTokens(data.week) + '</td><td>' + fmtCost(data.weekCost) + '</td></tr>';
1350
+ tableHtml += '<tr><td>This Month</td><td>' + fmtTokens(data.month) + '</td><td>' + fmtCost(data.monthCost) + '</td></tr>';
1351
+ tableHtml += '</tbody>';
1352
+ document.getElementById('usage-cost-table').innerHTML = tableHtml;
1353
+ // OTLP-specific sections
1354
+ var otelExtra = document.getElementById('otel-extra-sections');
1355
+ if (data.source === 'otlp') {
1356
+ otelExtra.style.display = '';
1357
+ var runEl = document.getElementById('usage-avg-run');
1358
+ if (runEl) runEl.textContent = data.avgRunMs > 0 ? (data.avgRunMs > 1000 ? (data.avgRunMs/1000).toFixed(1) + 's' : data.avgRunMs.toFixed(0) + 'ms') : 'โ€”';
1359
+ var msgEl = document.getElementById('usage-msg-count');
1360
+ if (msgEl) msgEl.textContent = data.messageCount || '0';
1361
+ // Model breakdown table
1362
+ if (data.modelBreakdown && data.modelBreakdown.length > 0) {
1363
+ var mHtml = '<thead><tr><th>Model</th><th>Tokens</th></tr></thead><tbody>';
1364
+ data.modelBreakdown.forEach(function(m) {
1365
+ mHtml += '<tr><td><span class="badge model">' + escHtml(m.model) + '</span></td><td>' + fmtTokens(m.tokens) + '</td></tr>';
1366
+ });
1367
+ mHtml += '</tbody>';
1368
+ document.getElementById('usage-model-table').innerHTML = mHtml;
1369
+ }
1370
+ } else {
1371
+ otelExtra.style.display = 'none';
1372
+ }
1373
+ } catch(e) {
1374
+ document.getElementById('usage-chart').innerHTML = '<span style="color:#555">No usage data available</span>';
1375
+ }
1376
+ }
1377
+
1378
+ // ===== Transcripts =====
1379
+ async function loadTranscripts() {
1380
+ try {
1381
+ var data = await fetch('/api/transcripts').then(r => r.json());
1382
+ var html = '';
1383
+ data.transcripts.forEach(function(t) {
1384
+ html += '<div class="transcript-item" onclick="viewTranscript(\'' + escHtml(t.id) + '\')">';
1385
+ html += '<div><div class="transcript-name">' + escHtml(t.name) + '</div>';
1386
+ html += '<div class="transcript-meta-row">';
1387
+ html += '<span>' + t.messages + ' messages</span>';
1388
+ html += '<span>' + (t.size > 1024 ? (t.size/1024).toFixed(1) + ' KB' : t.size + ' B') + '</span>';
1389
+ html += '<span>' + timeAgo(t.modified) + '</span>';
1390
+ html += '</div></div>';
1391
+ html += '<span style="color:#444;font-size:18px;">โ–ธ</span>';
1392
+ html += '</div>';
1393
+ });
1394
+ document.getElementById('transcript-list').innerHTML = html || '<div style="padding:16px;color:#666;">No transcript files found</div>';
1395
+ document.getElementById('transcript-list').style.display = '';
1396
+ document.getElementById('transcript-viewer').style.display = 'none';
1397
+ document.getElementById('transcript-back-btn').style.display = 'none';
1398
+ } catch(e) {
1399
+ document.getElementById('transcript-list').innerHTML = '<div style="padding:16px;color:#666;">Failed to load transcripts</div>';
1400
+ }
1401
+ }
1402
+
1403
+ function showTranscriptList() {
1404
+ document.getElementById('transcript-list').style.display = '';
1405
+ document.getElementById('transcript-viewer').style.display = 'none';
1406
+ document.getElementById('transcript-back-btn').style.display = 'none';
1407
+ }
1408
+
1409
+ async function viewTranscript(sessionId) {
1410
+ document.getElementById('transcript-list').style.display = 'none';
1411
+ document.getElementById('transcript-viewer').style.display = '';
1412
+ document.getElementById('transcript-back-btn').style.display = '';
1413
+ document.getElementById('transcript-messages').innerHTML = '<div style="padding:20px;color:#666;">Loading transcript...</div>';
1414
+ try {
1415
+ var data = await fetch('/api/transcript/' + encodeURIComponent(sessionId)).then(r => r.json());
1416
+ // Metadata
1417
+ var metaHtml = '<div class="stat-row"><span class="stat-label">Session</span><span class="stat-val">' + escHtml(data.name) + '</span></div>';
1418
+ metaHtml += '<div class="stat-row"><span class="stat-label">Messages</span><span class="stat-val">' + data.messageCount + '</span></div>';
1419
+ if (data.model) metaHtml += '<div class="stat-row"><span class="stat-label">Model</span><span class="stat-val"><span class="badge model">' + escHtml(data.model) + '</span></span></div>';
1420
+ if (data.totalTokens) metaHtml += '<div class="stat-row"><span class="stat-label">Tokens</span><span class="stat-val"><span class="badge tokens">' + (data.totalTokens/1000).toFixed(0) + 'K</span></span></div>';
1421
+ if (data.duration) metaHtml += '<div class="stat-row"><span class="stat-label">Duration</span><span class="stat-val">' + data.duration + '</span></div>';
1422
+ document.getElementById('transcript-meta').innerHTML = metaHtml;
1423
+ // Messages
1424
+ var msgsHtml = '';
1425
+ data.messages.forEach(function(m, idx) {
1426
+ var role = m.role || 'unknown';
1427
+ var cls = role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : role === 'system' ? 'system' : 'tool';
1428
+ var content = m.content || '';
1429
+ var needsTruncate = content.length > 800;
1430
+ var displayContent = needsTruncate ? content.substring(0, 800) : content;
1431
+ msgsHtml += '<div class="chat-msg ' + cls + '">';
1432
+ msgsHtml += '<div class="chat-role">' + escHtml(role) + '</div>';
1433
+ if (needsTruncate) {
1434
+ msgsHtml += '<div class="chat-content-truncated" id="msg-' + idx + '-short">' + escHtml(displayContent) + '</div>';
1435
+ msgsHtml += '<div id="msg-' + idx + '-full" style="display:none;white-space:pre-wrap;">' + escHtml(content) + '</div>';
1436
+ msgsHtml += '<div class="chat-expand" onclick="toggleMsg(' + idx + ')">Show more (' + content.length + ' chars)</div>';
1437
+ } else {
1438
+ msgsHtml += '<div style="white-space:pre-wrap;">' + escHtml(content) + '</div>';
1439
+ }
1440
+ if (m.timestamp) msgsHtml += '<div class="chat-ts">' + new Date(m.timestamp).toLocaleString() + '</div>';
1441
+ msgsHtml += '</div>';
1442
+ });
1443
+ document.getElementById('transcript-messages').innerHTML = msgsHtml || '<div style="color:#555;padding:16px;">No messages in this transcript</div>';
1444
+ } catch(e) {
1445
+ document.getElementById('transcript-messages').innerHTML = '<div style="color:#e74c3c;padding:16px;">Failed to load transcript</div>';
1446
+ }
1447
+ }
1448
+
1449
+ function toggleMsg(idx) {
1450
+ var short = document.getElementById('msg-' + idx + '-short');
1451
+ var full = document.getElementById('msg-' + idx + '-full');
1452
+ if (short.style.display === 'none') {
1453
+ short.style.display = '';
1454
+ full.style.display = 'none';
1455
+ short.nextElementSibling.nextElementSibling.textContent = 'Show more';
1456
+ } else {
1457
+ short.style.display = 'none';
1458
+ full.style.display = '';
1459
+ event.target.textContent = 'Show less';
1460
+ }
1461
+ }
1462
+
1463
+ loadAll();
1464
+ setInterval(loadAll, 10000);
1465
+
1466
+ // Real-time log stream via SSE
1467
+ var logStream = null;
1468
+ var streamBuffer = [];
1469
+ var MAX_STREAM_LINES = 500;
1470
+
1471
+ function startLogStream() {
1472
+ if (logStream) logStream.close();
1473
+ streamBuffer = [];
1474
+ logStream = new EventSource('/api/logs-stream');
1475
+ logStream.onmessage = function(e) {
1476
+ var data = JSON.parse(e.data);
1477
+ streamBuffer.push(data.line);
1478
+ if (streamBuffer.length > MAX_STREAM_LINES) streamBuffer.shift();
1479
+ appendLogLine('ov-logs', data.line);
1480
+ appendLogLine('logs-full', data.line);
1481
+ processFlowEvent(data.line);
1482
+ document.getElementById('refresh-time').textContent = 'Live โ€ข ' + new Date().toLocaleTimeString();
1483
+ };
1484
+ logStream.onerror = function() {
1485
+ setTimeout(startLogStream, 5000);
1486
+ };
1487
+ }
1488
+
1489
+ function parseLogLine(line) {
1490
+ try {
1491
+ var obj = JSON.parse(line);
1492
+ var ts = '';
1493
+ if (obj.time || (obj._meta && obj._meta.date)) {
1494
+ var d = new Date(obj.time || obj._meta.date);
1495
+ ts = d.toLocaleTimeString('en-GB', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
1496
+ }
1497
+ var level = (obj.logLevelName || obj.level || 'info').toLowerCase();
1498
+ var cls = 'info';
1499
+ if (level === 'error' || level === 'fatal') cls = 'err';
1500
+ else if (level === 'warn' || level === 'warning') cls = 'warn';
1501
+ else if (level === 'debug') cls = 'msg';
1502
+ var msg = obj.msg || obj.message || obj.name || '';
1503
+ var extras = [];
1504
+ if (obj["0"]) extras.push(obj["0"]);
1505
+ if (obj["1"]) extras.push(obj["1"]);
1506
+ var display;
1507
+ if (msg && extras.length) display = msg + ' | ' + extras.join(' ');
1508
+ else if (extras.length) display = extras.join(' ');
1509
+ else if (!msg) display = line.substring(0, 200);
1510
+ else display = msg;
1511
+ if (ts) display = '<span class="ts">' + ts + '</span> ' + escHtml(display);
1512
+ else display = escHtml(display);
1513
+ return {cls: cls, html: display};
1514
+ } catch(e) {
1515
+ var cls = 'msg';
1516
+ if (line.includes('Error') || line.includes('failed')) cls = 'err';
1517
+ else if (line.includes('WARN')) cls = 'warn';
1518
+ else if (line.includes('run start') || line.includes('inbound')) cls = 'info';
1519
+ return {cls: cls, html: escHtml(line.substring(0, 300))};
1520
+ }
1521
+ }
1522
+
1523
+ function appendLogLine(elId, line) {
1524
+ var el = document.getElementById(elId);
1525
+ if (!el) return;
1526
+ var parsed = parseLogLine(line);
1527
+ var div = document.createElement('div');
1528
+ div.className = 'log-line';
1529
+ div.innerHTML = '<span class="' + parsed.cls + '">' + parsed.html + '</span>';
1530
+ el.appendChild(div);
1531
+ while (el.children.length > MAX_STREAM_LINES) el.removeChild(el.firstChild);
1532
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 150) {
1533
+ el.scrollTop = el.scrollHeight;
1534
+ }
1535
+ }
1536
+
1537
+ startLogStream();
1538
+
1539
+ // ===== Flow Visualization Engine =====
1540
+ var flowStats = { messages: 0, events: 0, activeTools: {}, msgTimestamps: [] };
1541
+ var flowInitDone = false;
1542
+
1543
+ function initFlow() {
1544
+ if (flowInitDone) return;
1545
+ flowInitDone = true;
1546
+ fetch('/api/overview').then(function(r){return r.json();}).then(function(d) {
1547
+ var el = document.getElementById('brain-model-text');
1548
+ if (el && d.model) el.textContent = d.model;
1549
+ var label = document.getElementById('brain-model-label');
1550
+ if (label && d.model) {
1551
+ var short = d.model.split('/').pop().split('-').slice(0,2).join(' ');
1552
+ label.textContent = short.charAt(0).toUpperCase() + short.slice(1);
1553
+ }
1554
+ var tok = document.getElementById('flow-tokens');
1555
+ if (tok) tok.textContent = (d.mainTokens / 1000).toFixed(0) + 'K';
1556
+ }).catch(function(){});
1557
+ setInterval(updateFlowStats, 2000);
1558
+ }
1559
+
1560
+ function updateFlowStats() {
1561
+ var now = Date.now();
1562
+ flowStats.msgTimestamps = flowStats.msgTimestamps.filter(function(t){return now - t < 60000;});
1563
+ var el1 = document.getElementById('flow-msg-rate');
1564
+ if (el1) el1.textContent = flowStats.msgTimestamps.length;
1565
+ var el2 = document.getElementById('flow-event-count');
1566
+ if (el2) el2.textContent = flowStats.events;
1567
+ var names = Object.keys(flowStats.activeTools).filter(function(k){return flowStats.activeTools[k];});
1568
+ var el3 = document.getElementById('flow-active-tools');
1569
+ if (el3) el3.textContent = names.length > 0 ? names.join(', ') : '\u2014';
1570
+ if (flowStats.events % 15 === 0) {
1571
+ fetch('/api/overview').then(function(r){return r.json();}).then(function(d) {
1572
+ var tok = document.getElementById('flow-tokens');
1573
+ if (tok) tok.textContent = (d.mainTokens / 1000).toFixed(0) + 'K';
1574
+ }).catch(function(){});
1575
+ }
1576
+ }
1577
+
1578
+ function animateParticle(pathId, color, duration, reverse) {
1579
+ var path = document.getElementById(pathId);
1580
+ if (!path) return;
1581
+ var svg = document.getElementById('flow-svg');
1582
+ if (!svg) return;
1583
+ var len = path.getTotalLength();
1584
+ var particle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1585
+ particle.setAttribute('r', '5');
1586
+ particle.setAttribute('fill', color);
1587
+ particle.style.filter = 'drop-shadow(0 0 8px ' + color + ')';
1588
+ svg.appendChild(particle);
1589
+ var glowCls = color === '#60a0ff' ? 'glow-blue' : color === '#f0c040' ? 'glow-yellow' : color === '#50e080' ? 'glow-green' : color === '#40a0b0' ? 'glow-cyan' : color === '#c0a0ff' ? 'glow-purple' : 'glow-red';
1590
+ path.classList.add(glowCls);
1591
+ var startT = performance.now();
1592
+ var trailN = 0;
1593
+ function step(now) {
1594
+ var t = Math.min((now - startT) / duration, 1);
1595
+ var dist = reverse ? (1 - t) * len : t * len;
1596
+ try {
1597
+ var pt = path.getPointAtLength(dist);
1598
+ particle.setAttribute('cx', pt.x);
1599
+ particle.setAttribute('cy', pt.y);
1600
+ } catch(e) { particle.remove(); path.classList.remove(glowCls); return; }
1601
+ if (trailN++ % 4 === 0) {
1602
+ var tr = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1603
+ tr.setAttribute('cx', particle.getAttribute('cx'));
1604
+ tr.setAttribute('cy', particle.getAttribute('cy'));
1605
+ tr.setAttribute('r', '3');
1606
+ tr.setAttribute('fill', color);
1607
+ tr.setAttribute('opacity', '0.5');
1608
+ svg.insertBefore(tr, particle);
1609
+ var trS = now;
1610
+ (function(el, s) {
1611
+ function fade(n) {
1612
+ var a = (n - s) / 400;
1613
+ if (a >= 1) { el.remove(); return; }
1614
+ el.setAttribute('opacity', String(0.5 * (1 - a)));
1615
+ el.setAttribute('r', String(3 * (1 - a * 0.5)));
1616
+ requestAnimationFrame(fade);
1617
+ }
1618
+ requestAnimationFrame(fade);
1619
+ })(tr, trS);
1620
+ }
1621
+ if (t < 1) requestAnimationFrame(step);
1622
+ else {
1623
+ particle.remove();
1624
+ setTimeout(function() { path.classList.remove(glowCls); }, 400);
1625
+ }
1626
+ }
1627
+ requestAnimationFrame(step);
1628
+ }
1629
+
1630
+ function highlightNode(nodeId, dur) {
1631
+ var node = document.getElementById(nodeId);
1632
+ if (!node) return;
1633
+ node.classList.add('active');
1634
+ setTimeout(function() { node.classList.remove('active'); }, dur || 2000);
1635
+ }
1636
+
1637
+ function triggerInbound(ch) {
1638
+ ch = ch || 'tg';
1639
+ var chNodeId = ch === 'tg' ? 'node-telegram' : ch === 'sig' ? 'node-signal' : 'node-whatsapp';
1640
+ highlightNode(chNodeId, 3000);
1641
+ animateParticle('path-human-' + ch, '#c0a0ff', 550, false);
1642
+ highlightNode('node-human', 2200);
1643
+ setTimeout(function() {
1644
+ animateParticle('path-' + ch + '-gw', '#60a0ff', 800, false);
1645
+ highlightNode('node-gateway', 2000);
1646
+ }, 400);
1647
+ setTimeout(function() {
1648
+ animateParticle('path-gw-brain', '#60a0ff', 600, false);
1649
+ highlightNode('node-brain', 2500);
1650
+ }, 1050);
1651
+ setTimeout(function() {
1652
+ animateParticle('path-brain-session', '#60a0ff', 400, false);
1653
+ highlightNode('node-session', 1500);
1654
+ }, 1550);
1655
+ setTimeout(function() { triggerInfraNetwork(); }, 300);
1656
+ }
1657
+
1658
+ function triggerToolCall(toolName) {
1659
+ var pathId = 'path-brain-' + toolName;
1660
+ animateParticle(pathId, '#f0c040', 700, false);
1661
+ highlightNode('node-' + toolName, 2500);
1662
+ setTimeout(function() {
1663
+ animateParticle(pathId, '#f0c040', 700, true);
1664
+ }, 900);
1665
+ var ind = document.getElementById('ind-' + toolName);
1666
+ if (ind) { ind.classList.add('active'); setTimeout(function() { ind.classList.remove('active'); }, 4000); }
1667
+ flowStats.activeTools[toolName] = true;
1668
+ setTimeout(function() { delete flowStats.activeTools[toolName]; }, 5000);
1669
+ if (toolName === 'exec') {
1670
+ setTimeout(function() { triggerInfraMachine(); triggerInfraRuntime(); }, 400);
1671
+ } else if (toolName === 'browser' || toolName === 'search') {
1672
+ setTimeout(function() { triggerInfraNetwork(); }, 400);
1673
+ } else if (toolName === 'memory') {
1674
+ setTimeout(function() { triggerInfraStorage(); }, 400);
1675
+ }
1676
+ }
1677
+
1678
+ function triggerOutbound(ch) {
1679
+ ch = ch || 'tg';
1680
+ animateParticle('path-gw-brain', '#50e080', 600, true);
1681
+ highlightNode('node-gateway', 2000);
1682
+ setTimeout(function() {
1683
+ animateParticle('path-' + ch + '-gw', '#50e080', 800, true);
1684
+ }, 500);
1685
+ setTimeout(function() {
1686
+ animateParticle('path-human-' + ch, '#50e080', 550, true);
1687
+ highlightNode('node-human', 1800);
1688
+ }, 1200);
1689
+ setTimeout(function() { triggerInfraNetwork(); }, 200);
1690
+ }
1691
+
1692
+ function triggerError() {
1693
+ var brain = document.getElementById('node-brain');
1694
+ if (!brain) return;
1695
+ var r = brain.querySelector('rect');
1696
+ if (r) { r.style.stroke = '#e04040'; setTimeout(function() { r.style.stroke = '#f0c040'; }, 2500); }
1697
+ }
1698
+
1699
+ function triggerInfraNetwork() {
1700
+ animateParticle('path-gw-network', '#40a0b0', 1200, false);
1701
+ highlightNode('node-network', 2500);
1702
+ }
1703
+ function triggerInfraRuntime() {
1704
+ animateParticle('path-brain-runtime', '#40a0b0', 1000, false);
1705
+ highlightNode('node-runtime', 2200);
1706
+ }
1707
+ function triggerInfraMachine() {
1708
+ animateParticle('path-brain-machine', '#40a0b0', 1000, false);
1709
+ highlightNode('node-machine', 2200);
1710
+ }
1711
+ function triggerInfraStorage() {
1712
+ animateParticle('path-memory-storage', '#40a0b0', 700, false);
1713
+ highlightNode('node-storage', 2000);
1714
+ }
1715
+
1716
+ var flowThrottles = {};
1717
+ function processFlowEvent(line) {
1718
+ flowStats.events++;
1719
+ var now = Date.now();
1720
+ var msg = '', level = '';
1721
+ try {
1722
+ var obj = JSON.parse(line);
1723
+ msg = ((obj.msg || '') + ' ' + (obj.message || '') + ' ' + (obj.name || '') + ' ' + (obj['0'] || '') + ' ' + (obj['1'] || '')).toLowerCase();
1724
+ level = (obj.logLevelName || obj.level || '').toLowerCase();
1725
+ } catch(e) { msg = line.toLowerCase(); }
1726
+
1727
+ if (level === 'error' || level === 'fatal') { triggerError(); return; }
1728
+
1729
+ if (msg.includes('run start') && msg.includes('messagechannel')) {
1730
+ if (now - (flowThrottles['inbound']||0) < 500) return;
1731
+ flowThrottles['inbound'] = now;
1732
+ var ch = 'tg';
1733
+ if (msg.includes('signal')) ch = 'sig';
1734
+ else if (msg.includes('whatsapp')) ch = 'wa';
1735
+ triggerInbound(ch);
1736
+ flowStats.msgTimestamps.push(now);
1737
+ return;
1738
+ }
1739
+ if (msg.includes('inbound') || msg.includes('dispatching') || msg.includes('message received')) {
1740
+ triggerInbound('tg');
1741
+ flowStats.msgTimestamps.push(now);
1742
+ return;
1743
+ }
1744
+
1745
+ if ((msg.includes('tool start') || msg.includes('tool-call') || msg.includes('tool_use')) && !msg.includes('tool end')) {
1746
+ var toolName = '';
1747
+ var toolMatch = msg.match(/tool=(\w+)/);
1748
+ if (toolMatch) toolName = toolMatch[1].toLowerCase();
1749
+ var flowTool = 'exec';
1750
+ if (toolName === 'exec' || toolName === 'read' || toolName === 'write' || toolName === 'edit' || toolName === 'process') {
1751
+ flowTool = 'exec';
1752
+ } else if (toolName.includes('browser') || toolName === 'canvas') {
1753
+ flowTool = 'browser';
1754
+ } else if (toolName === 'web_search' || toolName === 'web_fetch') {
1755
+ flowTool = 'search';
1756
+ } else if (toolName === 'cron' || toolName === 'sessions_spawn' || toolName === 'sessions_send') {
1757
+ flowTool = 'cron';
1758
+ } else if (toolName === 'tts') {
1759
+ flowTool = 'tts';
1760
+ } else if (toolName === 'memory_search' || toolName === 'memory_get') {
1761
+ flowTool = 'memory';
1762
+ } else if (toolName === 'message') {
1763
+ if (now - (flowThrottles['outbound']||0) < 500) return;
1764
+ flowThrottles['outbound'] = now;
1765
+ triggerOutbound('tg'); return;
1766
+ }
1767
+ if (now - (flowThrottles['tool-'+flowTool]||0) < 300) return;
1768
+ flowThrottles['tool-'+flowTool] = now;
1769
+ triggerToolCall(flowTool); return;
1770
+ }
1771
+
1772
+ var toolMap = {
1773
+ 'exec': ['exec','shell','command'],
1774
+ 'browser': ['browser','screenshot','snapshot'],
1775
+ 'search': ['web_search','web_fetch'],
1776
+ 'cron': ['cron','schedule'],
1777
+ 'tts': ['tts','speech','voice'],
1778
+ 'memory': ['memory_search','memory_get']
1779
+ };
1780
+ if (msg.includes('tool') || msg.includes('invoke') || msg.includes('calling')) {
1781
+ for (var t in toolMap) {
1782
+ for (var i = 0; i < toolMap[t].length; i++) {
1783
+ if (msg.includes(toolMap[t][i])) { triggerToolCall(t); return; }
1784
+ }
1785
+ }
1786
+ }
1787
+
1788
+ if (msg.includes('response sent') || msg.includes('completion') || msg.includes('reply sent') || msg.includes('deliver') || (msg.includes('lane task done') && msg.includes('main'))) {
1789
+ var ch = 'tg';
1790
+ if (msg.includes('signal')) ch = 'sig';
1791
+ else if (msg.includes('whatsapp')) ch = 'wa';
1792
+ triggerOutbound(ch);
1793
+ return;
1794
+ }
1795
+ }
1796
+ </script>
1797
+ </body>
1798
+ </html>
1799
+ """
1800
+
1801
+
1802
+ # โ”€โ”€ API Routes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1803
+
1804
+ @app.route('/')
1805
+ def index():
1806
+ return render_template_string(DASHBOARD_HTML)
1807
+
1808
+
1809
+ @app.route('/api/overview')
1810
+ def api_overview():
1811
+ sessions = _get_sessions()
1812
+ main = next((s for s in sessions if s.get('key', '').endswith(':main')), {})
1813
+
1814
+ crons = _get_crons()
1815
+ enabled = len([j for j in crons if j.get('enabled')])
1816
+ disabled = len(crons) - enabled
1817
+
1818
+ mem_files = _get_memory_files()
1819
+ total_size = sum(f['size'] for f in mem_files)
1820
+
1821
+ # System info
1822
+ system = []
1823
+ try:
1824
+ disk = subprocess.run(['df', '-h', '/'], capture_output=True, text=True).stdout.strip().split('\n')[-1].split()
1825
+ disk_pct = int(disk[4].replace('%', '')) if len(disk) > 4 else 0
1826
+ disk_color = 'green' if disk_pct < 80 else ('yellow' if disk_pct < 90 else 'red')
1827
+ system.append(['Disk /', f'{disk[2]} / {disk[1]} ({disk[4]})', disk_color])
1828
+ except Exception:
1829
+ system.append(['Disk /', 'โ€”', ''])
1830
+
1831
+ try:
1832
+ mem = subprocess.run(['free', '-h'], capture_output=True, text=True).stdout.strip().split('\n')[1].split()
1833
+ system.append(['RAM', f'{mem[2]} / {mem[1]}', ''])
1834
+ except Exception:
1835
+ system.append(['RAM', 'โ€”', ''])
1836
+
1837
+ try:
1838
+ load = open('/proc/loadavg').read().split()[:3]
1839
+ system.append(['Load', ' '.join(load), ''])
1840
+ except Exception:
1841
+ system.append(['Load', 'โ€”', ''])
1842
+
1843
+ try:
1844
+ uptime = subprocess.run(['uptime', '-p'], capture_output=True, text=True).stdout.strip()
1845
+ system.append(['Uptime', uptime.replace('up ', ''), ''])
1846
+ except Exception:
1847
+ system.append(['Uptime', 'โ€”', ''])
1848
+
1849
+ gw = subprocess.run(['pgrep', '-f', 'moltbot'], capture_output=True, text=True)
1850
+ system.append(['Gateway', 'Running' if gw.returncode == 0 else 'Stopped',
1851
+ 'green' if gw.returncode == 0 else 'red'])
1852
+
1853
+ # Infrastructure details for Flow tab
1854
+ infra = {
1855
+ 'userName': USER_NAME,
1856
+ 'network': get_local_ip(),
1857
+ }
1858
+ try:
1859
+ import platform
1860
+ uname = platform.uname()
1861
+ infra['machine'] = uname.node
1862
+ infra['runtime'] = f'Node.js ยท {uname.system} {uname.release.split("-")[0]}'
1863
+ except Exception:
1864
+ infra['machine'] = 'Host'
1865
+ infra['runtime'] = 'Runtime'
1866
+
1867
+ try:
1868
+ disk_info = subprocess.run(['df', '-h', '/'], capture_output=True, text=True).stdout.strip().split('\n')[-1].split()
1869
+ infra['storage'] = f'{disk_info[1]} root'
1870
+ except Exception:
1871
+ infra['storage'] = 'Disk'
1872
+
1873
+ return jsonify({
1874
+ 'model': main.get('model', 'claude-opus-4-5') or 'claude-opus-4-5',
1875
+ 'provider': 'anthropic',
1876
+ 'sessionCount': len(sessions),
1877
+ 'mainSessionUpdated': main.get('updatedAt'),
1878
+ 'mainTokens': main.get('totalTokens', 0),
1879
+ 'contextWindow': main.get('contextTokens', 200000),
1880
+ 'cronCount': len(crons),
1881
+ 'cronEnabled': enabled,
1882
+ 'cronDisabled': disabled,
1883
+ 'memoryCount': len(mem_files),
1884
+ 'memorySize': total_size,
1885
+ 'system': system,
1886
+ 'infra': infra,
1887
+ })
1888
+
1889
+
1890
+ @app.route('/api/sessions')
1891
+ def api_sessions():
1892
+ return jsonify({'sessions': _get_sessions()})
1893
+
1894
+
1895
+ @app.route('/api/crons')
1896
+ def api_crons():
1897
+ return jsonify({'jobs': _get_crons()})
1898
+
1899
+
1900
+ @app.route('/api/logs')
1901
+ def api_logs():
1902
+ lines_count = int(request.args.get('lines', 100))
1903
+ today = datetime.now().strftime('%Y-%m-%d')
1904
+ log_file = os.path.join(LOG_DIR, f'moltbot-{today}.log')
1905
+ lines = []
1906
+ if os.path.exists(log_file):
1907
+ result = subprocess.run(['tail', f'-{lines_count}', log_file], capture_output=True, text=True)
1908
+ lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
1909
+ return jsonify({'lines': lines})
1910
+
1911
+
1912
+ @app.route('/api/logs-stream')
1913
+ def api_logs_stream():
1914
+ """SSE endpoint โ€” streams new log lines in real-time."""
1915
+ today = datetime.now().strftime('%Y-%m-%d')
1916
+ log_file = os.path.join(LOG_DIR, f'moltbot-{today}.log')
1917
+
1918
+ def generate():
1919
+ if not os.path.exists(log_file):
1920
+ yield 'data: {"line":"No log file found"}\n\n'
1921
+ return
1922
+ proc = subprocess.Popen(
1923
+ ['tail', '-f', '-n', '0', log_file],
1924
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
1925
+ )
1926
+ try:
1927
+ while True:
1928
+ line = proc.stdout.readline()
1929
+ if line:
1930
+ yield f'data: {json.dumps({"line": line.rstrip()})}\n\n'
1931
+ except GeneratorExit:
1932
+ proc.kill()
1933
+
1934
+ return Response(generate(), mimetype='text/event-stream',
1935
+ headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
1936
+
1937
+
1938
+ @app.route('/api/memory-files')
1939
+ def api_memory_files():
1940
+ return jsonify(_get_memory_files())
1941
+
1942
+
1943
+ @app.route('/api/file')
1944
+ def api_view_file():
1945
+ """Return the contents of a memory file."""
1946
+ path = request.args.get('path', '')
1947
+ full = os.path.normpath(os.path.join(WORKSPACE, path))
1948
+ if not full.startswith(os.path.normpath(WORKSPACE)):
1949
+ return jsonify({'error': 'Access denied'}), 403
1950
+ if not os.path.exists(full):
1951
+ return jsonify({'error': 'File not found'}), 404
1952
+ try:
1953
+ with open(full, 'r') as f:
1954
+ content = f.read(100_000)
1955
+ return jsonify({'path': path, 'content': content})
1956
+ except Exception as e:
1957
+ return jsonify({'error': str(e)}), 500
1958
+
1959
+
1960
+ # โ”€โ”€ OTLP Receiver Endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1961
+
1962
+ @app.route('/v1/metrics', methods=['POST'])
1963
+ def otlp_metrics():
1964
+ """OTLP/HTTP receiver for metrics (protobuf)."""
1965
+ if not _HAS_OTEL_PROTO:
1966
+ return jsonify({
1967
+ 'error': 'opentelemetry-proto not installed',
1968
+ 'message': 'Install OTLP support: pip install openclaw-dashboard[otel] '
1969
+ 'or: pip install opentelemetry-proto protobuf',
1970
+ }), 501
1971
+
1972
+ try:
1973
+ pb_data = request.get_data()
1974
+ _process_otlp_metrics(pb_data)
1975
+ return '{}', 200, {'Content-Type': 'application/json'}
1976
+ except Exception as e:
1977
+ return jsonify({'error': str(e)}), 400
1978
+
1979
+
1980
+ @app.route('/v1/traces', methods=['POST'])
1981
+ def otlp_traces():
1982
+ """OTLP/HTTP receiver for traces (protobuf)."""
1983
+ if not _HAS_OTEL_PROTO:
1984
+ return jsonify({
1985
+ 'error': 'opentelemetry-proto not installed',
1986
+ 'message': 'Install OTLP support: pip install openclaw-dashboard[otel] '
1987
+ 'or: pip install opentelemetry-proto protobuf',
1988
+ }), 501
1989
+
1990
+ try:
1991
+ pb_data = request.get_data()
1992
+ _process_otlp_traces(pb_data)
1993
+ return '{}', 200, {'Content-Type': 'application/json'}
1994
+ except Exception as e:
1995
+ return jsonify({'error': str(e)}), 400
1996
+
1997
+
1998
+ @app.route('/api/otel-status')
1999
+ def api_otel_status():
2000
+ """Return OTLP receiver status."""
2001
+ counts = {}
2002
+ with _metrics_lock:
2003
+ for k in metrics_store:
2004
+ counts[k] = len(metrics_store[k])
2005
+ return jsonify({
2006
+ 'available': _HAS_OTEL_PROTO,
2007
+ 'hasData': _has_otel_data(),
2008
+ 'lastReceived': _otel_last_received,
2009
+ 'counts': counts,
2010
+ })
2011
+
2012
+
2013
+ # โ”€โ”€ New Feature APIs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2014
+
2015
+ @app.route('/api/usage')
2016
+ def api_usage():
2017
+ """Token/cost tracking โ€” OTLP data preferred, falls back to log parsing."""
2018
+ # Prefer OTLP data when available
2019
+ if _has_otel_data():
2020
+ return jsonify(_get_otel_usage_data())
2021
+
2022
+ # Fallback: parse session JSONL files
2023
+ sessions_dir = SESSIONS_DIR or os.path.expanduser('~/.clawdbot/agents/main/sessions')
2024
+ daily_tokens = {}
2025
+ if os.path.isdir(sessions_dir):
2026
+ for fname in os.listdir(sessions_dir):
2027
+ if not fname.endswith('.jsonl'):
2028
+ continue
2029
+ fpath = os.path.join(sessions_dir, fname)
2030
+ try:
2031
+ fmtime = datetime.fromtimestamp(os.path.getmtime(fpath))
2032
+ with open(fpath, 'r') as f:
2033
+ for line in f:
2034
+ try:
2035
+ obj = json.loads(line.strip())
2036
+ # Extract tokens from various possible fields
2037
+ tokens = 0
2038
+ usage = obj.get('usage') or obj.get('tokens_used') or {}
2039
+ if isinstance(usage, dict):
2040
+ tokens = (usage.get('total_tokens') or usage.get('totalTokens')
2041
+ or (usage.get('input_tokens', 0) + usage.get('output_tokens', 0))
2042
+ or 0)
2043
+ elif isinstance(usage, (int, float)):
2044
+ tokens = int(usage)
2045
+ # If no explicit tokens, estimate from content length
2046
+ if not tokens:
2047
+ content = obj.get('content', '')
2048
+ if isinstance(content, str) and len(content) > 0:
2049
+ tokens = max(1, len(content) // 4) # rough: 1 token โ‰ˆ 4 chars
2050
+ elif isinstance(content, list):
2051
+ total_len = sum(len(str(c.get('text', ''))) for c in content if isinstance(c, dict))
2052
+ tokens = max(1, total_len // 4) if total_len else 0
2053
+ # Get date
2054
+ ts = obj.get('timestamp') or obj.get('time') or obj.get('created_at')
2055
+ if ts:
2056
+ if isinstance(ts, (int, float)):
2057
+ dt = datetime.fromtimestamp(ts / 1000 if ts > 1e12 else ts)
2058
+ else:
2059
+ try:
2060
+ dt = datetime.fromisoformat(str(ts).replace('Z', '+00:00'))
2061
+ except Exception:
2062
+ dt = fmtime
2063
+ else:
2064
+ dt = fmtime
2065
+ day = dt.strftime('%Y-%m-%d')
2066
+ if tokens > 0:
2067
+ daily_tokens[day] = daily_tokens.get(day, 0) + tokens
2068
+ except (json.JSONDecodeError, ValueError):
2069
+ pass
2070
+ except Exception:
2071
+ pass
2072
+
2073
+ today = datetime.now()
2074
+ days = []
2075
+ for i in range(13, -1, -1):
2076
+ d = today - timedelta(days=i)
2077
+ ds = d.strftime('%Y-%m-%d')
2078
+ days.append({'date': ds, 'tokens': daily_tokens.get(ds, 0)})
2079
+
2080
+ today_str = today.strftime('%Y-%m-%d')
2081
+ week_start = (today - timedelta(days=today.weekday())).strftime('%Y-%m-%d')
2082
+ month_start = today.strftime('%Y-%m-01')
2083
+ today_tok = daily_tokens.get(today_str, 0)
2084
+ week_tok = sum(v for k, v in daily_tokens.items() if k >= week_start)
2085
+ month_tok = sum(v for k, v in daily_tokens.items() if k >= month_start)
2086
+ # Cost estimates: Claude Opus ~$15/M in + $75/M out; average ~$30/M
2087
+ cpt = 30.0 / 1_000_000
2088
+ return jsonify({
2089
+ 'days': days, 'today': today_tok, 'week': week_tok, 'month': month_tok,
2090
+ 'todayCost': round(today_tok * cpt, 2),
2091
+ 'weekCost': round(week_tok * cpt, 2),
2092
+ 'monthCost': round(month_tok * cpt, 2),
2093
+ })
2094
+
2095
+
2096
+ @app.route('/api/transcripts')
2097
+ def api_transcripts():
2098
+ """List available session transcript .jsonl files."""
2099
+ sessions_dir = SESSIONS_DIR or os.path.expanduser('~/.clawdbot/agents/main/sessions')
2100
+ transcripts = []
2101
+ if os.path.isdir(sessions_dir):
2102
+ for fname in sorted(os.listdir(sessions_dir), key=lambda f: os.path.getmtime(os.path.join(sessions_dir, f)), reverse=True):
2103
+ if not fname.endswith('.jsonl') or 'deleted' in fname:
2104
+ continue
2105
+ fpath = os.path.join(sessions_dir, fname)
2106
+ try:
2107
+ msg_count = 0
2108
+ with open(fpath) as f:
2109
+ for _ in f:
2110
+ msg_count += 1
2111
+ transcripts.append({
2112
+ 'id': fname.replace('.jsonl', ''),
2113
+ 'name': fname.replace('.jsonl', '')[:40],
2114
+ 'messages': msg_count,
2115
+ 'size': os.path.getsize(fpath),
2116
+ 'modified': int(os.path.getmtime(fpath) * 1000),
2117
+ })
2118
+ except Exception:
2119
+ pass
2120
+ return jsonify({'transcripts': transcripts[:50]})
2121
+
2122
+
2123
+ @app.route('/api/transcript/<session_id>')
2124
+ def api_transcript(session_id):
2125
+ """Parse and return a session transcript for the chat viewer."""
2126
+ sessions_dir = SESSIONS_DIR or os.path.expanduser('~/.clawdbot/agents/main/sessions')
2127
+ fpath = os.path.join(sessions_dir, session_id + '.jsonl')
2128
+ # Sanitize path
2129
+ fpath = os.path.normpath(fpath)
2130
+ if not fpath.startswith(os.path.normpath(sessions_dir)):
2131
+ return jsonify({'error': 'Access denied'}), 403
2132
+ if not os.path.exists(fpath):
2133
+ return jsonify({'error': 'Transcript not found'}), 404
2134
+
2135
+ messages = []
2136
+ model = None
2137
+ total_tokens = 0
2138
+ first_ts = None
2139
+ last_ts = None
2140
+ try:
2141
+ with open(fpath) as f:
2142
+ for line in f:
2143
+ try:
2144
+ obj = json.loads(line.strip())
2145
+ role = obj.get('role', obj.get('type', 'unknown'))
2146
+ content = obj.get('content', '')
2147
+ if isinstance(content, list):
2148
+ parts = []
2149
+ for part in content:
2150
+ if isinstance(part, dict):
2151
+ parts.append(part.get('text', str(part)))
2152
+ else:
2153
+ parts.append(str(part))
2154
+ content = '\n'.join(parts)
2155
+ elif not isinstance(content, str):
2156
+ content = str(content) if content else ''
2157
+ # Tool use handling
2158
+ if obj.get('tool_calls') or obj.get('tool_use'):
2159
+ tools = obj.get('tool_calls') or obj.get('tool_use') or []
2160
+ if isinstance(tools, list):
2161
+ for tc in tools:
2162
+ tname = tc.get('name', tc.get('function', {}).get('name', 'tool'))
2163
+ messages.append({
2164
+ 'role': 'tool',
2165
+ 'content': f"[Tool Call: {tname}]\n{json.dumps(tc.get('input', tc.get('arguments', {})), indent=2)[:500]}",
2166
+ 'timestamp': obj.get('timestamp') or obj.get('time'),
2167
+ })
2168
+ if role == 'tool_result':
2169
+ role = 'tool'
2170
+ ts = obj.get('timestamp') or obj.get('time') or obj.get('created_at')
2171
+ if ts:
2172
+ if isinstance(ts, (int, float)):
2173
+ ts_ms = int(ts * 1000) if ts < 1e12 else int(ts)
2174
+ else:
2175
+ try:
2176
+ ts_ms = int(datetime.fromisoformat(str(ts).replace('Z', '+00:00')).timestamp() * 1000)
2177
+ except Exception:
2178
+ ts_ms = None
2179
+ if ts_ms:
2180
+ if not first_ts or ts_ms < first_ts:
2181
+ first_ts = ts_ms
2182
+ if not last_ts or ts_ms > last_ts:
2183
+ last_ts = ts_ms
2184
+ else:
2185
+ ts_ms = None
2186
+ if not model:
2187
+ model = obj.get('model')
2188
+ usage = obj.get('usage', {})
2189
+ if isinstance(usage, dict):
2190
+ total_tokens += usage.get('total_tokens', 0) or (
2191
+ usage.get('input_tokens', 0) + usage.get('output_tokens', 0))
2192
+ if content or role in ('user', 'assistant', 'system'):
2193
+ messages.append({
2194
+ 'role': role, 'content': content, 'timestamp': ts_ms,
2195
+ })
2196
+ except (json.JSONDecodeError, ValueError):
2197
+ pass
2198
+ except Exception as e:
2199
+ return jsonify({'error': str(e)}), 500
2200
+
2201
+ duration = None
2202
+ if first_ts and last_ts and last_ts > first_ts:
2203
+ dur_sec = (last_ts - first_ts) / 1000
2204
+ if dur_sec < 60:
2205
+ duration = f'{dur_sec:.0f}s'
2206
+ elif dur_sec < 3600:
2207
+ duration = f'{dur_sec / 60:.0f}m'
2208
+ else:
2209
+ duration = f'{dur_sec / 3600:.1f}h'
2210
+
2211
+ return jsonify({
2212
+ 'name': session_id[:40],
2213
+ 'messageCount': len(messages),
2214
+ 'model': model,
2215
+ 'totalTokens': total_tokens,
2216
+ 'duration': duration,
2217
+ 'messages': messages[:500], # Cap at 500 messages
2218
+ })
2219
+
2220
+
2221
+ @app.route('/api/heatmap')
2222
+ def api_heatmap():
2223
+ """Activity heatmap โ€” events per hour for the last 7 days."""
2224
+ now = datetime.now()
2225
+ # Initialize 7 days ร— 24 hours grid
2226
+ grid = {}
2227
+ day_labels = []
2228
+ for i in range(6, -1, -1):
2229
+ d = now - timedelta(days=i)
2230
+ ds = d.strftime('%Y-%m-%d')
2231
+ grid[ds] = [0] * 24
2232
+ day_labels.append({'date': ds, 'label': d.strftime('%a %d')})
2233
+
2234
+ # Parse log files for the last 7 days
2235
+ for i in range(7):
2236
+ d = now - timedelta(days=i)
2237
+ ds = d.strftime('%Y-%m-%d')
2238
+ log_file = os.path.join(LOG_DIR, f'moltbot-{ds}.log')
2239
+ if not os.path.exists(log_file):
2240
+ continue
2241
+ try:
2242
+ with open(log_file) as f:
2243
+ for line in f:
2244
+ try:
2245
+ obj = json.loads(line.strip())
2246
+ ts = obj.get('time') or (obj.get('_meta', {}).get('date') if isinstance(obj.get('_meta'), dict) else None)
2247
+ if ts:
2248
+ if isinstance(ts, (int, float)):
2249
+ dt = datetime.fromtimestamp(ts / 1000 if ts > 1e12 else ts)
2250
+ else:
2251
+ dt = datetime.fromisoformat(str(ts).replace('Z', '+00:00').replace('+00:00', ''))
2252
+ hour = dt.hour
2253
+ day_key = dt.strftime('%Y-%m-%d')
2254
+ if day_key in grid:
2255
+ grid[day_key][hour] += 1
2256
+ except Exception:
2257
+ # Count non-JSON lines too
2258
+ if ds in grid:
2259
+ grid[ds][12] += 1 # default to noon
2260
+ except Exception:
2261
+ pass
2262
+
2263
+ max_val = max(max(hours) for hours in grid.values()) if grid else 0
2264
+ days = []
2265
+ for dl in day_labels:
2266
+ days.append({'label': dl['label'], 'hours': grid.get(dl['date'], [0] * 24)})
2267
+
2268
+ return jsonify({'days': days, 'max': max_val})
2269
+
2270
+
2271
+ @app.route('/api/health')
2272
+ def api_health():
2273
+ """System health checks."""
2274
+ checks = []
2275
+ # 1. Gateway โ€” check if port 18789 is responding
2276
+ try:
2277
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
2278
+ s.settimeout(2)
2279
+ result = s.connect_ex(('127.0.0.1', 18789))
2280
+ s.close()
2281
+ if result == 0:
2282
+ checks.append({'id': 'gateway', 'status': 'healthy', 'color': 'green', 'detail': 'Port 18789 responding'})
2283
+ else:
2284
+ # Fallback: check process
2285
+ gw = subprocess.run(['pgrep', '-f', 'moltbot'], capture_output=True, text=True)
2286
+ if gw.returncode == 0:
2287
+ checks.append({'id': 'gateway', 'status': 'warning', 'color': 'yellow', 'detail': 'Process running, port not responding'})
2288
+ else:
2289
+ checks.append({'id': 'gateway', 'status': 'critical', 'color': 'red', 'detail': 'Not running'})
2290
+ except Exception:
2291
+ checks.append({'id': 'gateway', 'status': 'critical', 'color': 'red', 'detail': 'Check failed'})
2292
+
2293
+ # 2. Disk space โ€” warn if < 5GB free
2294
+ try:
2295
+ st = os.statvfs('/')
2296
+ free_gb = (st.f_bavail * st.f_frsize) / (1024 ** 3)
2297
+ total_gb = (st.f_blocks * st.f_frsize) / (1024 ** 3)
2298
+ pct_used = ((total_gb - free_gb) / total_gb) * 100
2299
+ if free_gb < 2:
2300
+ checks.append({'id': 'disk', 'status': 'critical', 'color': 'red', 'detail': f'{free_gb:.1f} GB free ({pct_used:.0f}% used)'})
2301
+ elif free_gb < 5:
2302
+ checks.append({'id': 'disk', 'status': 'warning', 'color': 'yellow', 'detail': f'{free_gb:.1f} GB free ({pct_used:.0f}% used)'})
2303
+ else:
2304
+ checks.append({'id': 'disk', 'status': 'healthy', 'color': 'green', 'detail': f'{free_gb:.1f} GB free ({pct_used:.0f}% used)'})
2305
+ except Exception:
2306
+ checks.append({'id': 'disk', 'status': 'warning', 'color': 'yellow', 'detail': 'Check failed'})
2307
+
2308
+ # 3. Memory usage (RSS of this process + overall)
2309
+ try:
2310
+ import resource
2311
+ rss_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 # KB -> MB on Linux
2312
+ mem = subprocess.run(['free', '-m'], capture_output=True, text=True)
2313
+ mem_parts = mem.stdout.strip().split('\n')[1].split()
2314
+ used_mb = int(mem_parts[2])
2315
+ total_mb = int(mem_parts[1])
2316
+ pct = (used_mb / total_mb) * 100
2317
+ if pct > 90:
2318
+ checks.append({'id': 'memory', 'status': 'critical', 'color': 'red', 'detail': f'{used_mb}MB / {total_mb}MB ({pct:.0f}%)'})
2319
+ elif pct > 75:
2320
+ checks.append({'id': 'memory', 'status': 'warning', 'color': 'yellow', 'detail': f'{used_mb}MB / {total_mb}MB ({pct:.0f}%)'})
2321
+ else:
2322
+ checks.append({'id': 'memory', 'status': 'healthy', 'color': 'green', 'detail': f'{used_mb}MB / {total_mb}MB ({pct:.0f}%)'})
2323
+ except Exception:
2324
+ checks.append({'id': 'memory', 'status': 'warning', 'color': 'yellow', 'detail': 'Check failed'})
2325
+
2326
+ # 4. Uptime
2327
+ try:
2328
+ uptime = subprocess.run(['uptime', '-p'], capture_output=True, text=True).stdout.strip().replace('up ', '')
2329
+ checks.append({'id': 'uptime', 'status': 'healthy', 'color': 'green', 'detail': uptime})
2330
+ except Exception:
2331
+ checks.append({'id': 'uptime', 'status': 'warning', 'color': 'yellow', 'detail': 'Unknown'})
2332
+
2333
+ # 5. OTLP Metrics
2334
+ if _has_otel_data():
2335
+ ago = time.time() - _otel_last_received
2336
+ if ago < 300: # <5min
2337
+ total = sum(len(metrics_store[k]) for k in metrics_store)
2338
+ checks.append({'id': 'otel', 'status': 'healthy', 'color': 'green',
2339
+ 'detail': f'Connected โ€” {total} data points, last {int(ago)}s ago'})
2340
+ elif ago < 3600:
2341
+ checks.append({'id': 'otel', 'status': 'warning', 'color': 'yellow',
2342
+ 'detail': f'Stale โ€” last data {int(ago/60)}m ago'})
2343
+ else:
2344
+ checks.append({'id': 'otel', 'status': 'warning', 'color': 'yellow',
2345
+ 'detail': f'Stale โ€” last data {int(ago/3600)}h ago'})
2346
+ elif _HAS_OTEL_PROTO:
2347
+ checks.append({'id': 'otel', 'status': 'warning', 'color': 'yellow',
2348
+ 'detail': 'OTLP ready โ€” no data received yet'})
2349
+ else:
2350
+ checks.append({'id': 'otel', 'status': 'warning', 'color': 'yellow',
2351
+ 'detail': 'Not installed โ€” pip install openclaw-dashboard[otel]'})
2352
+
2353
+ return jsonify({'checks': checks})
2354
+
2355
+
2356
+ @app.route('/api/health-stream')
2357
+ def api_health_stream():
2358
+ """SSE endpoint โ€” auto-refresh health checks every 30 seconds."""
2359
+ def generate():
2360
+ while True:
2361
+ try:
2362
+ with app.test_request_context():
2363
+ resp = api_health()
2364
+ data = resp.get_json()
2365
+ yield f'data: {json.dumps(data)}\n\n'
2366
+ except Exception:
2367
+ yield f'data: {json.dumps({"checks": []})}\n\n'
2368
+ time.sleep(30)
2369
+
2370
+ return Response(generate(), mimetype='text/event-stream',
2371
+ headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
2372
+
2373
+
2374
+ # โ”€โ”€ Data Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2375
+
2376
+ def _get_sessions():
2377
+ """Read active sessions from the session directory."""
2378
+ sessions = []
2379
+ try:
2380
+ base = SESSIONS_DIR or os.path.expanduser('~/.clawdbot/agents/main/sessions')
2381
+ if not os.path.isdir(base):
2382
+ return sessions
2383
+ idx_files = sorted(
2384
+ [f for f in os.listdir(base) if f.endswith('.jsonl') and 'deleted' not in f],
2385
+ key=lambda f: os.path.getmtime(os.path.join(base, f)),
2386
+ reverse=True
2387
+ )
2388
+ for fname in idx_files[:30]:
2389
+ fpath = os.path.join(base, fname)
2390
+ try:
2391
+ mtime = os.path.getmtime(fpath)
2392
+ size = os.path.getsize(fpath)
2393
+ with open(fpath) as f:
2394
+ first = json.loads(f.readline())
2395
+ sid = fname.replace('.jsonl', '')
2396
+ sessions.append({
2397
+ 'sessionId': sid,
2398
+ 'key': sid[:12] + '...',
2399
+ 'displayName': sid[:20],
2400
+ 'updatedAt': int(mtime * 1000),
2401
+ 'model': 'claude-opus-4-5',
2402
+ 'channel': 'unknown',
2403
+ 'totalTokens': size,
2404
+ 'contextTokens': 200000,
2405
+ })
2406
+ except Exception:
2407
+ pass
2408
+ except Exception:
2409
+ pass
2410
+ return sessions
2411
+
2412
+
2413
+ def _get_crons():
2414
+ """Read crons from moltbot state."""
2415
+ try:
2416
+ crons_file = os.path.expanduser('~/.clawdbot/cron/jobs.json')
2417
+ if os.path.exists(crons_file):
2418
+ with open(crons_file) as f:
2419
+ data = json.load(f)
2420
+ if isinstance(data, list):
2421
+ return data
2422
+ if isinstance(data, dict):
2423
+ return data.get('jobs', list(data.values()))
2424
+ except Exception:
2425
+ pass
2426
+ return []
2427
+
2428
+
2429
+ def _get_memory_files():
2430
+ """List workspace memory files."""
2431
+ result = []
2432
+ for name in ['MEMORY.md', 'SOUL.md', 'IDENTITY.md', 'USER.md', 'AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md']:
2433
+ path = os.path.join(WORKSPACE, name)
2434
+ if os.path.exists(path):
2435
+ result.append({'path': name, 'size': os.path.getsize(path)})
2436
+ if os.path.isdir(MEMORY_DIR):
2437
+ pattern = os.path.join(MEMORY_DIR, '*.md')
2438
+ for f in sorted(glob.glob(pattern), reverse=True):
2439
+ name = 'memory/' + os.path.basename(f)
2440
+ result.append({'path': name, 'size': os.path.getsize(f)})
2441
+ return result
2442
+
2443
+
2444
+ # โ”€โ”€ CLI Entry Point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2445
+
2446
+ BANNER = r"""
2447
+ ___ ____ _
2448
+ / _ \ _ __ ___ _ __ / ___| | __ ___ __
2449
+ | | | | '_ \ / _ \ '_ \| | | |/ _` \ \ /\ / /
2450
+ | |_| | |_) | __/ | | | |___| | (_| |\ V V /
2451
+ \___/| .__/ \___|_| |_|\____|_|\__,_| \_/\_/
2452
+ |_| Dashboard v{version}
2453
+
2454
+ ๐Ÿฆž See your agent think
2455
+
2456
+ Tabs: Overview ยท ๐Ÿ“Š Usage ยท Sessions ยท Crons ยท Logs
2457
+ Memory ยท ๐Ÿ“œ Transcripts ยท Flow
2458
+ New: ๐Ÿ“ก OTLP receiver ยท Real-time metrics ยท Model breakdown
2459
+ """
2460
+
2461
+
2462
+ def main():
2463
+ parser = argparse.ArgumentParser(
2464
+ description="OpenClaw Dashboard โ€” Real-time observability for your AI agent",
2465
+ formatter_class=argparse.RawDescriptionHelpFormatter,
2466
+ epilog="Environment variables:\n"
2467
+ " OPENCLAW_HOME Agent workspace directory\n"
2468
+ " OPENCLAW_LOG_DIR Log directory (default: /tmp/moltbot)\n"
2469
+ " OPENCLAW_METRICS_FILE Path to metrics persistence JSON file\n"
2470
+ " OPENCLAW_USER Your name in the Flow visualization\n"
2471
+ )
2472
+ parser.add_argument('--port', '-p', type=int, default=8900, help='Port (default: 8900)')
2473
+ parser.add_argument('--host', '-H', type=str, default='0.0.0.0', help='Host (default: 0.0.0.0)')
2474
+ parser.add_argument('--workspace', '-w', type=str, help='Agent workspace directory')
2475
+ parser.add_argument('--log-dir', '-l', type=str, help='Log directory')
2476
+ parser.add_argument('--sessions-dir', '-s', type=str, help='Sessions directory (transcript .jsonl files)')
2477
+ parser.add_argument('--metrics-file', '-m', type=str, help='Path to metrics persistence JSON file')
2478
+ parser.add_argument('--name', '-n', type=str, help='Your name (shown in Flow tab)')
2479
+ parser.add_argument('--version', '-v', action='version', version=f'openclaw-dashboard {__version__}')
2480
+
2481
+ args = parser.parse_args()
2482
+ detect_config(args)
2483
+
2484
+ # Metrics file config
2485
+ global METRICS_FILE
2486
+ if args.metrics_file:
2487
+ METRICS_FILE = os.path.expanduser(args.metrics_file)
2488
+ elif os.environ.get('OPENCLAW_METRICS_FILE'):
2489
+ METRICS_FILE = os.path.expanduser(os.environ['OPENCLAW_METRICS_FILE'])
2490
+
2491
+ # Load persisted metrics and start flush thread
2492
+ _load_metrics_from_disk()
2493
+ _start_metrics_flush_thread()
2494
+
2495
+ # Print banner
2496
+ print(BANNER.format(version=__version__))
2497
+ print(f" Workspace: {WORKSPACE}")
2498
+ print(f" Sessions: {SESSIONS_DIR}")
2499
+ print(f" Logs: {LOG_DIR}")
2500
+ print(f" Metrics: {_metrics_file_path()}")
2501
+ print(f" OTLP: {'โœ… Ready (opentelemetry-proto installed)' if _HAS_OTEL_PROTO else 'โŒ Not available (pip install openclaw-dashboard[otel])'}")
2502
+ print(f" User: {USER_NAME}")
2503
+ print()
2504
+
2505
+ local_ip = get_local_ip()
2506
+ print(f" โ†’ http://localhost:{args.port}")
2507
+ if local_ip != '127.0.0.1':
2508
+ print(f" โ†’ http://{local_ip}:{args.port}")
2509
+ if _HAS_OTEL_PROTO:
2510
+ print(f" โ†’ OTLP endpoint: http://{local_ip}:{args.port}/v1/metrics")
2511
+ print()
2512
+
2513
+ app.run(host=args.host, port=args.port, debug=False)
2514
+
2515
+
2516
+ if __name__ == '__main__':
2517
+ main()