a2acalling 0.5.1 → 0.5.4

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.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  ---
9
9
 
10
- Your AI agent can now call other AI agents — across instances, with scoped permissions, strategic summaries, and owner notifications. Think of it as a phone system for agents.
10
+ Your AI agent can now call other AI agents — across instances, with scoped permissions, strategic summaries, and owner notifications. Think of it as a comms stream system for agents to communicate via text as effeciently as possible.
11
11
 
12
12
  ## ✨ Features
13
13
 
@@ -21,6 +21,7 @@ Your AI agent can now call other AI agents — across instances, with scoped per
21
21
  - 🧭 **Adaptive collaboration mode** — dynamic phase changes based on overlap and depth
22
22
  - 🗂️ **Minimal dashboard** — contacts, calls, tier settings, and invite generation
23
23
  - 💾 **Conversation history** — SQLite storage with context retrieval
24
+ - 🧾 **Traceable logs** — DB-backed structured logs with `trace_id`, `error_code`, and hints
24
25
 
25
26
  ## 🚀 Quick Start
26
27
 
@@ -195,6 +196,50 @@ Dashboard paths:
195
196
  - Standalone A2A server: `http://<host>:<port>/dashboard`
196
197
  - OpenClaw gateway mode: `http://<gateway>/a2a`
197
198
 
199
+ ### Traceability and Logs
200
+
201
+ All runtime logs are persisted in SQLite and also emitted to stdout:
202
+
203
+ - Log DB: `~/.config/openclaw/a2a-logs.db` (or `$A2A_CONFIG_DIR/a2a-logs.db`)
204
+ - Trace fields: `trace_id`, `conversation_id`, `token_id`, `error_code`, `status_code`, `hint`
205
+
206
+ Dashboard/API log routes:
207
+
208
+ - `GET /api/a2a/dashboard/logs`
209
+ - `GET /api/a2a/dashboard/logs/trace/:traceId`
210
+ - `GET /api/a2a/dashboard/logs/stats`
211
+ - `GET /api/a2a/dashboard/debug/call?trace_id=<id>` (or `conversation_id=<id>`)
212
+
213
+ Useful filters for `/api/a2a/dashboard/logs`:
214
+
215
+ - `trace_id`, `conversation_id`, `token_id`
216
+ - `error_code`, `status_code`
217
+ - `component`, `event`, `level`, `search`, `from`, `to`, `limit`
218
+
219
+ Example:
220
+
221
+ ```bash
222
+ curl "http://localhost:3001/api/a2a/dashboard/logs?trace_id=trace_abc123&error_code=TOKEN_INVALID_OR_EXPIRED"
223
+ ```
224
+
225
+ ### Incoming Call Debug
226
+
227
+ Every `/api/a2a/invoke` and `/api/a2a/end` response now returns:
228
+ - `trace_id` (generated when caller does not send one)
229
+ - `request_id` (generated when caller does not send one)
230
+
231
+ To inspect one call, use the dashboard debug endpoint:
232
+
233
+ ```bash
234
+ curl -H "x-admin-token: $A2A_ADMIN_TOKEN" \
235
+ "http://localhost:3001/api/a2a/dashboard/debug/call?trace_id=<trace_id>"
236
+ ```
237
+
238
+ For each call you get:
239
+ - `summary` (event count, first/last seen, duration, and IDs involved)
240
+ - `errors` and `error_codes` for fast triage
241
+ - `logs` (ordered timeline events from that trace)
242
+
198
243
  ## 📡 Protocol
199
244
 
200
245
  Tokens use the `a2a://` URI scheme:
@@ -338,6 +383,8 @@ app.listen(3001);
338
383
  | `A2A_OWNER_NAME` | Override owner display name |
339
384
  | `A2A_COLLAB_MODE` | Conversation style: `adaptive` (default) or `deep_dive` |
340
385
  | `A2A_ADMIN_TOKEN` | Protect dashboard/conversation admin routes for non-local access |
386
+ | `A2A_LOG_LEVEL` | Minimum persisted/stdout log level: `trace`, `debug`, `info`, `warn`, `error` (default: `info`) |
387
+ | `A2A_LOG_STACKS` | Include stack traces in log DB error payloads (`true` by default outside production) |
341
388
 
342
389
  ## 🤝 Philosophy
343
390
 
