agent-relay-server 0.3.11 → 0.4.0

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/public/index.html CHANGED
@@ -81,6 +81,10 @@
81
81
  <a href="#" class="nav-link" :class="{ active: view === 'messages' }" @click.prevent="switchView('messages')">
82
82
  <i class="ti ti-messages"></i>Messages
83
83
  </a>
84
+ <a href="#" class="nav-link" :class="{ active: view === 'tasks' }" @click.prevent="switchView('tasks')">
85
+ <i class="ti ti-checkup-list"></i>Tasks
86
+ <span class="badge bg-warning text-white ms-auto" x-text="stats.openTasks ?? 0"></span>
87
+ </a>
84
88
  <a href="#" class="nav-link" :class="{ active: view === 'analytics' }" @click.prevent="switchView('analytics')">
85
89
  <i class="ti ti-chart-area-line"></i>Analytics
86
90
  </a>
@@ -96,20 +100,34 @@
96
100
  <span class="form-check-label small">Show offline</span>
97
101
  </label>
98
102
  </div>
99
- <div class="text-muted small" x-show="stats.version" x-text="'v' + stats.version"></div>
100
- </div>
101
- </aside>
103
+ <div class="text-muted small" x-show="stats.version" x-text="'v' + stats.version"></div>
104
+ </div>
105
+ </aside>
102
106
 
103
107
  <!-- Mobile nav -->
104
108
  <div class="mobile-nav d-none border-bottom p-2 gap-1 position-fixed top-0 w-100 bg-dark" style="z-index:50">
105
- <template x-for="v in ['overview','agents','messages','analytics']">
109
+ <template x-for="v in ['overview','agents','messages','tasks','analytics']">
106
110
  <button class="btn btn-sm" :class="view === v ? 'btn-primary' : 'btn-ghost-secondary'" @click="switchView(v)" x-text="v.charAt(0).toUpperCase() + v.slice(1)"></button>
107
111
  </template>
108
112
  </div>
109
113
 
110
114
  <!-- Main content -->
111
- <main class="flex-grow-1 overflow-auto" style="height: 100vh">
112
- <div class="container-xl py-3">
115
+ <main class="flex-grow-1 overflow-auto" style="height: 100vh">
116
+ <div class="container-xl py-3">
117
+ <template x-if="authNeeded">
118
+ <div class="alert alert-warning d-flex align-items-center gap-2 mb-3">
119
+ <i class="ti ti-lock"></i>
120
+ <input
121
+ type="password"
122
+ class="form-control form-control-sm"
123
+ style="max-width: 320px"
124
+ placeholder="Agent Relay token"
125
+ x-model="authToken"
126
+ @keydown.enter="saveTokenAndRefresh()"
127
+ >
128
+ <button class="btn btn-sm btn-warning" @click="saveTokenAndRefresh()">Unlock</button>
129
+ </div>
130
+ </template>
113
131
 
114
132
  <!-- ==================== OVERVIEW ==================== -->
115
133
  <div x-show="view === 'overview'" x-cloak class="fade-in">
@@ -148,7 +166,7 @@
148
166
  <div class="d-flex align-items-center">
149
167
  <div>
150
168
  <div class="text-secondary small">Messages (24h)</div>
151
- <div class="h1 mb-0 text-info" x-text="stats.messages24h ?? 0"></div>
169
+ <div class="h1 mb-0 text-info" x-text="stats.messagesLast24h ?? 0"></div>
152
170
  </div>
153
171
  <i class="ti ti-mail ms-auto stat-card"></i>
154
172
  </div>
@@ -446,6 +464,90 @@
446
464
  </div>
447
465
  </div>
448
466
 
