a2acalling 0.6.0 → 0.6.2
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 +33 -9
- package/SKILL.md +67 -5
- package/bin/cli.js +468 -151
- package/docs/protocol.md +24 -14
- package/package.json +1 -1
- package/scripts/install-openclaw.js +64 -68
- package/src/dashboard/public/app.js +765 -28
- package/src/dashboard/public/index.html +57 -13
- package/src/dashboard/public/style.css +16 -0
- package/src/lib/callbook.js +358 -0
- package/src/lib/client.js +1 -2
- package/src/lib/config.js +67 -15
- package/src/lib/external-ip.js +18 -7
- package/src/lib/invite-host.js +26 -41
- package/src/lib/logger.js +26 -14
- package/src/lib/tokens.js +314 -113
- package/src/routes/a2a.js +11 -2
- package/src/routes/callbook.js +142 -0
- package/src/routes/dashboard.js +557 -25
- package/src/server.js +6 -0
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
const state = {
|
|
2
2
|
settings: null,
|
|
3
|
+
dashboardStatus: null,
|
|
4
|
+
callbookDevices: [],
|
|
3
5
|
contacts: [],
|
|
6
|
+
selectedContactId: null,
|
|
7
|
+
selectedContactCalls: [],
|
|
8
|
+
contactCallResult: null,
|
|
4
9
|
calls: [],
|
|
5
10
|
invites: [],
|
|
6
11
|
logs: [],
|
|
@@ -58,6 +63,32 @@ function esc(text) {
|
|
|
58
63
|
.replaceAll("'", ''');
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
async function copyText(value) {
|
|
67
|
+
const text = String(value || '');
|
|
68
|
+
if (!text) return false;
|
|
69
|
+
try {
|
|
70
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
71
|
+
await navigator.clipboard.writeText(text);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// fall back
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const ta = document.createElement('textarea');
|
|
79
|
+
ta.value = text;
|
|
80
|
+
ta.style.position = 'fixed';
|
|
81
|
+
ta.style.left = '-9999px';
|
|
82
|
+
document.body.appendChild(ta);
|
|
83
|
+
ta.select();
|
|
84
|
+
document.execCommand('copy');
|
|
85
|
+
document.body.removeChild(ta);
|
|
86
|
+
return true;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
61
92
|
function bindTabs() {
|
|
62
93
|
const activateTab = (tab, options = {}) => {
|
|
63
94
|
const target = String(tab || '').replace(/^#/, '').trim();
|
|
@@ -91,27 +122,339 @@ function bindTabs() {
|
|
|
91
122
|
activateTab(window.location.hash);
|
|
92
123
|
}
|
|
93
124
|
|
|
125
|
+
function norm(value) {
|
|
126
|
+
return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getLocalOwnerName() {
|
|
130
|
+
return state.dashboardStatus?.agent?.owner_name || state.dashboardStatus?.agent?.ownerName || '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isMine(contact) {
|
|
134
|
+
return Boolean(contact?.is_mine);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function formatLocation(contact) {
|
|
138
|
+
const host = String(contact?.host || contact?.web_address || '').trim();
|
|
139
|
+
const server = String(contact?.server_name || contact?.serverName || '').trim();
|
|
140
|
+
if (server && host && norm(server) !== norm(host)) {
|
|
141
|
+
return `${server} (${host})`;
|
|
142
|
+
}
|
|
143
|
+
return server || host || '-';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function contactLabel(contact) {
|
|
147
|
+
return String(contact?.name || '').trim() || String(contact?.host || '').trim() || '-';
|
|
148
|
+
}
|
|
149
|
+
|
|
94
150
|
function renderContacts() {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
151
|
+
const el = document.getElementById('contacts-sections');
|
|
152
|
+
if (!el) return;
|
|
153
|
+
|
|
154
|
+
const contacts = Array.isArray(state.contacts) ? state.contacts.slice() : [];
|
|
155
|
+
const selected = state.selectedContactId ? String(state.selectedContactId) : '';
|
|
156
|
+
|
|
157
|
+
const myAgents = contacts
|
|
158
|
+
.filter(c => isMine(c))
|
|
159
|
+
.sort((a, b) => contactLabel(a).localeCompare(contactLabel(b)));
|
|
160
|
+
|
|
161
|
+
const lastCalled = contacts
|
|
162
|
+
.filter(c => c && c.last_call_at)
|
|
163
|
+
.sort((a, b) => String(b.last_call_at || '').localeCompare(String(a.last_call_at || '')))
|
|
164
|
+
.slice(0, 12);
|
|
165
|
+
|
|
166
|
+
const groups = new Map();
|
|
167
|
+
for (const c of contacts) {
|
|
168
|
+
const owner = String(c?.owner || '').trim() || '(unknown owner)';
|
|
169
|
+
if (!groups.has(owner)) groups.set(owner, []);
|
|
170
|
+
groups.get(owner).push(c);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const owners = Array.from(groups.keys()).sort((a, b) => {
|
|
174
|
+
// Keep local owner group near the top (after the dedicated "My agents" section).
|
|
175
|
+
const local = norm(getLocalOwnerName());
|
|
176
|
+
const aIsLocal = local && norm(a) === local;
|
|
177
|
+
const bIsLocal = local && norm(b) === local;
|
|
178
|
+
if (aIsLocal && !bIsLocal) return -1;
|
|
179
|
+
if (!aIsLocal && bIsLocal) return 1;
|
|
180
|
+
if (a === '(unknown owner)' && b !== '(unknown owner)') return 1;
|
|
181
|
+
if (a !== '(unknown owner)' && b === '(unknown owner)') return -1;
|
|
182
|
+
return a.localeCompare(b);
|
|
108
183
|
});
|
|
184
|
+
|
|
185
|
+
const rowHtml = (c, opts = {}) => {
|
|
186
|
+
const canCall = Boolean(c?.can_call);
|
|
187
|
+
const mine = Boolean(c?.is_mine);
|
|
188
|
+
const lastSummary = String(c?.last_owner_summary || c?.last_summary || '').trim();
|
|
189
|
+
const summaryPreview = lastSummary ? lastSummary.slice(0, 120) : '-';
|
|
190
|
+
const lastCallAt = c?.last_call_at ? fmtDate(c.last_call_at) : '-';
|
|
191
|
+
const calls = Number.isFinite(c?.call_count) ? c.call_count : (c?.call_count || 0);
|
|
192
|
+
const isSelected = selected && String(c?.id) === selected;
|
|
193
|
+
|
|
194
|
+
const actionBits = [];
|
|
195
|
+
if (c?.last_call_id) {
|
|
196
|
+
actionBits.push(`<button data-open-call="${esc(c.last_call_id)}" type="button">Transcript</button>`);
|
|
197
|
+
}
|
|
198
|
+
actionBits.push(`<button data-toggle-mine="${esc(c.id)}" type="button">${mine ? 'Unmark mine' : 'Mark mine'}</button>`);
|
|
199
|
+
actionBits.push(`<button data-remove-contact="${esc(c.id)}" type="button">Remove</button>`);
|
|
200
|
+
|
|
201
|
+
const locationCell = opts.showLocation ? `<td>${esc(formatLocation(c))}</td>` : '';
|
|
202
|
+
const ownerCell = opts.showOwner ? `<td>${esc(c?.owner || '-')}</td>` : '';
|
|
203
|
+
const summaryCell = opts.showSummary ? `<td title="${esc(lastSummary)}">${esc(summaryPreview)}</td>` : '';
|
|
204
|
+
|
|
205
|
+
return `
|
|
206
|
+
<tr ${isSelected ? 'data-selected="1"' : ''}>
|
|
207
|
+
<td>
|
|
208
|
+
<div class="row" style="margin:0;">
|
|
209
|
+
<button class="btn-link" data-contact-select="${esc(c.id)}" type="button">${esc(contactLabel(c))}</button>
|
|
210
|
+
<button data-contact-call="${esc(c.id)}" type="button" ${canCall ? '' : 'disabled'}>Call</button>
|
|
211
|
+
</div>
|
|
212
|
+
</td>
|
|
213
|
+
${locationCell}
|
|
214
|
+
${ownerCell}
|
|
215
|
+
<td>${esc(c?.status || '-')}</td>
|
|
216
|
+
<td>${esc(String(calls))}</td>
|
|
217
|
+
<td>${esc(lastCallAt)}</td>
|
|
218
|
+
${summaryCell}
|
|
219
|
+
<td>${actionBits.join(' ')}</td>
|
|
220
|
+
</tr>
|
|
221
|
+
`;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const tableHtml = (rows, opts = {}) => {
|
|
225
|
+
const cols = [];
|
|
226
|
+
cols.push('<th>Agent</th>');
|
|
227
|
+
if (opts.showLocation) cols.push('<th>Location</th>');
|
|
228
|
+
if (opts.showOwner) cols.push('<th>Owner</th>');
|
|
229
|
+
cols.push('<th>Status</th>');
|
|
230
|
+
cols.push('<th>Calls</th>');
|
|
231
|
+
cols.push('<th>Last Call</th>');
|
|
232
|
+
if (opts.showSummary) cols.push('<th>Last Summary</th>');
|
|
233
|
+
cols.push('<th>Action</th>');
|
|
234
|
+
|
|
235
|
+
if (!rows.length) {
|
|
236
|
+
return `<table><thead><tr>${cols.join('')}</tr></thead><tbody><tr><td colspan="${cols.length}">(none)</td></tr></tbody></table>`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return `<table><thead><tr>${cols.join('')}</tr></thead><tbody>${rows.map(c => rowHtml(c, opts)).join('')}</tbody></table>`;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const myAgentsSection = `
|
|
243
|
+
<div class="card">
|
|
244
|
+
<h3>My agents</h3>
|
|
245
|
+
${tableHtml(myAgents, { showLocation: true, showOwner: false, showSummary: false })}
|
|
246
|
+
</div>
|
|
247
|
+
`;
|
|
248
|
+
|
|
249
|
+
const lastCalledSection = `
|
|
250
|
+
<div class="card">
|
|
251
|
+
<h3>Last called agents</h3>
|
|
252
|
+
${tableHtml(lastCalled, { showLocation: false, showOwner: true, showSummary: false })}
|
|
253
|
+
</div>
|
|
254
|
+
`;
|
|
255
|
+
|
|
256
|
+
const groupedSections = owners.map(owner => {
|
|
257
|
+
const rows = (groups.get(owner) || []).slice().sort((a, b) => contactLabel(a).localeCompare(contactLabel(b)));
|
|
258
|
+
return `
|
|
259
|
+
<div class="card">
|
|
260
|
+
<h3>${esc(owner)}</h3>
|
|
261
|
+
${tableHtml(rows, { showLocation: false, showOwner: false, showSummary: true })}
|
|
262
|
+
</div>
|
|
263
|
+
`;
|
|
264
|
+
}).join('');
|
|
265
|
+
|
|
266
|
+
el.innerHTML = `${myAgentsSection}${lastCalledSection}${groupedSections}`;
|
|
109
267
|
}
|
|
110
268
|
|
|
111
269
|
async function loadContacts() {
|
|
112
270
|
const payload = await request('/contacts');
|
|
113
271
|
state.contacts = payload.contacts || [];
|
|
114
272
|
renderContacts();
|
|
273
|
+
renderContactDetail();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function bindContactsActions() {
|
|
277
|
+
const form = document.getElementById('add-contact-form');
|
|
278
|
+
if (!form) return;
|
|
279
|
+
|
|
280
|
+
const urlEl = document.getElementById('add-contact-url');
|
|
281
|
+
const mineEl = document.getElementById('add-contact-mine');
|
|
282
|
+
const serverNameEl = document.getElementById('add-contact-server-name');
|
|
283
|
+
const defaultServerNameFromUrl = () => {
|
|
284
|
+
if (!urlEl || !serverNameEl) return;
|
|
285
|
+
if (mineEl && !mineEl.checked) return;
|
|
286
|
+
if (serverNameEl.value.trim()) return;
|
|
287
|
+
const match = String(urlEl.value || '').trim().match(/^(?:a2a|oclaw):\\/\\/([^/]+)\\//);
|
|
288
|
+
if (match && match[1]) {
|
|
289
|
+
serverNameEl.value = match[1];
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
urlEl?.addEventListener('blur', defaultServerNameFromUrl);
|
|
293
|
+
urlEl?.addEventListener('change', defaultServerNameFromUrl);
|
|
294
|
+
mineEl?.addEventListener('change', () => {
|
|
295
|
+
if (!serverNameEl) return;
|
|
296
|
+
serverNameEl.disabled = !mineEl.checked;
|
|
297
|
+
if (mineEl.checked) {
|
|
298
|
+
defaultServerNameFromUrl();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
if (serverNameEl && mineEl) {
|
|
302
|
+
serverNameEl.disabled = !mineEl.checked;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
form.addEventListener('submit', async (e) => {
|
|
306
|
+
e.preventDefault();
|
|
307
|
+
const url = document.getElementById('add-contact-url').value.trim();
|
|
308
|
+
const name = document.getElementById('add-contact-name').value.trim();
|
|
309
|
+
const owner = document.getElementById('add-contact-owner').value.trim();
|
|
310
|
+
const isMine = Boolean(document.getElementById('add-contact-mine')?.checked);
|
|
311
|
+
const serverName = document.getElementById('add-contact-server-name').value.trim();
|
|
312
|
+
const tagsRaw = document.getElementById('add-contact-tags').value.trim();
|
|
313
|
+
const notes = document.getElementById('add-contact-notes').value.trim();
|
|
314
|
+
const fieldsRaw = document.getElementById('add-contact-fields').value.trim();
|
|
315
|
+
const tags = tagsRaw
|
|
316
|
+
? tagsRaw.split(',').map(v => v.trim()).filter(Boolean).slice(0, 30)
|
|
317
|
+
: [];
|
|
318
|
+
|
|
319
|
+
let fields = {};
|
|
320
|
+
if (fieldsRaw) {
|
|
321
|
+
try {
|
|
322
|
+
const parsed = JSON.parse(fieldsRaw);
|
|
323
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
324
|
+
throw new Error('Fields must be a JSON object');
|
|
325
|
+
}
|
|
326
|
+
fields = parsed;
|
|
327
|
+
} catch (err) {
|
|
328
|
+
showNotice(`Fields JSON invalid: ${err.message}`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
await request('/contacts', {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
body: JSON.stringify({
|
|
337
|
+
invite_url: url,
|
|
338
|
+
name: name || undefined,
|
|
339
|
+
owner: owner || undefined,
|
|
340
|
+
is_mine: isMine,
|
|
341
|
+
server_name: serverName || undefined,
|
|
342
|
+
tags,
|
|
343
|
+
notes: notes || undefined,
|
|
344
|
+
fields
|
|
345
|
+
})
|
|
346
|
+
});
|
|
347
|
+
showNotice('Contact added');
|
|
348
|
+
form.reset();
|
|
349
|
+
await loadContacts();
|
|
350
|
+
} catch (err) {
|
|
351
|
+
showNotice(err.message);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const panel = document.getElementById('tab-contacts');
|
|
356
|
+
panel?.addEventListener('click', async (e) => {
|
|
357
|
+
const selectBtn = e.target.closest('button[data-contact-select]');
|
|
358
|
+
if (selectBtn) {
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
const id = selectBtn.dataset.contactSelect;
|
|
361
|
+
if (id) {
|
|
362
|
+
await loadCallsForContact(id);
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const openBtn = e.target.closest('button[data-open-call]');
|
|
368
|
+
if (openBtn) {
|
|
369
|
+
e.preventDefault();
|
|
370
|
+
openCallTranscript(openBtn.dataset.openCall);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const mineBtn = e.target.closest('button[data-toggle-mine]');
|
|
375
|
+
if (mineBtn) {
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
const id = mineBtn.dataset.toggleMine;
|
|
378
|
+
if (!id) return;
|
|
379
|
+
|
|
380
|
+
const contact = (state.contacts || []).find(c => String(c.id) === String(id));
|
|
381
|
+
const next = contact ? !Boolean(contact.is_mine) : true;
|
|
382
|
+
|
|
383
|
+
mineBtn.disabled = true;
|
|
384
|
+
try {
|
|
385
|
+
await request(`/contacts/${encodeURIComponent(id)}`, {
|
|
386
|
+
method: 'PUT',
|
|
387
|
+
body: JSON.stringify({ is_mine: next })
|
|
388
|
+
});
|
|
389
|
+
showNotice(next ? 'Marked as mine' : 'Unmarked');
|
|
390
|
+
await loadContacts();
|
|
391
|
+
if (state.selectedContactId && String(state.selectedContactId) === String(id)) {
|
|
392
|
+
await loadCallsForContact(id);
|
|
393
|
+
}
|
|
394
|
+
} catch (err) {
|
|
395
|
+
showNotice(err.message);
|
|
396
|
+
mineBtn.disabled = false;
|
|
397
|
+
}
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const removeBtn = e.target.closest('button[data-remove-contact]');
|
|
402
|
+
if (removeBtn) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
const id = removeBtn.dataset.removeContact;
|
|
405
|
+
if (!id) return;
|
|
406
|
+
removeBtn.disabled = true;
|
|
407
|
+
try {
|
|
408
|
+
await request(`/contacts/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
409
|
+
showNotice('Contact removed');
|
|
410
|
+
if (state.selectedContactId && String(state.selectedContactId) === String(id)) {
|
|
411
|
+
state.selectedContactId = null;
|
|
412
|
+
state.selectedContactCalls = [];
|
|
413
|
+
state.contactCallResult = null;
|
|
414
|
+
}
|
|
415
|
+
await loadContacts();
|
|
416
|
+
} catch (err) {
|
|
417
|
+
showNotice(err.message);
|
|
418
|
+
removeBtn.disabled = false;
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const callBtn = e.target.closest('button[data-contact-call]');
|
|
424
|
+
if (callBtn) {
|
|
425
|
+
e.preventDefault();
|
|
426
|
+
const id = callBtn.dataset.contactCall;
|
|
427
|
+
if (!id) return;
|
|
428
|
+
|
|
429
|
+
const contact = (state.contacts || []).find(c => String(c.id) === String(id));
|
|
430
|
+
if (!contact) {
|
|
431
|
+
showNotice('Contact not found');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (!contact.can_call) {
|
|
435
|
+
showNotice('This contact has no callable A2A endpoint stored.');
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Quick-call: use existing draft message if available, else prompt.
|
|
440
|
+
let message = '';
|
|
441
|
+
const draftEl = document.getElementById('contact-call-message');
|
|
442
|
+
if (state.selectedContactId && String(state.selectedContactId) === String(id) && draftEl && draftEl.value.trim()) {
|
|
443
|
+
message = draftEl.value.trim();
|
|
444
|
+
} else {
|
|
445
|
+
const prompted = window.prompt(`Message to send to ${contactLabel(contact)}:`, 'Hello from my agent.');
|
|
446
|
+
if (prompted === null) return;
|
|
447
|
+
message = String(prompted || '').trim();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (!message) {
|
|
451
|
+
showNotice('Message required');
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
await callContact(id, message);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
});
|
|
115
458
|
}
|
|
116
459
|
|
|
117
460
|
function renderCalls() {
|
|
@@ -154,27 +497,238 @@ async function loadCallDetail(conversationId) {
|
|
|
154
497
|
`;
|
|
155
498
|
}
|
|
156
499
|
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
500
|
+
function renderContactDetail() {
|
|
501
|
+
const el = document.getElementById('contact-detail');
|
|
502
|
+
if (!el) return;
|
|
503
|
+
|
|
504
|
+
const contactId = state.selectedContactId ? String(state.selectedContactId) : '';
|
|
505
|
+
if (!contactId) {
|
|
506
|
+
el.innerHTML = '<strong>Select a contact to view details and call history.</strong>';
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const contact = (state.contacts || []).find(c => String(c.id) === contactId) || null;
|
|
511
|
+
if (!contact) {
|
|
512
|
+
el.innerHTML = '<strong>Selected contact not found.</strong>';
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const calls = Array.isArray(state.selectedContactCalls) ? state.selectedContactCalls : [];
|
|
517
|
+
const canCall = Boolean(contact.can_call);
|
|
518
|
+
|
|
519
|
+
const tagsText = Array.isArray(contact.tags) ? contact.tags.join(', ') : '';
|
|
520
|
+
const fieldsText = (() => {
|
|
521
|
+
try {
|
|
522
|
+
const obj = (contact.fields && typeof contact.fields === 'object') ? contact.fields : {};
|
|
523
|
+
return JSON.stringify(obj, null, 2);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
return '{}';
|
|
526
|
+
}
|
|
527
|
+
})();
|
|
528
|
+
|
|
529
|
+
const result = state.contactCallResult;
|
|
530
|
+
const resultHtml = result
|
|
531
|
+
? `<div style="margin-top:0.6rem;">
|
|
532
|
+
<strong>Last call result:</strong> ${result.success ? 'success' : 'failed'}<br>
|
|
533
|
+
${result.conversation_id ? `Conversation: <span class="mono">${esc(result.conversation_id)}</span> <button data-open-call="${esc(result.conversation_id)}" type="button">Transcript</button><br>` : ''}
|
|
534
|
+
${result.error ? `<span class="mono">${esc(result.error)}</span><br>` : ''}
|
|
535
|
+
${result.response ? `<pre class="summary">${esc(String(result.response))}</pre>` : ''}
|
|
536
|
+
</div>`
|
|
537
|
+
: '';
|
|
538
|
+
|
|
539
|
+
const callRows = calls.map(call => {
|
|
540
|
+
const summary = String(call.summary || call.owner_summary || '').trim();
|
|
541
|
+
const preview = summary ? summary.slice(0, 140) : '-';
|
|
162
542
|
return `
|
|
163
543
|
<tr>
|
|
164
|
-
<td>${call.id}</td>
|
|
165
|
-
<td>${call.status || '-'}</td>
|
|
166
|
-
<td>${fmtDate(call.last_message_at)}</td>
|
|
167
|
-
<td
|
|
544
|
+
<td class="mono">${esc(call.id)}</td>
|
|
545
|
+
<td>${esc(call.status || '-')}</td>
|
|
546
|
+
<td>${esc(fmtDate(call.last_message_at))}</td>
|
|
547
|
+
<td title="${esc(summary)}">${esc(preview)}</td>
|
|
548
|
+
<td><button data-open-call="${esc(call.id)}" type="button">Transcript</button></td>
|
|
168
549
|
</tr>
|
|
169
550
|
`;
|
|
170
551
|
}).join('');
|
|
552
|
+
|
|
171
553
|
el.innerHTML = `
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
<
|
|
175
|
-
<
|
|
176
|
-
</
|
|
554
|
+
<div class="row">
|
|
555
|
+
<h3 style="margin:0;">Contact: ${esc(contactLabel(contact))}</h3>
|
|
556
|
+
<button data-contact-call="${esc(contact.id)}" type="button" ${canCall ? '' : 'disabled'}>Call</button>
|
|
557
|
+
<button data-remove-contact="${esc(contact.id)}" type="button">Remove</button>
|
|
558
|
+
</div>
|
|
559
|
+
|
|
560
|
+
<div class="row" style="margin-bottom:0.4rem;">
|
|
561
|
+
<div><strong>Mine:</strong> ${contact.is_mine ? 'yes' : 'no'}</div>
|
|
562
|
+
<div><strong>Owner:</strong> ${esc(contact.owner || '-')}</div>
|
|
563
|
+
<div><strong>Web address:</strong> <span class="mono">${esc(contact.web_address || contact.host || '-')}</span></div>
|
|
564
|
+
<div><strong>Server name:</strong> ${esc(contact.server_name || '-')}</div>
|
|
565
|
+
</div>
|
|
566
|
+
<div class="row">
|
|
567
|
+
<div><strong>Status:</strong> ${esc(contact.status || '-')}</div>
|
|
568
|
+
<div><strong>Total calls:</strong> ${esc(String(contact.call_count || 0))}</div>
|
|
569
|
+
<div><strong>Last call:</strong> ${esc(contact.last_call_at ? fmtDate(contact.last_call_at) : '-')}</div>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
${resultHtml}
|
|
573
|
+
|
|
574
|
+
<details style="margin-top:0.8rem;" open>
|
|
575
|
+
<summary><strong>Edit contact</strong></summary>
|
|
576
|
+
<form id="contact-edit-form" data-contact-id="${esc(contact.id)}" style="margin-top:0.6rem;">
|
|
577
|
+
<label>Agent name <input id="contact-edit-name" type="text" value="${esc(contact.name || '')}"></label>
|
|
578
|
+
<label>Owner name <input id="contact-edit-owner" type="text" value="${esc(contact.owner || '')}"></label>
|
|
579
|
+
<label><input id="contact-edit-mine" type="checkbox" ${contact.is_mine ? 'checked' : ''}> Mark as mine (personal agent)</label>
|
|
580
|
+
<label>Server name (my agents only) <input id="contact-edit-server-name" type="text" value="${esc(contact.server_name || '')}" ${contact.is_mine ? '' : 'disabled'}></label>
|
|
581
|
+
<label>Tags <input id="contact-edit-tags" type="text" value="${esc(tagsText)}" placeholder="comma,separated"></label>
|
|
582
|
+
<label>Notes <textarea id="contact-edit-notes" rows="3">${esc(contact.notes || '')}</textarea></label>
|
|
583
|
+
<label>Fields (JSON) <textarea id="contact-edit-fields" rows="5">${esc(fieldsText)}</textarea></label>
|
|
584
|
+
<div class="row">
|
|
585
|
+
<button type="submit">Save</button>
|
|
586
|
+
</div>
|
|
587
|
+
</form>
|
|
588
|
+
</details>
|
|
589
|
+
|
|
590
|
+
<details style="margin-top:0.8rem;" open>
|
|
591
|
+
<summary><strong>Call</strong></summary>
|
|
592
|
+
<form id="contact-call-form" data-contact-id="${esc(contact.id)}" style="margin-top:0.6rem;">
|
|
593
|
+
<label>Message <textarea id="contact-call-message" rows="4" placeholder="Message to send"></textarea></label>
|
|
594
|
+
<div class="row">
|
|
595
|
+
<button type="submit" ${canCall ? '' : 'disabled'}>Call</button>
|
|
596
|
+
</div>
|
|
597
|
+
</form>
|
|
598
|
+
</details>
|
|
599
|
+
|
|
600
|
+
<details style="margin-top:0.8rem;">
|
|
601
|
+
<summary><strong>Call history</strong></summary>
|
|
602
|
+
<div style="margin-top:0.6rem;">
|
|
603
|
+
<table>
|
|
604
|
+
<thead><tr><th>ID</th><th>Status</th><th>Updated</th><th>Summary</th><th>Action</th></tr></thead>
|
|
605
|
+
<tbody>${callRows || '<tr><td colspan="5">No calls found.</td></tr>'}</tbody>
|
|
606
|
+
</table>
|
|
607
|
+
</div>
|
|
608
|
+
</details>
|
|
177
609
|
`;
|
|
610
|
+
|
|
611
|
+
const editForm = document.getElementById('contact-edit-form');
|
|
612
|
+
if (editForm) {
|
|
613
|
+
const mineEl = document.getElementById('contact-edit-mine');
|
|
614
|
+
const serverNameEl = document.getElementById('contact-edit-server-name');
|
|
615
|
+
mineEl?.addEventListener('change', () => {
|
|
616
|
+
if (!serverNameEl) return;
|
|
617
|
+
serverNameEl.disabled = !mineEl.checked;
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
editForm.addEventListener('submit', async (e) => {
|
|
621
|
+
e.preventDefault();
|
|
622
|
+
const id = editForm.dataset.contactId;
|
|
623
|
+
if (!id) return;
|
|
624
|
+
|
|
625
|
+
const tagsRaw = document.getElementById('contact-edit-tags').value.trim();
|
|
626
|
+
const tags = tagsRaw
|
|
627
|
+
? tagsRaw.split(',').map(v => v.trim()).filter(Boolean).slice(0, 30)
|
|
628
|
+
: [];
|
|
629
|
+
|
|
630
|
+
let fields = {};
|
|
631
|
+
const fieldsRaw = document.getElementById('contact-edit-fields').value.trim();
|
|
632
|
+
if (fieldsRaw) {
|
|
633
|
+
try {
|
|
634
|
+
const parsed = JSON.parse(fieldsRaw);
|
|
635
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
636
|
+
throw new Error('Fields must be a JSON object');
|
|
637
|
+
}
|
|
638
|
+
fields = parsed;
|
|
639
|
+
} catch (err) {
|
|
640
|
+
showNotice(`Fields JSON invalid: ${err.message}`);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
await request(`/contacts/${encodeURIComponent(id)}`, {
|
|
647
|
+
method: 'PUT',
|
|
648
|
+
body: JSON.stringify({
|
|
649
|
+
name: document.getElementById('contact-edit-name').value,
|
|
650
|
+
owner: document.getElementById('contact-edit-owner').value,
|
|
651
|
+
is_mine: Boolean(document.getElementById('contact-edit-mine')?.checked),
|
|
652
|
+
server_name: document.getElementById('contact-edit-server-name').value,
|
|
653
|
+
notes: document.getElementById('contact-edit-notes').value,
|
|
654
|
+
tags,
|
|
655
|
+
fields
|
|
656
|
+
})
|
|
657
|
+
});
|
|
658
|
+
showNotice('Contact saved');
|
|
659
|
+
await loadContacts();
|
|
660
|
+
await loadCallsForContact(id);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
showNotice(err.message);
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const callForm = document.getElementById('contact-call-form');
|
|
668
|
+
if (callForm) {
|
|
669
|
+
callForm.addEventListener('submit', async (e) => {
|
|
670
|
+
e.preventDefault();
|
|
671
|
+
const id = callForm.dataset.contactId;
|
|
672
|
+
if (!id) return;
|
|
673
|
+
const message = document.getElementById('contact-call-message').value.trim();
|
|
674
|
+
if (!message) {
|
|
675
|
+
showNotice('Message required');
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
await callContact(id, message);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function openCallTranscript(conversationId) {
|
|
684
|
+
const id = String(conversationId || '').trim();
|
|
685
|
+
if (!id) return;
|
|
686
|
+
try { window.location.hash = 'calls'; } catch (err) {}
|
|
687
|
+
// Let hashchange tab switch complete before rendering details.
|
|
688
|
+
setTimeout(() => loadCallDetail(id).catch(err => showNotice(err.message)), 50);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function callContact(contactId, message) {
|
|
692
|
+
const id = String(contactId || '').trim();
|
|
693
|
+
if (!id) return;
|
|
694
|
+
state.selectedContactId = id;
|
|
695
|
+
state.contactCallResult = { success: false, error: null, response: null, conversation_id: null };
|
|
696
|
+
renderContactDetail();
|
|
697
|
+
|
|
698
|
+
try {
|
|
699
|
+
const result = await request(`/contacts/${encodeURIComponent(id)}/call`, {
|
|
700
|
+
method: 'POST',
|
|
701
|
+
body: JSON.stringify({ message })
|
|
702
|
+
});
|
|
703
|
+
state.contactCallResult = {
|
|
704
|
+
success: true,
|
|
705
|
+
response: result.response || '',
|
|
706
|
+
conversation_id: result.conversation_id || null
|
|
707
|
+
};
|
|
708
|
+
showNotice('Call complete');
|
|
709
|
+
await Promise.all([loadContacts(), loadCalls()]);
|
|
710
|
+
await loadCallsForContact(id);
|
|
711
|
+
} catch (err) {
|
|
712
|
+
state.contactCallResult = { success: false, error: err.message, response: null, conversation_id: null };
|
|
713
|
+
renderContactDetail();
|
|
714
|
+
showNotice(err.message);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function loadCallsForContact(contactId, contactName) {
|
|
719
|
+
const id = String(contactId || '').trim();
|
|
720
|
+
if (!id) return;
|
|
721
|
+
state.selectedContactId = id;
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
const payload = await request(`/contacts/${encodeURIComponent(id)}/calls?limit=100`);
|
|
725
|
+
state.selectedContactCalls = payload.calls || [];
|
|
726
|
+
} catch (err) {
|
|
727
|
+
state.selectedContactCalls = [];
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
renderContacts();
|
|
731
|
+
renderContactDetail();
|
|
178
732
|
}
|
|
179
733
|
|
|
180
734
|
function readLogFilters() {
|
|
@@ -428,6 +982,178 @@ async function loadSettings() {
|
|
|
428
982
|
document.getElementById('defaults-max-calls').value = payload.defaults?.maxCalls || 100;
|
|
429
983
|
}
|
|
430
984
|
|
|
985
|
+
function renderCallbookStatus() {
|
|
986
|
+
const el = document.getElementById('callbook-status');
|
|
987
|
+
if (!el) return;
|
|
988
|
+
|
|
989
|
+
const s = state.dashboardStatus;
|
|
990
|
+
if (!s) {
|
|
991
|
+
el.textContent = 'Loading…';
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const warnings = Array.isArray(s.warnings) ? s.warnings : [];
|
|
996
|
+
const publicUrl = s.public_dashboard_url || '-';
|
|
997
|
+
const enabled = Boolean(s.callbook && s.callbook.enabled);
|
|
998
|
+
const deviceCount = s.callbook && Number.isFinite(s.callbook.device_count) ? s.callbook.device_count : 0;
|
|
999
|
+
const invite = s.invite_host || null;
|
|
1000
|
+
const inviteSource = invite && invite.source ? invite.source : null;
|
|
1001
|
+
const inviteResolved = invite && invite.host ? invite.host : null;
|
|
1002
|
+
const ext = s.external_ip || null;
|
|
1003
|
+
const extAttempts = ext && Array.isArray(ext.attempts) ? ext.attempts : [];
|
|
1004
|
+
const extMeta = [];
|
|
1005
|
+
if (ext && ext.source) extMeta.push(ext.source);
|
|
1006
|
+
if (ext && ext.checked_at) extMeta.push(`checked ${fmtDate(ext.checked_at)}`);
|
|
1007
|
+
if (ext && ext.from_cache) extMeta.push('cache');
|
|
1008
|
+
if (ext && ext.stale) extMeta.push('stale');
|
|
1009
|
+
const extMetaText = extMeta.length ? ` <span class="mono">(${esc(extMeta.join(', '))})</span>` : '';
|
|
1010
|
+
const extErrorText = ext && ext.error ? esc(ext.error) : '';
|
|
1011
|
+
const extAttemptsHtml = extAttempts.length
|
|
1012
|
+
? `<details style="margin-top:0.5rem;">
|
|
1013
|
+
<summary>External IP probe</summary>
|
|
1014
|
+
<div class="mono" style="margin-top:0.35rem;">
|
|
1015
|
+
${extAttempts.map(a => {
|
|
1016
|
+
const service = a && a.service ? String(a.service) : '-';
|
|
1017
|
+
const ok = Boolean(a && a.ok);
|
|
1018
|
+
const status = a && a.statusCode ? ` (${a.statusCode})` : '';
|
|
1019
|
+
const err = a && a.error ? ` (${a.error})` : '';
|
|
1020
|
+
return esc(`${service}: ${ok ? 'ok' + status : 'failed' + err}`);
|
|
1021
|
+
}).join('<br>')}
|
|
1022
|
+
</div>
|
|
1023
|
+
</details>`
|
|
1024
|
+
: '';
|
|
1025
|
+
|
|
1026
|
+
el.innerHTML = `
|
|
1027
|
+
<div><strong>Public dashboard URL:</strong> <span class="mono">${esc(publicUrl)}</span></div>
|
|
1028
|
+
<div><strong>Invite host:</strong> <span class="mono">${esc(inviteResolved || '-')}</span>${inviteSource ? ` <span class="mono">(${esc(inviteSource)})</span>` : ''}</div>
|
|
1029
|
+
<div><strong>External IP (egress):</strong> <span class="mono">${esc((ext && ext.ip) ? ext.ip : '-')}</span>${extMetaText}</div>
|
|
1030
|
+
${extErrorText ? `<div style="margin-top:0.35rem;"><strong>External IP error:</strong> <span class="mono">${extErrorText}</span></div>` : ''}
|
|
1031
|
+
${extAttemptsHtml}
|
|
1032
|
+
<div><strong>Callbook session storage:</strong> ${enabled ? 'enabled' : 'disabled'}</div>
|
|
1033
|
+
<div><strong>Paired devices:</strong> ${deviceCount}</div>
|
|
1034
|
+
${warnings.length ? `<div style="margin-top:0.5rem;"><strong>Warnings:</strong><br>${warnings.map(w => esc(w)).join('<br>')}</div>` : ''}
|
|
1035
|
+
`;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
async function loadDashboardStatus(refreshIp = false) {
|
|
1039
|
+
const payload = await request(`/status${refreshIp ? '?refresh_ip=true' : ''}`);
|
|
1040
|
+
state.dashboardStatus = payload;
|
|
1041
|
+
renderCallbookStatus();
|
|
1042
|
+
renderContacts();
|
|
1043
|
+
renderContactDetail();
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function renderCallbookDevices() {
|
|
1047
|
+
const tbody = document.querySelector('#callbook-devices-table tbody');
|
|
1048
|
+
if (!tbody) return;
|
|
1049
|
+
tbody.innerHTML = '';
|
|
1050
|
+
|
|
1051
|
+
const devices = Array.isArray(state.callbookDevices) ? state.callbookDevices : [];
|
|
1052
|
+
if (devices.length === 0) {
|
|
1053
|
+
const tr = document.createElement('tr');
|
|
1054
|
+
tr.innerHTML = '<td colspan="6">No devices found.</td>';
|
|
1055
|
+
tbody.appendChild(tr);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
devices.forEach(dev => {
|
|
1060
|
+
const tr = document.createElement('tr');
|
|
1061
|
+
const revoked = Boolean(dev.revoked_at);
|
|
1062
|
+
const sessions = dev.active_sessions ?? '-';
|
|
1063
|
+
tr.innerHTML = `
|
|
1064
|
+
<td>${esc(dev.label || dev.id || '-')}</td>
|
|
1065
|
+
<td>${esc(fmtDate(dev.created_at))}</td>
|
|
1066
|
+
<td>${esc(fmtDate(dev.last_used_at))}</td>
|
|
1067
|
+
<td>${esc(String(sessions))}</td>
|
|
1068
|
+
<td>${revoked ? esc(fmtDate(dev.revoked_at)) : '-'}</td>
|
|
1069
|
+
<td>
|
|
1070
|
+
<button data-revoke="${esc(dev.id)}" ${revoked ? 'disabled' : ''}>Revoke</button>
|
|
1071
|
+
</td>
|
|
1072
|
+
`;
|
|
1073
|
+
tbody.appendChild(tr);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
tbody.querySelectorAll('button[data-revoke]').forEach(btn => {
|
|
1077
|
+
btn.addEventListener('click', async () => {
|
|
1078
|
+
const deviceId = btn.dataset.revoke;
|
|
1079
|
+
if (!deviceId) return;
|
|
1080
|
+
btn.disabled = true;
|
|
1081
|
+
try {
|
|
1082
|
+
await request(`/callbook/devices/${encodeURIComponent(deviceId)}/revoke`, { method: 'POST' });
|
|
1083
|
+
showNotice('Device revoked');
|
|
1084
|
+
await loadCallbookDevices();
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
showNotice(err.message);
|
|
1087
|
+
btn.disabled = false;
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async function loadCallbookDevices() {
|
|
1094
|
+
const payload = await request('/callbook/devices?include_revoked=true');
|
|
1095
|
+
state.callbookDevices = payload.devices || [];
|
|
1096
|
+
renderCallbookDevices();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function bindCallbookActions() {
|
|
1100
|
+
const form = document.getElementById('callbook-provision-form');
|
|
1101
|
+
if (!form) return;
|
|
1102
|
+
|
|
1103
|
+
const urlEl = document.getElementById('callbook-install-url');
|
|
1104
|
+
const labelEl = document.getElementById('callbook-label');
|
|
1105
|
+
const warningsEl = document.getElementById('callbook-warnings');
|
|
1106
|
+
|
|
1107
|
+
document.getElementById('callbook-refresh')?.addEventListener('click', () => {
|
|
1108
|
+
Promise.all([loadDashboardStatus(true), loadCallbookDevices()]).catch(err => showNotice(err.message));
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
document.getElementById('callbook-refresh-devices')?.addEventListener('click', () => {
|
|
1112
|
+
loadCallbookDevices().catch(err => showNotice(err.message));
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
document.getElementById('callbook-logout')?.addEventListener('click', async () => {
|
|
1116
|
+
try {
|
|
1117
|
+
await request('/callbook/logout', { method: 'POST' });
|
|
1118
|
+
showNotice('Logged out (cookie cleared)');
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
showNotice(err.message);
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
document.getElementById('callbook-copy-url')?.addEventListener('click', async () => {
|
|
1125
|
+
const ok = await copyText(urlEl?.value || '');
|
|
1126
|
+
showNotice(ok ? 'Copied' : 'Copy failed');
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
form.addEventListener('submit', async (e) => {
|
|
1130
|
+
e.preventDefault();
|
|
1131
|
+
if (warningsEl) warningsEl.textContent = '';
|
|
1132
|
+
if (urlEl) urlEl.value = '';
|
|
1133
|
+
|
|
1134
|
+
const body = {
|
|
1135
|
+
label: labelEl ? labelEl.value : 'Callbook Remote',
|
|
1136
|
+
ttl_hours: 24
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
try {
|
|
1140
|
+
const result = await request('/callbook/provision', {
|
|
1141
|
+
method: 'POST',
|
|
1142
|
+
body: JSON.stringify(body)
|
|
1143
|
+
});
|
|
1144
|
+
if (urlEl) urlEl.value = result.install_url || '';
|
|
1145
|
+
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
|
1146
|
+
const expiresAt = result.expires_at ? `Expires: ${fmtDate(result.expires_at)}` : '';
|
|
1147
|
+
if (warningsEl) {
|
|
1148
|
+
warningsEl.textContent = [expiresAt, ...warnings].filter(Boolean).join('\n');
|
|
1149
|
+
}
|
|
1150
|
+
showNotice('Install link created');
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
showNotice(err.message);
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
|
|
431
1157
|
function renderInvites() {
|
|
432
1158
|
const tbody = document.querySelector('#invites-table tbody');
|
|
433
1159
|
tbody.innerHTML = '';
|
|
@@ -518,12 +1244,23 @@ function bindRefreshButtons() {
|
|
|
518
1244
|
|
|
519
1245
|
async function bootstrap() {
|
|
520
1246
|
bindTabs();
|
|
1247
|
+
bindContactsActions();
|
|
521
1248
|
bindSettingsActions();
|
|
1249
|
+
bindCallbookActions();
|
|
522
1250
|
bindInviteActions();
|
|
523
1251
|
bindRefreshButtons();
|
|
524
1252
|
|
|
525
1253
|
try {
|
|
526
|
-
await Promise.all([
|
|
1254
|
+
await Promise.all([
|
|
1255
|
+
loadSettings(),
|
|
1256
|
+
loadDashboardStatus(),
|
|
1257
|
+
loadCallbookDevices(),
|
|
1258
|
+
loadContacts(),
|
|
1259
|
+
loadCalls(),
|
|
1260
|
+
loadInvites(),
|
|
1261
|
+
loadLogStats(),
|
|
1262
|
+
loadLogs()
|
|
1263
|
+
]);
|
|
527
1264
|
showNotice('Dashboard loaded');
|
|
528
1265
|
} catch (err) {
|
|
529
1266
|
showNotice(err.message);
|