package/SKILL.md CHANGED
@@ -106,6 +106,24 @@ done — Save manifest and finish
106
106
 
107
107
  4. Manifest saved to `~/.config/openclaw/a2a-disclosure.json`
108
108
 
109
+ ### Open GUI (Dashboard)
110
+
111
+ User says: `/a2a gui`, `/a2a dashboard`, "open the GUI", "open the dashboard", "show me A2A logs"
112
+
113
+ This opens the local dashboard UI in the default browser (or prints the URL if auto-open is not possible).
114
+
115
+ Notes:
116
+ - This command is safe and **does not require onboarding**.
117
+ - Optional: open a specific tab via `--tab`.
118
+
119
+ Examples:
120
+
121
+ ```bash
122
+ a2a gui
123
+ a2a gui --tab logs
124
+ a2a dashboard --tab calls
125
+ ```
126
+
109
127
  ### Invite (Create & Share Token)
110
128
 
111
129
  User says: `/a2a invite`, `/a2a invite public`, `/a2a invite friends`, `/a2a invite family`, "create an invite", "generate an A2A invite"
package/bin/cli.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * a2a remotes List remote agents
11
11
  * a2a call <url> <msg> Call a remote agent
12
12
  * a2a ping <url> Ping a remote agent
13
+ * a2a gui Open the local dashboard GUI in a browser
13
14
  * a2a setup Auto setup (gateway-aware dashboard install)
14
15
  */
15
16
 
@@ -69,6 +70,77 @@ function formatTimeAgo(date) {
69
70
  return date.toLocaleDateString();
70
71
  }
71
72
 
73
+ function openInBrowser(url) {
74
+ const { spawn } = require('child_process');
75
+
76
+ const platform = process.platform;
77
+ let cmd = null;
78
+ let args = [];
79
+
80
+ if (platform === 'darwin') {
81
+ cmd = 'open';
82
+ args = [url];
83
+ } else if (platform === 'win32') {
84
+ cmd = 'cmd';
85
+ args = ['/c', 'start', '', url];
86
+ } else {
87
+ cmd = 'xdg-open';
88
+ args = [url];
89
+ }
90
+
91
+ try {
92
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
93
+ child.unref();
94
+ return { attempted: true, command: [cmd, ...args].join(' ') };
95
+ } catch (err) {
96
+ return { attempted: false, error: err.message };
97
+ }
98
+ }
99
+
100
+ async function findLocalServerPort(preferredPorts = []) {
101
+ const http = require('http');
102
+
103
+ const candidates = [];
104
+ const seen = new Set();
105
+ for (const port of preferredPorts) {
106
+ const n = Number.parseInt(String(port), 10);
107
+ if (!Number.isFinite(n) || n <= 0 || n > 65535) continue;
108
+ if (seen.has(n)) continue;
109
+ seen.add(n);
110
+ candidates.push(n);
111
+ }
112
+
113
+ const defaultPorts = [3001, 80, 8080, 8443, 9001];
114
+ for (const port of defaultPorts) {
115
+ if (seen.has(port)) continue;
116
+ seen.add(port);
117
+ candidates.push(port);
118
+ }
119
+
120
+ const probe = (port) => new Promise(resolve => {
121
+ const req = http.request({
122
+ hostname: '127.0.0.1',
123
+ port,
124
+ path: '/api/a2a/ping',
125
+ method: 'GET',
126
+ timeout: 800
127
+ }, (res) => {
128
+ res.resume();
129
+ resolve(res.statusCode === 200);
130
+ });
131
+ req.on('error', () => resolve(false));
132
+ req.on('timeout', () => { req.destroy(); resolve(false); });
133
+ req.end();
134
+ });
135
+
136
+ for (const port of candidates) {
137
+ // eslint-disable-next-line no-await-in-loop
138
+ const ok = await probe(port);
139
+ if (ok) return port;
140
+ }
141
+ return null;
142
+ }
143
+
72
144
  // Parse arguments
73
145
  function parseArgs(argv) {
74
146
  const args = { _: [], flags: {} };
@@ -747,6 +819,55 @@ https://github.com/onthegonow/a2a_calling`;
747
819
  }
748
820
  },
749
821
 
