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 +48 -1
- package/SKILL.md +18 -0
- package/bin/cli.js +123 -0
- package/docs/protocol.md +26 -0
- package/package.json +1 -1
- package/scripts/install-openclaw.js +73 -7
- package/src/dashboard/public/app.js +195 -7
- package/src/dashboard/public/index.html +51 -0
- package/src/dashboard/public/style.css +27 -0
- package/src/index.js +5 -0
- package/src/lib/call-monitor.js +68 -6
- package/src/lib/config.js +11 -1
- package/src/lib/conversations.js +13 -1
- package/src/lib/disclosure.js +11 -1
- package/src/lib/invite-host.js +17 -7
- package/src/lib/logger.js +566 -0
- package/src/lib/openclaw-integration.js +53 -6
- package/src/lib/runtime-adapter.js +295 -10
- package/src/lib/summarizer.js +21 -2
- package/src/lib/tokens.js +12 -1
- package/src/routes/a2a.js +250 -32
- package/src/routes/dashboard.js +163 -0
- package/src/server.js +182 -42
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
|
|
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
|
@@ -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
|
|
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
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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('&', '&')
|
|
55
|
+
.replaceAll('<', '<')
|
|
56
|
+
.replaceAll('>', '>')
|
|
57
|
+
.replaceAll('"', '"')
|
|
58
|
+
.replaceAll("'", ''');
|
|
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
|
-
|
|
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);
|