467
+ <!-- ==================== TASKS ==================== -->
468
+ <div x-show="view === 'tasks'" x-cloak class="fade-in">
469
+ <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
470
+ <h2 class="page-title mb-0">Tasks</h2>
471
+ <div class="ms-auto d-flex gap-2 align-items-center flex-wrap">
472
+ <select class="form-select form-select-sm" style="width:auto; min-width: 150px" x-model="taskStatusFilter" @change="fetchTasks()">
473
+ <option value="">Status: Active</option>
474
+ <option value="open">Status: Open</option>
475
+ <option value="claimed">Status: Claimed</option>
476
+ <option value="in_progress">Status: In Progress</option>
477
+ <option value="blocked">Status: Blocked</option>
478
+ <option value="done">Status: Done</option>
479
+ <option value="failed">Status: Failed</option>
480
+ <option value="canceled">Status: Canceled</option>
481
+ </select>
482
+ <input type="text" class="form-control form-control-sm" style="width: 150px" placeholder="Source" x-model.debounce.300ms="taskSourceFilter" @input="fetchTasks()">
483
+ <button class="btn btn-sm btn-ghost-secondary" @click="fetchTasks()">
484
+ <i class="ti ti-refresh"></i>
485
+ </button>
486
+ </div>
487
+ </div>
488
+
489
+ <div class="row g-3">
490
+ <template x-for="task in filteredTasks" :key="task.id">
491
+ <div class="col-lg-6">
492
+ <div class="card">
493
+ <div class="card-body">
494
+ <div class="d-flex align-items-start gap-2">
495
+ <span class="badge" :class="severityClass(task.severity)" x-text="task.severity"></span>
496
+ <div class="flex-grow-1 min-width-0">
497
+ <div class="d-flex align-items-center gap-2">
498
+ <span class="fw-bold text-truncate" x-text="task.title"></span>
499
+ <span class="badge bg-secondary-lt" x-text="task.status"></span>
500
+ </div>
501
+ <div class="text-secondary small mt-1">
502
+ <span x-text="'#' + task.id"></span>
503
+ <span class="mx-1">·</span>
504
+ <span x-text="task.source"></span>
505
+ <span class="mx-1">·</span>
506
+ <span x-text="displayTarget(task.target)"></span>
507
+ <template x-if="task.occurrenceCount > 1">
508
+ <span class="badge bg-warning-lt ms-1" x-text="'x' + task.occurrenceCount"></span>
509
+ </template>
510
+ </div>
511
+ <div class="msg-body mt-2" x-text="task.body"></div>
512
+ <div class="d-flex align-items-center gap-2 mt-2 flex-wrap">
513
+ <template x-if="task.channel">
514
+ <span class="badge bg-warning-lt" x-text="'#' + task.channel"></span>
515
+ </template>
516
+ <template x-if="task.dedupeKey">
517
+ <span class="badge bg-secondary-lt" x-text="task.dedupeKey"></span>
518
+ </template>
519
+ <template x-if="task.claimedBy">
520
+ <span class="badge bg-success-lt" x-text="'claimed by ' + displayTarget(task.claimedBy)"></span>
521
+ </template>
522
+ <template x-if="task.externalUrl">
523
+ <a class="btn btn-sm btn-ghost-secondary py-0 px-1" :href="task.externalUrl" target="_blank" rel="noreferrer">
524
+ <i class="ti ti-external-link" style="font-size:14px"></i>
525
+ </a>
526
+ </template>
527
+ <button class="btn btn-sm btn-ghost-secondary py-0 px-1 ms-auto" @click="openTaskEvents(task)">
528
+ <i class="ti ti-history" style="font-size:14px"></i>
529
+ Events
530
+ </button>
531
+ </div>
532
+ </div>
533
+ <span class="text-secondary small" x-text="timeAgo(task.updatedAt)"></span>
534
+ </div>
535
+ </div>
536
+ </div>
537
+ </div>
538
+ </template>
539
+ </div>
540
+
541
+ <template x-if="filteredTasks.length === 0">
542
+ <div class="card">
543
+ <div class="card-body text-center text-secondary py-5">
544
+ <i class="ti ti-checkup-list" style="font-size:48px; opacity:0.3"></i>
545
+ <p class="mt-2">No tasks</p>
546
+ </div>
547
+ </div>
548
+ </template>
549
+ </div>
550
+
449
551
  <!-- ==================== ANALYTICS ==================== -->