822
+ gui: async (args) => {
823
+ // GUI is always safe to open even before onboarding.
824
+ const tab = (args.flags.tab || args.flags.t || '').trim().toLowerCase();
825
+ const allowedTabs = new Set(['contacts', 'calls', 'logs', 'settings', 'invites']);
826
+ const hash = allowedTabs.has(tab) ? `#${tab}` : '';
827
+
828
+ const urlFlag = args.flags.url;
829
+ if (urlFlag) {
830
+ const url = String(urlFlag);
831
+ console.log(`Dashboard URL: ${url}`);
832
+ const opened = openInBrowser(url);
833
+ if (opened.attempted) {
834
+ console.log(`Opening browser via: ${opened.command}`);
835
+ } else {
836
+ console.log('Could not auto-open browser.');
837
+ }
838
+ return;
839
+ }
840
+
841
+ const preferred = [];
842
+ if (args.flags.port || args.flags.p) preferred.push(args.flags.port || args.flags.p);
843
+ if (process.env.A2A_PORT) preferred.push(process.env.A2A_PORT);
844
+ if (process.env.PORT) preferred.push(process.env.PORT);
845
+
846
+ const port = await findLocalServerPort(preferred);
847
+ if (!port) {
848
+ console.log('Dashboard is not reachable on common ports.');
849
+ console.log('Start the server (example):');
850
+ console.log(' A2A_HOSTNAME="localhost:3001" a2a server --port 3001');
851
+ console.log('Then open:');
852
+ console.log(' http://127.0.0.1:3001/dashboard/');
853
+ return;
854
+ }
855
+
856
+ const url = `http://127.0.0.1:${port}/dashboard/${hash}`;
857
+ console.log(`Dashboard URL: ${url}`);
858
+ const opened = openInBrowser(url);
859
+ if (opened.attempted) {
860
+ console.log(`Opening browser via: ${opened.command}`);
861
+ } else {
862
+ console.log('Could not auto-open browser; open the URL above manually.');
863
+ }
864
+ },
865
+
866
+ dashboard: (args) => {
867
+ // Alias for gui
868
+ return commands.gui(args);
869
+ },
870
+
750
871
  status: async (args) => {
751
872
  const url = args._[1];
752
873
  if (!url) {
@@ -1099,6 +1220,8 @@ Calling:
1099
1220
  call <contact|url> <msg> Call a remote agent
1100
1221
  ping <url> Check if agent is reachable
1101
1222
  status <url> Get A2A status
1223
+ gui Open the local dashboard GUI in a browser
1224
+ --tab, -t Optional: contacts|calls|logs|settings|invites
1102
1225
 
1103
1226
  Server:
1104
1227
  server Start the A2A server
package/docs/protocol.md CHANGED
@@ -56,6 +56,8 @@ Headers:
56
56
  ```
57
57
  Authorization: Bearer fed_abc123xyz
58
58
  Content-Type: application/json
59
+ x-trace-id: trace_... (optional)
60
+ x-request-id: req_... (optional)
59
61
  ```
60
62
 
61
63
  Request body:
@@ -76,6 +78,8 @@ Success response:
76
78
  ```json
77
79
  {
78
80
  "success": true,
81
+ "trace_id": "trace_...",
82
+ "request_id": "req_...",
79
83
  "conversation_id": "conv_123456",
80
84
  "response": "The agent's response text",
81
85
  "can_continue": true,
@@ -114,6 +118,8 @@ Success response:
114
118
  ```json
115
119
  {
116
120
  "success": true,
121
+ "trace_id": "trace_...",
122
+ "request_id": "req_...",
117
123
  "conversation_id": "conv_123456",
118
124
  "status": "concluded",
119
125
  "summary": "Optional summary text"
@@ -127,6 +133,26 @@ Error responses:
127
133
  {"success": false, "error": "internal_error", "message": "..."}
128
134
  ```
129
135
 
136
+ ## Traceability and Log APIs
137
+
138
+ A2A persists structured runtime logs to `~/.config/openclaw/a2a-logs.db` (or `$A2A_CONFIG_DIR/a2a-logs.db`).
139
+
140
+ Primary log fields:
141
+ - `trace_id`
142
+ - `conversation_id`
143
+ - `token_id`
144
+ - `error_code`
145
+ - `status_code`
146
+ - `hint`
147
+
148
+ Dashboard API endpoints:
149
+ - `GET /api/a2a/dashboard/logs`
150
+ - `GET /api/a2a/dashboard/logs/trace/:traceId`
151
+ - `GET /api/a2a/dashboard/logs/stats`
152
+ - `GET /api/a2a/dashboard/debug/call?trace_id=<id>`
153
+
154
+ Example filters for `/logs`: `trace_id`, `conversation_id`, `token_id`, `error_code`, `status_code`, `component`, `event`, `level`, `search`, `from`, `to`.
155
+
130
156
  ## Permission Scopes
131
157
 
132
158
  | Scope | Tools | Files | Memory | Actions |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.5.1",
3
+ "version": "0.5.4",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -77,6 +77,53 @@ function loadOpenClawConfig() {
77
77
  }
78
78
  }
79
79
 
80
+ function normalizeDashboardPluginEntry(rawEntry, backendUrl) {
81
+ const issues = [];
82
+ const normalized = {
83
+ enabled: true,
84
+ config: {}
85
+ };
86
+ const entry = (rawEntry && typeof rawEntry === 'object') ? rawEntry : {};
87
+
88
+ const legacyBackendUrl = (typeof entry.backendUrl === 'string' && entry.backendUrl.trim())
89
+ ? entry.backendUrl.trim()
90
+ : null;
91
+ const rawConfig = (entry.config && typeof entry.config === 'object') ? entry.config : null;
92
+
93
+ if (!entry || typeof entry !== 'object') {
94
+ issues.push('plugin entry is missing or invalid');
95
+ }
96
+
97
+ if (entry && typeof entry.enabled === 'boolean') {
98
+ normalized.enabled = entry.enabled;
99
+ }
100
+
101
+ if (rawConfig) {
102
+ normalized.config = { ...rawConfig };
103
+ } else if (entry.config !== undefined) {
104
+ issues.push('plugin entry has non-object config; replacing with empty object');
105
+ }
106
+
107
+ if (legacyBackendUrl) {
108
+ issues.push(`legacy key detected: plugins.entries.${DASHBOARD_PLUGIN_ID}.backendUrl (using backendUrl migration)`);
109
+ normalized.config.backendUrl = backendUrl || legacyBackendUrl;
110
+ } else if (typeof normalized.config.backendUrl === 'string' && normalized.config.backendUrl.trim()) {
111
+ normalized.config.backendUrl = normalized.config.backendUrl.trim();
112
+ } else if (typeof backendUrl === 'string' && backendUrl.trim()) {
113
+ normalized.config.backendUrl = backendUrl.trim();
114
+ } else {
115
+ issues.push('backendUrl could not be determined; plugin may fail to route dashboard traffic');
116
+ }
117
+
118
+ return {
119
+ normalized,
120
+ issues,
121
+ changed: issues.length > 0,
122
+ legacyBackendUrl,
123
+ summary: `a2a-dashboard-proxy config => ${normalized.enabled ? 'enabled' : 'disabled'}, backendUrl=${normalized.config.backendUrl || 'missing'}`
124
+ };
125
+ }
126
+
80
127
  function writeOpenClawConfig(config) {
81
128
  const backupPath = `${OPENCLAW_CONFIG}.backup.${Date.now()}`;
82
129
  fs.copyFileSync(OPENCLAW_CONFIG, backupPath);
@@ -278,12 +325,25 @@ function sendHtml(res: ServerResponse, status: number, html: string) {
278
325
 
279
326
  function resolveBackendUrl(api: OpenClawPluginApi): URL {
280
327
  const fallback = process.env.A2A_DASHBOARD_BACKEND_URL || "http://127.0.0.1:3001";
328
+ const pluginConfigRaw = (api as OpenClawPluginApi & { pluginConfig?: unknown }).pluginConfig;
329
+ const pluginConfig = (pluginConfigRaw && typeof pluginConfigRaw === "object")
330
+ ? (pluginConfigRaw as Record<string, unknown>)
331
+ : {};
332
+ const configuredUrl = typeof pluginConfig.backendUrl === "string" && pluginConfig.backendUrl
333
+ ? pluginConfig.backendUrl
334
+ : "";
335
+ if (configuredUrl) {
336
+ return new URL(configuredUrl);
337
+ }
281
338
  try {
282
339
  const cfg = api.runtime.config.loadConfig() as Record<string, unknown>;
283
340
  const plugins = (cfg.plugins || {}) as Record<string, unknown>;
284
341
  const entries = (plugins.entries || {}) as Record<string, unknown>;
285
342
  const pluginEntry = (entries[PLUGIN_ID] || {}) as Record<string, unknown>;
286
- const candidate = typeof pluginEntry.backendUrl === "string" && pluginEntry.backendUrl
343
+ const entryConfig = (pluginEntry.config || {}) as Record<string, unknown>;
344
+ const candidate = typeof entryConfig.backendUrl === "string" && entryConfig.backendUrl
345
+ ? entryConfig.backendUrl
346
+ : typeof pluginEntry.backendUrl === "string" && pluginEntry.backendUrl
287
347
  ? pluginEntry.backendUrl
288
348
  : fallback;
289
349
  return new URL(candidate);
@@ -559,12 +619,18 @@ async function install() {
559
619
 
560
620
  config.plugins = config.plugins || {};
561
621
  config.plugins.entries = config.plugins.entries || {};
562
- const existingEntry = config.plugins.entries[DASHBOARD_PLUGIN_ID] || {};
563
- config.plugins.entries[DASHBOARD_PLUGIN_ID] = {
564
- ...existingEntry,
565
- enabled: true,
566
- backendUrl
567
- };
622
+ const rawEntry = config.plugins.entries[DASHBOARD_PLUGIN_ID];
623
+ const audit = normalizeDashboardPluginEntry(rawEntry, backendUrl);
624
+ for (const issue of audit.issues) {
625
+ warn(`a2a-dashboard-proxy config issue: ${issue}`);
626
+ }
627
+ if (audit.legacyBackendUrl) {
628
+ warn(`Auto-fixing legacy key: plugins.entries.${DASHBOARD_PLUGIN_ID}.backendUrl`);
629
+ }
630
+ if (audit.changed) {
631
+ log(`Migrated dashboard plugin config: ${audit.summary}`);
632
+ }
633
+ config.plugins.entries[DASHBOARD_PLUGIN_ID] = audit.normalized;
568
634
  configUpdated = true;
569
635
  log(`Configured gateway plugin entry: ${DASHBOARD_PLUGIN_ID}`);
570
636
  }
@@ -2,7 +2,10 @@ const state = {
2
2
  settings: null,
3
3
  contacts: [],
4
4
  calls: [],
5
- invites: []
5
+ invites: [],
6
+ logs: [],
7
+ logStats: null,
8
+ trace: null
6
9
  };
7
10
 
8
11
  function showNotice(message) {
@@ -46,16 +49,46 @@ function fmtDate(value) {
46
49
  }
47
50
  }
48
51
 
52
+ function esc(text) {
53
+ return String(text ?? '')
54
+ .replaceAll('&', '&amp;')
55
+ .replaceAll('<', '&lt;')
56
+ .replaceAll('>', '&gt;')
57
+ .replaceAll('"', '&quot;')
58
+ .replaceAll("'", '&#039;');
59
+ }
60
+
49
61
  function bindTabs() {
62
+ const activateTab = (tab, options = {}) => {
63
+ const target = String(tab || '').replace(/^#/, '').trim();
64
+ if (!target) return false;
65
+ const btn = Array.from(document.querySelectorAll('.tab')).find(b => b.dataset.tab === target);
66
+ const panel = document.getElementById(`tab-${target}`);
67
+ if (!btn || !panel) return false;
68
+
69
+ document.querySelectorAll('.tab').forEach(b => b.classList.remove('is-active'));
70
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('is-active'));
71
+ btn.classList.add('is-active');
72
+ panel.classList.add('is-active');
73
+
74
+ if (options.updateHash) {
75
+ try { window.location.hash = target; } catch (err) {}
76
+ }
77
+ return true;
78
+ };
79
+
50
80
  document.querySelectorAll('.tab').forEach(btn => {
51
81
  btn.addEventListener('click', () => {
52
- const tab = btn.dataset.tab;
53
- document.querySelectorAll('.tab').forEach(b => b.classList.remove('is-active'));
54
- document.querySelectorAll('.panel').forEach(p => p.classList.remove('is-active'));
55
- btn.classList.add('is-active');
56
- document.getElementById(`tab-${tab}`).classList.add('is-active');
82
+ activateTab(btn.dataset.tab, { updateHash: true });
57
83
  });
58
84
  });
85
+
86
+ window.addEventListener('hashchange', () => {
87
+ activateTab(window.location.hash);
88
+ });
89
+
90
+ // Deep-link into a tab with /dashboard/#logs, etc.
91
+ activateTab(window.location.hash);
59
92
  }
60
93
 
61
94
  function renderContacts() {
@@ -144,6 +177,137 @@ async function loadCallsForContact(contactId, contactName) {
144
177
  `;
145
178
  }
146
179
 
180
+ function readLogFilters() {
181
+ const level = document.getElementById('logs-level').value.trim();
182
+ const component = document.getElementById('logs-component').value.trim();
183
+ const event = document.getElementById('logs-event').value.trim();
184
+ const traceId = document.getElementById('logs-trace').value.trim();
185
+ const conversationId = document.getElementById('logs-conversation').value.trim();
186
+ const tokenId = document.getElementById('logs-token').value.trim();
187
+ const search = document.getElementById('logs-search').value.trim();
188
+ const limit = Number.parseInt(document.getElementById('logs-limit').value, 10) || 200;
189
+
190
+ const params = new URLSearchParams();
191
+ params.set('limit', String(Math.min(1000, Math.max(1, limit))));
192
+ if (level) params.set('level', level);
193
+ if (component) params.set('component', component);
194
+ if (event) params.set('event', event);
195
+ if (traceId) params.set('trace_id', traceId);
196
+ if (conversationId) params.set('conversation_id', conversationId);
197
+ if (tokenId) params.set('token_id', tokenId);
198
+ if (search) params.set('search', search);
199
+
200
+ return params;
201
+ }
202
+
203
+ function renderLogStats() {
204
+ const el = document.getElementById('log-stats');
205
+ if (!state.logStats) {
206
+ el.textContent = '';
207
+ el.style.display = 'none';
208
+ return;
209
+ }
210
+ const stats = state.logStats;
211
+ const levels = Object.entries(stats.by_level || {}).sort((a, b) => a[0].localeCompare(b[0]));
212
+ const components = Object.entries(stats.by_component || {})
213
+ .sort((a, b) => b[1] - a[1])
214
+ .slice(0, 12);
215
+
216
+ el.style.display = 'block';
217
+ el.innerHTML = `
218
+ <div class="row">
219
+ <strong>Total:</strong> ${stats.total || 0}
220
+ </div>
221
+ <div class="row">
222
+ <strong>By level:</strong> ${levels.map(([k, v]) => `${esc(k)}=${v}`).join(' · ') || '(none)'}
223
+ </div>
224
+ <div class="row">
225
+ <strong>Top components:</strong> ${components.map(([k, v]) => `${esc(k)}=${v}`).join(' · ') || '(none)'}
226
+ </div>
227
+ `;
228
+ }
229
+
230
+ function renderTraceDetail() {
231
+ const el = document.getElementById('trace-detail');
232
+ if (!state.trace || !Array.isArray(state.trace.logs)) {
233
+ el.textContent = '';
234
+ el.style.display = 'none';
235
+ return;
236
+ }
237
+ el.style.display = 'block';
238
+ const logs = state.trace.logs || [];
239
+ const lines = logs.map(row => {
240
+ const msg = row.message || '';
241
+ const meta = [
242
+ row.component ? row.component : null,
243
+ row.event ? row.event : null,
244
+ row.error_code ? `code=${row.error_code}` : null,
245
+ row.status_code ? `status=${row.status_code}` : null
246
+ ].filter(Boolean).join(' ');
247
+ return `[${fmtDate(row.timestamp)}] ${row.level?.toUpperCase() || ''} ${meta}\n${msg}${row.hint ? `\nHint: ${row.hint}` : ''}`;
248
+ }).join('\n\n');
249
+
250
+ el.innerHTML = `
251
+ <div class="row">
252
+ <h3 style="margin:0;">Trace: <span class="mono">${esc(state.trace.trace_id || '')}</span></h3>
253
+ <button id="clear-trace">Clear</button>
254
+ </div>
255
+ <pre class="summary mono">${esc(lines || 'No trace logs.')}</pre>
256
+ `;
257
+ const clearBtn = document.getElementById('clear-trace');
258
+ if (clearBtn) {
259
+ clearBtn.addEventListener('click', () => {
260
+ state.trace = null;
261
+ renderTraceDetail();
262
+ });
263
+ }
264
+ }
265
+
266
+ function renderLogs() {
267
+ const tbody = document.querySelector('#logs-table tbody');
268
+ tbody.innerHTML = '';
269
+
270
+ state.logs.forEach(row => {
271
+ const tr = document.createElement('tr');
272
+ const trace = row.trace_id || '';
273
+ tr.innerHTML = `
274
+ <td>${esc(fmtDate(row.timestamp))}</td>
275
+ <td>${esc(row.level || '-')}</td>
276
+ <td>${esc(row.component || '-')}</td>
277
+ <td>${esc(row.event || '-')}</td>
278
+ <td title="${esc(row.message || '')}">${esc(String(row.message || '').slice(0, 120) || '-')}</td>
279
+ <td class="mono">${esc(trace ? trace.slice(0, 14) + '…' : '-')}</td>
280
+ <td class="mono">${esc(row.conversation_id ? row.conversation_id.slice(0, 14) + '…' : '-')}</td>
281
+ <td class="mono">${esc(row.token_id || '-')}</td>
282
+ <td>${esc(row.error_code || '-')}</td>
283
+ <td>${esc(row.status_code ?? '-')}</td>
284
+ `;
285
+ if (trace) {
286
+ tr.addEventListener('click', () => loadTrace(trace).catch(err => showNotice(err.message)));
287
+ }
288
+ tbody.appendChild(tr);
289
+ });
290
+ }
291
+
292
+ async function loadLogs() {
293
+ const qs = readLogFilters().toString();
294
+ const payload = await request(`/logs?${qs}`);
295
+ state.logs = payload.logs || [];
296
+ renderLogs();
297
+ }
298
+
299
+ async function loadLogStats() {
300
+ const payload = await request('/logs/stats');
301
+ state.logStats = payload.stats || null;
302
+ renderLogStats();
303
+ }
304
+
305
+ async function loadTrace(traceId) {
306
+ const payload = await request(`/logs/trace/${encodeURIComponent(traceId)}?limit=500`);
307
+ state.trace = payload;
308
+ renderTraceDetail();
309
+ }
310
+
147
311
  function fillTierSelects() {
148
312
  const tiers = (state.settings?.tiers || []).slice().sort((a, b) => a.id.localeCompare(b.id));
149
313
  const tierSelect = document.getElementById('tier-select');
@@ -326,6 +490,30 @@ function bindRefreshButtons() {
326
490
  document.getElementById('refresh-contacts').addEventListener('click', () => loadContacts().catch(err => showNotice(err.message)));
327
491
  document.getElementById('refresh-calls').addEventListener('click', () => loadCalls().catch(err => showNotice(err.message)));
328
492
  document.getElementById('refresh-invites').addEventListener('click', () => loadInvites().catch(err => showNotice(err.message)));
493
+ document.getElementById('refresh-logs').addEventListener('click', () => loadLogs().catch(err => showNotice(err.message)));
494
+ document.getElementById('refresh-log-stats').addEventListener('click', () => loadLogStats().catch(err => showNotice(err.message)));
495
+
496
+ // Auto-refresh logs as filters change (debounced).
497
+ let debounce = null;
498
+ const schedule = () => {
499
+ clearTimeout(debounce);
500
+ debounce = setTimeout(() => loadLogs().catch(err => showNotice(err.message)), 250);
501
+ };
502
+ [
503
+ 'logs-level',
504
+ 'logs-component',
505
+ 'logs-event',
506
+ 'logs-trace',
507
+ 'logs-conversation',
508
+ 'logs-token',
509
+ 'logs-search',
510
+ 'logs-limit'
511
+ ].forEach(id => {
512
+ const el = document.getElementById(id);
513
+ if (!el) return;
514
+ el.addEventListener('input', schedule);
515
+ el.addEventListener('change', schedule);
516
+ });
329
517
  }
330
518
 
331
519
  async function bootstrap() {
@@ -335,7 +523,7 @@ async function bootstrap() {
335
523
  bindRefreshButtons();
336
524
 
337
525
  try {
338
- await Promise.all([loadSettings(), loadContacts(), loadCalls(), loadInvites()]);
526
+ await Promise.all([loadSettings(), loadContacts(), loadCalls(), loadInvites(), loadLogStats(), loadLogs()]);
339
527
  showNotice('Dashboard loaded');
340
528
  } catch (err) {
341
529
  showNotice(err.message);