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 +2517 -0
- openclaw_dashboard-0.2.0.dist-info/METADATA +305 -0
- openclaw_dashboard-0.2.0.dist-info/RECORD +7 -0
- openclaw_dashboard-0.2.0.dist-info/WHEEL +5 -0
- openclaw_dashboard-0.2.0.dist-info/entry_points.txt +2 -0
- openclaw_dashboard-0.2.0.dist-info/licenses/LICENSE +21 -0
- openclaw_dashboard-0.2.0.dist-info/top_level.txt +1 -0
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">—</span></div>
|
|
910
|
+
<div class="flow-stat"><span class="flow-stat-label">Tokens</span><span class="flow-stat-value" id="flow-tokens">—</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">📱 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">📡 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">💬 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">🔀 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">💾 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;">🧠</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">⚡ 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">🌐 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">🔍 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">⏰ 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">🔊 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">📝 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;">⚙️ 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;">🖥️ 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;">💿 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;">🌐 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
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()
|