450
552
  <div x-show="view === 'analytics'" x-cloak class="fade-in">
451
553
  <h2 class="page-title mb-3">Analytics</h2>
@@ -471,7 +573,7 @@
471
573
  <div class="card">
472
574
  <div class="card-body text-center">
473
575
  <div class="text-secondary small">Messages (24h)</div>
474
- <div class="h2 mb-0 text-info" x-text="stats.messages24h ?? 0"></div>
576
+ <div class="h2 mb-0 text-info" x-text="stats.messagesLast24h ?? 0"></div>
475
577
  </div>
476
578
  </div>
477
579
  </div>
@@ -638,6 +740,38 @@
638
740
  </div>
639
741
  <div class="modal-backdrop fade show" x-show="threadOpen" x-cloak></div>
640
742
 
743
+ <!-- ==================== TASK EVENTS MODAL ==================== -->
744
+ <div class="modal modal-blur" :class="{ show: taskEventsOpen }" :style="taskEventsOpen ? 'display:block' : 'display:none'" tabindex="-1" @click.self="taskEventsOpen = false">
745
+ <div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
746
+ <div class="modal-content">
747
+ <div class="modal-header">
748
+ <h5 class="modal-title">Task Events</h5>
749
+ <button class="btn-close" @click="taskEventsOpen = false"></button>
750
+ </div>
751
+ <div class="modal-body p-0">
752
+ <template x-for="event in taskEvents" :key="event.id">
753
+ <div class="p-3 border-bottom">
754
+ <div class="d-flex align-items-center gap-2 mb-1">
755
+ <span class="badge" :class="severityClass(event.severity)" x-text="event.severity"></span>
756
+ <span class="fw-bold small" x-text="event.type"></span>
757
+ <span class="text-secondary small ms-auto" x-text="'#' + event.id + ' · ' + fmtTime(event.createdAt)"></span>
758
+ </div>
759
+ <div class="fw-bold small mb-1" x-text="event.title"></div>
760
+ <div class="msg-body" x-text="event.body"></div>
761
+ </div>
762
+ </template>
763
+ <template x-if="taskEvents.length === 0">
764
+ <div class="p-4 text-center text-secondary">No events</div>
765
+ </template>
766
+ </div>
767
+ <div class="modal-footer">
768
+ <button class="btn btn-ghost-secondary" @click="taskEventsOpen = false">Close</button>
769
+ </div>
770
+ </div>
771
+ </div>
772
+ </div>
773
+ <div class="modal-backdrop fade show" x-show="taskEventsOpen" x-cloak></div>
774
+
641
775
  <!-- ==================== CONFIRM MODAL ==================== -->
642
776
  <div class="modal modal-blur" :class="{ show: confirmModal.show }" :style="confirmModal.show ? 'display:block' : 'display:none'" tabindex="-1" @click.self="confirmModal.show = false">
643
777
  <div class="modal-dialog modal-sm modal-dialog-centered">
@@ -683,502 +817,7 @@
683
817
  <script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
684
818
  <script src="https://cdn.jsdelivr.net/npm/apexcharts@latest/dist/apexcharts.min.js"></script>
685
819
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
686
- <script>
687
- function relay() {
688
- const load = (key, fallback) => {
689
- try { const v = localStorage.getItem('ar-' + key); return v !== null ? JSON.parse(v) : fallback; }
690
- catch { return fallback; }
691
- };
692
-
693
- return {
694
- // Navigation
695
- view: load('view', 'overview'),
696
-
697
- // Persisted preferences
698
- showOffline: load('showOffline', false),
699
- autoRefresh: load('autoRefresh', true),
700
- agentSort: load('agentSort', 'status'),
701
- agentSortDir: load('agentSortDir', 'asc'),
702
-
703
- // Data
704
- agents: [],
705
- agentsById: {},
706
- messages: [],
707
- stats: {},
708
-
709
- // UI state
710
- selectedAgent: '',
711
- replyTo: null,
712
- composeOpen: false,
713
- threadOpen: false,
714
- threadMessages: [],
715
- connected: false,
716
-
717
- // Compose form
718
- compose: { from: '', to: '', body: '', channel: '', subject: '', claimable: false },
719
-
720
- // Modals
721
- confirmModal: { show: false, title: '', message: '', action: null },
722
- renameModal: { show: false, agentId: '', label: '' },
723
-
724
- // Filters
725
- channelFilter: '',
726
- tagFilter: '',
727
- agentStatusFilter: load('agentStatusFilter', ''),
728
- agentTagFilter: load('agentTagFilter', ''),
729
-
730
- // Charts
731
- chartInstances: {},
732
-
733
- // SSE + polling
734
- _es: null,
735
- _statsTimer: null,
736
-
737
- async init() {
738
- await this.refresh();
739
- this.connectSSE();
740
- // Slow poll for stats only — SSE handles real-time data
741
- this._statsTimer = setInterval(() => this.fetchStats(), 30_000);
742
-
743
- this.$watch('showOffline', v => this.save('showOffline', v));
744
- this.$watch('agentSort', v => this.save('agentSort', v));
745
- this.$watch('agentSortDir', v => this.save('agentSortDir', v));
746
- this.$watch('agentStatusFilter', v => this.save('agentStatusFilter', v));
747
- this.$watch('agentTagFilter', v => this.save('agentTagFilter', v));
748
- this.$watch('view', v => {
749
- this.save('view', v);
750
- if (v === 'analytics') this.$nextTick(() => this.renderCharts());
751
- });
752
- },
753
-
754
- connectSSE() {
755
- if (this._es) this._es.close();
756
- const es = new EventSource(window.location.origin + '/api/events');
757
- this._es = es;
758
-
759
- es.addEventListener('connected', () => { this.connected = true; });
760
- es.onerror = () => { this.connected = false; };
761
-
762
- es.addEventListener('message.new', (e) => {
763
- const msg = JSON.parse(e.data);
764
- if (this.messages.some(m => m.id === msg.id)) return;
765
- if (this.selectedAgent && msg.from !== this.selectedAgent && msg.to !== this.selectedAgent) return;
766
- if (this.channelFilter && msg.channel !== this.channelFilter) return;
767
- this.messages.push(msg);
768
- if (this.messages.length > 200) this.messages.shift();
769
- this.stats.messages = (this.stats.messages ?? 0) + 1;
770
- });
771
-
772
- es.addEventListener('agent.status', (e) => {
773
- const data = JSON.parse(e.data);
774
- const idx = this.agents.findIndex(a => a.id === data.id);
775
- if (idx >= 0) {
776
- this.agents[idx] = data;
777
- } else {
778
- this.agents.push(data);
779
- }
780
- this.agentsById[data.id] = data;
781
- });
782
-
783
- es.addEventListener('agent.removed', (e) => {
784
- const { id } = JSON.parse(e.data);
785
- this.agents = this.agents.filter(a => a.id !== id);
786
- delete this.agentsById[id];
787
- });
788
-
789
- es.addEventListener('message.claimed', (e) => {
790
- const { messageId, claimedBy } = JSON.parse(e.data);
791
- const msg = this.messages.find(m => m.id === messageId);
792
- if (msg) msg.claimedBy = claimedBy;
793
- });
794
-
795
- es.addEventListener('message.deleted', (e) => {
796
- const { messageId } = JSON.parse(e.data);
797
- this.messages = this.messages.filter(m => m.id !== messageId);
798
- });
799
- },
800
-
801
- save(key, value) {
802
- localStorage.setItem('ar-' + key, JSON.stringify(value));
803
- },
804
-
805
- switchView(v) {
806
- this.view = v;
807
- if (v === 'messages') this.fetchMessages();
808
- },
809
-
810
- // ── API ──
811
-
812
- async api(method, path, body) {
813
- const opts = { method, headers: {} };
814
- if (body) {
815
- opts.headers['Content-Type'] = 'application/json';
816
- opts.body = JSON.stringify(body);
817
- }
818
- const r = await fetch(window.location.origin + '/api' + path, opts);
819
- if (!r.ok) {
820
- const text = await r.text();
821
- throw new Error(text || r.statusText);
822
- }
823
- return r.json();
824
- },
825
-
826
- async refresh() {
827
- await Promise.all([this.fetchStats(), this.fetchAgents(), this.fetchMessages()]);
828
- },
829
-
830
- async fetchStats() {
831
- try { this.stats = await this.api('GET', '/stats'); } catch {}
832
- },
833
-
834
- async fetchAgents() {
835
- try {
836
- this.agents = await this.api('GET', '/agents');
837
- this.agentsById = {};
838
- for (const a of this.agents) this.agentsById[a.id] = a;
839
- } catch {}
840
- },
841
-
842
- async fetchMessages() {
843
- try {
844
- let path = '/messages?limit=100';
845
- if (this.selectedAgent) path += '&for=' + encodeURIComponent(this.selectedAgent);
846
- if (this.channelFilter) path += '&channel=' + encodeURIComponent(this.channelFilter);
847
- this.messages = await this.api('GET', path);
848
- } catch {}
849
- },
850
-
851
- // ── Computed ──
852
-
853
- get onlineCount() {
854
- return this.agents.filter(a => a.status !== 'offline').length;
855
- },
856
-
857
- get sortedAgents() {
858
- let list = this.showOffline ? [...this.agents] : this.agents.filter(a => a.status !== 'offline');
859
- if (this.agentStatusFilter) {
860
- if (this.agentStatusFilter === 'starting') {
861
- list = list.filter(a => a.status !== 'offline' && !a.ready);
862
- } else {
863
- list = list.filter(a => a.status === this.agentStatusFilter);
864
- }
865
- }
866
- if (this.agentTagFilter) {
867
- list = list.filter(a => (a.tags || []).includes(this.agentTagFilter));
868
- }
869
- const dir = this.agentSortDir === 'desc' ? -1 : 1;
870
- return list.sort((a, b) => {
871
- let cmp = 0;
872
- switch (this.agentSort) {
873
- case 'name': cmp = this.displayName(a).localeCompare(this.displayName(b)); break;
874
- case 'status': {
875
- const order = { online: 0, idle: 1, busy: 2, offline: 3 };
876
- cmp = (order[a.status] ?? 9) - (order[b.status] ?? 9);
877
- break;
878
- }
879
- case 'lastSeen': cmp = new Date(b.lastSeen) - new Date(a.lastSeen); break;
880
- case 'created': cmp = new Date(b.createdAt) - new Date(a.createdAt); break;
881
- }
882
- return cmp * dir;
883
- });
884
- },
885
-
886
- get filteredMessages() {
887
- if (!this.tagFilter) return this.messages;
888
- const tag = this.tagFilter;
889
- return this.messages.filter(m => {
890
- if (m.to === 'tag:' + tag) return true;
891
- const fromAgent = this.agentsById[m.from];
892
- if (fromAgent?.tags?.includes(tag)) return true;
893
- const toAgent = this.agentsById[m.to];
894
- if (toAgent?.tags?.includes(tag)) return true;
895
- return false;
896
- });
897
- },
898
-
899
- get groupedMessages() {
900
- const threads = new Map();
901
- for (const m of this.filteredMessages) {
902
- const tid = m.threadId || m.id;
903
- if (!threads.has(tid)) threads.set(tid, { threadId: tid, messages: [] });
904
- threads.get(tid).messages.push(m);
905
- }
906
- for (const group of threads.values()) {
907
- group.messages.sort((a, b) => a.id - b.id);
908
- }
909
- return [...threads.values()].sort((a, b) => {
910
- const aLast = a.messages[a.messages.length - 1].id;
911
- const bLast = b.messages[b.messages.length - 1].id;
912
- return bLast - aLast;
913
- });
914
- },
915
-
916
- get composeAgents() {
917
- return this.showOffline ? this.agents : this.agents.filter(a => a.status !== 'offline');
918
- },
919
-
920
- get uniqueLabels() {
921
- return [...new Set(this.agents.filter(a => a.label).map(a => a.label))];
922
- },
923
-
924
- get uniqueCaps() {
925
- return [...new Set(this.agents.flatMap(a => a.capabilities || []))];
926
- },
927
-
928
- get uniqueTags() {
929
- return [...new Set(this.agents.flatMap(a => a.tags || []))];
930
- },
931
-
932
- // ── Display helpers ──
933
-
934
- displayName(agent) {
935
- if (!agent) return '?';
936
- return agent.label || agent.name || agent.id.slice(-12);
937
- },
938
-
939
- displayTarget(target) {
940
- if (!target) return '?';
941
- if (target === 'broadcast') return 'broadcast';
942
- if (target.startsWith('tag:')) return '#' + target.slice(4);
943
- if (target.startsWith('cap:')) return target.slice(4);
944
- if (target.startsWith('label:')) return target.slice(6);
945
- const agent = this.agentsById[target];
946
- return agent ? this.displayName(agent) : target.slice(-8);
947
- },
948
-
949
- agentStatusTitle(agent) {
950
- if (!agent) return '';
951
- if (agent.status === 'offline') return 'offline';
952
- if (agent.ready) return agent.status;
953
- const lastSeenMs = new Date(agent.lastSeen).getTime();
954
- if (!Number.isFinite(lastSeenMs)) return 'Trying to reconnect…';
955
- const ageSec = Math.max(0, (Date.now() - lastSeenMs) / 1000);
956
- return ageSec <= 45 ? 'Starting up…' : 'Trying to reconnect…';
957
- },
958
-
959
- timeAgo(iso) {
960
- if (!iso) return '';
961
- const ts = new Date(iso).getTime();
962
- if (!Number.isFinite(ts)) return '';
963
- const diff = Math.max(0, (Date.now() - ts) / 1000);
964
- if (diff < 60) return Math.floor(diff) + 's ago';
965
- if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
966
- if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
967
- return Math.floor(diff / 86400) + 'd ago';
968
- },
969
-
970
- fmtTime(iso) {
971
- if (!iso) return '';
972
- return new Date(iso).toLocaleString();
973
- },
974
-
975
- // ── Actions ──
976
-
977
- openCompose() {
978
- if (!this.replyTo) {
979
- this.compose = { from: 'user', to: '', body: '', channel: '', subject: '', claimable: false };
980
- }
981
- this.composeOpen = true;
982
- this.$nextTick(() => this.$refs.composeBody?.focus());
983
- },
984
-
985
- openComposeToAgent(agent) {
986
- this.replyTo = null;
987
- this.compose = { from: 'user', to: agent.id, body: '', channel: '', subject: '', claimable: false };
988
- this.composeOpen = true;
989
- this.$nextTick(() => this.$refs.composeBody?.focus());
990
- },
991
-
992
- startReply(msg) {
993
- this.replyTo = { id: msg.id, from: msg.from };
994
- this.compose.to = msg.from;
995
- this.compose.from = '';
996
- this.compose.body = '';
997
- this.compose.channel = msg.channel || '';
998
- this.compose.subject = '';
999
- this.compose.claimable = false;
1000
- this.openCompose();
1001
- },
1002
-
1003
- cancelReply() {
1004
- this.replyTo = null;
1005
- },
1006
-
1007
- async doSend() {
1008
- if (!this.compose.from || !this.compose.to || !this.compose.body) {
1009
- alert('From, To, and Message are required.');
1010
- return;
1011
- }
1012
- const payload = {
1013
- from: this.compose.from,
1014
- to: this.compose.to,
1015
- body: this.compose.body,
1016
- };
1017
- if (this.compose.channel) payload.channel = this.compose.channel;
1018
- if (this.compose.subject) payload.subject = this.compose.subject;
1019
- if (this.replyTo) payload.replyTo = this.replyTo.id;
1020
- if (this.compose.claimable) payload.claimable = true;
1021
-
1022
- try {
1023
- await this.api('POST', '/messages', payload);
1024
- this.composeOpen = false;
1025
- this.replyTo = null;
1026
- this.compose = { from: '', to: '', body: '', channel: '', subject: '', claimable: false };
1027
- } catch (e) {
1028
- alert('Send failed: ' + e.message);
1029
- }
1030
- },
1031
-
1032
- async doClaim(msgId) {
1033
- if (!this.compose.from && !this.selectedAgent) {
1034
- alert('Select a "From" agent first (open Compose to pick one).');
1035
- return;
1036
- }
1037
- const agentId = this.compose.from || this.selectedAgent;
1038
- try {
1039
- const result = await this.api('POST', '/messages/' + msgId + '/claim', { agentId });
1040
- if (!result.ok) alert('Claim failed: ' + (result.error || 'unknown'));
1041
- } catch (e) {
1042
- alert('Claim failed: ' + e.message);
1043
- }
1044
- },
1045
-
1046
- async doDeleteMessage(id) {
1047
- try {
1048
- await this.api('DELETE', '/messages/' + id);
1049
- this.messages = this.messages.filter(m => m.id !== id);
1050
- } catch (e) {
1051
- alert('Delete failed: ' + e.message);
1052
- }
1053
- },
1054
-
1055
- async openThread(threadId) {
1056
- this.threadMessages = [];
1057
- this.threadOpen = true;
1058
- try {
1059
- this.threadMessages = await this.api('GET', '/messages/' + threadId + '/thread');
1060
- } catch (e) {
1061
- alert('Failed to load thread: ' + e.message);
1062
- }
1063
- },
1064
-
1065
- openRename(agent) {
1066
- this.renameModal = { show: true, agentId: agent.id, label: agent.label || '' };
1067
- this.$nextTick(() => this.$refs.renameInput?.focus());
1068
- },
1069
-
1070
- async doRename() {
1071
- const label = this.renameModal.label.trim() || null;
1072
- try {
1073
- await this.api('PATCH', '/agents/' + this.renameModal.agentId + '/label', { label });
1074
- this.renameModal.show = false;
1075
- } catch (e) {
1076
- alert('Rename failed: ' + e.message);
1077
- }
1078
- },
1079
-
1080
- openConfirm(title, message, action) {
1081
- this.confirmModal = { show: true, title, message, action };
1082
- },
1083
-
1084
- async doDeleteAgent(id) {
1085
- try {
1086
- await this.api('DELETE', '/agents/' + id);
1087
- if (this.selectedAgent === id) this.selectedAgent = '';
1088
- this.agents = this.agents.filter(a => a.id !== id);
1089
- delete this.agentsById[id];
1090
- } catch (e) {
1091
- alert('Delete failed: ' + e.message);
1092
- }
1093
- },
1094
-
1095
- // ── Charts ──
1096
-
1097
- renderCharts() {
1098
- this.renderVolumeChart();
1099
- this.renderStatusChart();
1100
- this.renderAgentChart();
1101
- },
1102
-
1103
- renderVolumeChart() {
1104
- if (this.chartInstances.volume) this.chartInstances.volume.destroy();
1105
- const el = document.querySelector('#chart-volume');
1106
- if (!el) return;
1107
-
1108
- const buckets = {};
1109
- for (const m of this.messages) {
1110
- const day = m.createdAt ? new Date(m.createdAt).toISOString().split('T')[0] : null;
1111
- if (day) buckets[day] = (buckets[day] || 0) + 1;
1112
- }
1113
- const sorted = Object.entries(buckets).sort((a, b) => a[0].localeCompare(b[0]));
1114
-
1115
- this.chartInstances.volume = new ApexCharts(el, {
1116
- chart: { type: 'area', height: 280, background: 'transparent', toolbar: { show: false } },
1117
- theme: { mode: 'dark' },
1118
- series: [{ name: 'Messages', data: sorted.map(([d, c]) => ({ x: d, y: c })) }],
1119
- xaxis: { type: 'datetime' },
1120
- stroke: { curve: 'smooth', width: 2 },
1121
- fill: { type: 'gradient', gradient: { opacityFrom: 0.4, opacityTo: 0 } },
1122
- dataLabels: { enabled: false },
1123
- colors: ['#4299e1'],
1124
- grid: { borderColor: 'rgba(255,255,255,0.06)' },
1125
- });
1126
- this.chartInstances.volume.render();
1127
- },
1128
-
1129
- renderStatusChart() {
1130
- if (this.chartInstances.status) this.chartInstances.status.destroy();
1131
- const el = document.querySelector('#chart-status');
1132
- if (!el) return;
1133
-
1134
- const counts = { online: 0, idle: 0, busy: 0, offline: 0 };
1135
- for (const a of this.agents) counts[a.status] = (counts[a.status] || 0) + 1;
1136
- const labels = Object.keys(counts).filter(k => counts[k] > 0);
1137
- const series = labels.map(k => counts[k]);
1138
- const colorMap = { online: '#48bb78', idle: '#48bb78', busy: '#ecc94b', offline: '#718096' };
1139
-
1140
- this.chartInstances.status = new ApexCharts(el, {
1141
- chart: { type: 'donut', height: 280, background: 'transparent' },
1142
- theme: { mode: 'dark' },
1143
- series,
1144
- labels,
1145
- colors: labels.map(l => colorMap[l] || '#718096'),
1146
- legend: { position: 'bottom' },
1147
- dataLabels: { enabled: true },
1148
- });
1149
- this.chartInstances.status.render();
1150
- },
1151
-
1152
- renderAgentChart() {
1153
- if (this.chartInstances.agents) this.chartInstances.agents.destroy();
1154
- const el = document.querySelector('#chart-agents');
1155
- if (!el) return;
1156
-
1157
- const counts = {};
1158
- for (const m of this.messages) {
1159
- const name = this.displayTarget(m.from);
1160
- counts[name] = (counts[name] || 0) + 1;
1161
- }
1162
- const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 10);
1163
-
1164
- this.chartInstances.agents = new ApexCharts(el, {
1165
- chart: { type: 'bar', height: 280, background: 'transparent', toolbar: { show: false } },
1166
- theme: { mode: 'dark' },
1167
- series: [{ name: 'Messages', data: sorted.map(([, c]) => c) }],
1168
- xaxis: { categories: sorted.map(([n]) => n) },
1169
- colors: ['#4299e1'],
1170
- plotOptions: { bar: { borderRadius: 4, distributed: true } },
1171
- legend: { show: false },
1172
- grid: { borderColor: 'rgba(255,255,255,0.06)' },
1173
- });
1174
- this.chartInstances.agents.render();
1175
- },
1176
- };
1177
- }
820
+ <script src="/dashboard.js"></script>
1178
821
 
1179
- document.addEventListener('alpine:init', () => {
1180
- Alpine.data('relay', relay);
1181
- });
1182
- </script>
1183
822
  </body>
1184
823
  </html>