agentopia 1.0.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/.claude/settings.local.json +28 -0
- package/dist/app.d.ts +10 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +121 -0
- package/dist/app.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -0
- package/dist/db/database.d.ts +5 -0
- package/dist/db/database.d.ts.map +1 -0
- package/dist/db/database.js +39 -0
- package/dist/db/database.js.map +1 -0
- package/dist/db/schema.d.ts +3 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +621 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +4 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +9 -0
- package/dist/logger.js.map +1 -0
- package/dist/middleware/auth.d.ts +13 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +733 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/routes/agents.d.ts +3 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +1058 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/issues.d.ts +4 -0
- package/dist/routes/issues.d.ts.map +1 -0
- package/dist/routes/issues.js +946 -0
- package/dist/routes/issues.js.map +1 -0
- package/dist/routes/knowledge.d.ts +3 -0
- package/dist/routes/knowledge.d.ts.map +1 -0
- package/dist/routes/knowledge.js +117 -0
- package/dist/routes/knowledge.js.map +1 -0
- package/dist/routes/memories.d.ts +3 -0
- package/dist/routes/memories.d.ts.map +1 -0
- package/dist/routes/memories.js +115 -0
- package/dist/routes/memories.js.map +1 -0
- package/dist/routes/messages.d.ts +3 -0
- package/dist/routes/messages.d.ts.map +1 -0
- package/dist/routes/messages.js +130 -0
- package/dist/routes/messages.js.map +1 -0
- package/dist/routes/projects.d.ts +3 -0
- package/dist/routes/projects.d.ts.map +1 -0
- package/dist/routes/projects.js +754 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/templates.d.ts +3 -0
- package/dist/routes/templates.d.ts.map +1 -0
- package/dist/routes/templates.js +117 -0
- package/dist/routes/templates.js.map +1 -0
- package/dist/routes/ui.d.ts +3 -0
- package/dist/routes/ui.d.ts.map +1 -0
- package/dist/routes/ui.js +38 -0
- package/dist/routes/ui.js.map +1 -0
- package/dist/services/agent-hierarchy.d.ts +14 -0
- package/dist/services/agent-hierarchy.d.ts.map +1 -0
- package/dist/services/agent-hierarchy.js +58 -0
- package/dist/services/agent-hierarchy.js.map +1 -0
- package/dist/services/agent-issue-batch.d.ts +17 -0
- package/dist/services/agent-issue-batch.d.ts.map +1 -0
- package/dist/services/agent-issue-batch.js +57 -0
- package/dist/services/agent-issue-batch.js.map +1 -0
- package/dist/services/controller.d.ts +4 -0
- package/dist/services/controller.d.ts.map +1 -0
- package/dist/services/controller.js +237 -0
- package/dist/services/controller.js.map +1 -0
- package/dist/services/langgraph-runner.d.ts +33 -0
- package/dist/services/langgraph-runner.d.ts.map +1 -0
- package/dist/services/langgraph-runner.js +478 -0
- package/dist/services/langgraph-runner.js.map +1 -0
- package/dist/services/orchestrator.d.ts +9 -0
- package/dist/services/orchestrator.d.ts.map +1 -0
- package/dist/services/orchestrator.js +116 -0
- package/dist/services/orchestrator.js.map +1 -0
- package/dist/services/pre-controller.d.ts +7 -0
- package/dist/services/pre-controller.d.ts.map +1 -0
- package/dist/services/pre-controller.js +101 -0
- package/dist/services/pre-controller.js.map +1 -0
- package/dist/services/process-manager.d.ts +67 -0
- package/dist/services/process-manager.d.ts.map +1 -0
- package/dist/services/process-manager.js +938 -0
- package/dist/services/process-manager.js.map +1 -0
- package/dist/services/project-permissions.d.ts +84 -0
- package/dist/services/project-permissions.d.ts.map +1 -0
- package/dist/services/project-permissions.js +129 -0
- package/dist/services/project-permissions.js.map +1 -0
- package/dist/services/scheduler.d.ts +6 -0
- package/dist/services/scheduler.d.ts.map +1 -0
- package/dist/services/scheduler.js +300 -0
- package/dist/services/scheduler.js.map +1 -0
- package/dist/services/system-prompt.d.ts +3 -0
- package/dist/services/system-prompt.d.ts.map +1 -0
- package/dist/services/system-prompt.js +285 -0
- package/dist/services/system-prompt.js.map +1 -0
- package/dist/services/terminal.d.ts +18 -0
- package/dist/services/terminal.d.ts.map +1 -0
- package/dist/services/terminal.js +222 -0
- package/dist/services/terminal.js.map +1 -0
- package/dist/services/websocket.d.ts +15 -0
- package/dist/services/websocket.d.ts.map +1 -0
- package/dist/services/websocket.js +204 -0
- package/dist/services/websocket.js.map +1 -0
- package/dist/types.d.ts +108 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/env.ini +18 -0
- package/package.json +38 -0
- package/project_id +0 -0
- package/public/admin-users.html +188 -0
- package/public/agent.html +199 -0
- package/public/css/issues.css +275 -0
- package/public/css/style.css +1299 -0
- package/public/index.html +166 -0
- package/public/issue.html +76 -0
- package/public/js/agent.js +19 -0
- package/public/js/common.js +735 -0
- package/public/js/dashboard.js +772 -0
- package/public/js/files-panel.js +703 -0
- package/public/js/interactive-terminal.js +201 -0
- package/public/js/issue-renderer.js +559 -0
- package/public/js/issue.js +57 -0
- package/public/js/project.js +2425 -0
- package/public/js/terminal.js +564 -0
- package/public/project.html +430 -0
- package/public/terminal.html +67 -0
- package/public/vendor/marked.js +74 -0
- package/public/vendor/xterm-addon-fit.js +2 -0
- package/public/vendor/xterm.css +209 -0
- package/public/vendor/xterm.js +2 -0
- package/send_message_and_update_issue.js +65 -0
- package/tsconfig.json +19 -0
- package/update_round2_and_create_round3.js +284 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
// Cache for last activity data from summary endpoint
|
|
2
|
+
let _lastActivityMap = {};
|
|
3
|
+
let _notificationsCollapsed = false;
|
|
4
|
+
let _notifFilter = 'all'; // 'all' or 'action'
|
|
5
|
+
let _inboxSearchQuery = '';
|
|
6
|
+
let _inboxAllItems = []; // cached items for search filtering
|
|
7
|
+
let _dashboardProjectsById = {};
|
|
8
|
+
|
|
9
|
+
// Track known action-required issue IDs to detect new ones
|
|
10
|
+
let _knownActionIssueIds = null; // null = first load (don't ring on first load)
|
|
11
|
+
|
|
12
|
+
// Track locally acknowledged issue IDs so they survive inbox refresh
|
|
13
|
+
let _acknowledgedIds = new Set();
|
|
14
|
+
|
|
15
|
+
const PROJECT_ACCESS_META = {
|
|
16
|
+
owner: {
|
|
17
|
+
badge: 'OWNER',
|
|
18
|
+
tone: 'owner',
|
|
19
|
+
summary: 'Project Owner',
|
|
20
|
+
detail: 'Owned by you',
|
|
21
|
+
},
|
|
22
|
+
member: {
|
|
23
|
+
badge: 'SHARED',
|
|
24
|
+
tone: 'shared',
|
|
25
|
+
summary: 'Shared Member',
|
|
26
|
+
detail: 'Shared with you',
|
|
27
|
+
},
|
|
28
|
+
admin: {
|
|
29
|
+
badge: 'ADMIN VIEW',
|
|
30
|
+
tone: 'admin',
|
|
31
|
+
summary: 'Global Admin',
|
|
32
|
+
detail: 'Admin view',
|
|
33
|
+
},
|
|
34
|
+
bypass: {
|
|
35
|
+
badge: 'DEBUG',
|
|
36
|
+
tone: 'debug',
|
|
37
|
+
summary: 'Debug mode',
|
|
38
|
+
detail: 'legacy / localhost bypass',
|
|
39
|
+
},
|
|
40
|
+
none: {
|
|
41
|
+
badge: 'UNKNOWN',
|
|
42
|
+
tone: 'shared',
|
|
43
|
+
summary: 'Unknown role',
|
|
44
|
+
detail: 'Role info missing',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function displayProjectUser(user) {
|
|
49
|
+
if (!user) return 'Not set';
|
|
50
|
+
return user.display_name || user.username || 'Not set';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getProjectAccessLevel(project) {
|
|
54
|
+
if (project?.owner?.id && _currentUser?.id && project.owner.id === _currentUser.id) {
|
|
55
|
+
return 'owner';
|
|
56
|
+
}
|
|
57
|
+
return project?.permission_level || 'none';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getProjectAccessMeta(project) {
|
|
61
|
+
return PROJECT_ACCESS_META[getProjectAccessLevel(project)] || PROJECT_ACCESS_META.none;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function loadDashboardSummary() {
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch('/api/dashboard/summary', { headers: apiHeaders() });
|
|
67
|
+
if (!res.ok) return;
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
|
|
70
|
+
document.getElementById('stat-running').textContent = data.agents.running;
|
|
71
|
+
document.getElementById('stat-open-issues').textContent = data.issues.open;
|
|
72
|
+
const fmtTokensDash = v => v >= 1000000 ? (v / 1000000).toFixed(1) + 'M' : v >= 1000 ? (v / 1000).toFixed(1) + 'K' : v;
|
|
73
|
+
if (data.total_cost_usd > 0) {
|
|
74
|
+
document.getElementById('stat-cost').textContent = '$' + data.total_cost_usd.toFixed(2);
|
|
75
|
+
} else if (data.total_input_tokens > 0) {
|
|
76
|
+
document.getElementById('stat-cost').textContent = fmtTokensDash(data.total_input_tokens) + '↑ ' + fmtTokensDash(data.total_output_tokens) + '↓';
|
|
77
|
+
const costLabel = document.getElementById('stat-cost')?.closest('.stat-card')?.querySelector('.stat-label');
|
|
78
|
+
if (costLabel) costLabel.textContent = 'Token Usage';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const errCard = document.getElementById('stat-errors-card');
|
|
82
|
+
if (data.agents.error_count > 0) {
|
|
83
|
+
document.getElementById('stat-errors').textContent = data.agents.error_count;
|
|
84
|
+
errCard.style.display = '';
|
|
85
|
+
} else {
|
|
86
|
+
errCard.style.display = 'none';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
document.getElementById('dashboard-stats').style.display = '';
|
|
90
|
+
_lastActivityMap = data.last_activity || {};
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.error('Failed to load dashboard summary', e);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function loadNotifications() {
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch('/api/notifications', { headers: apiHeaders() });
|
|
99
|
+
if (!res.ok) return;
|
|
100
|
+
const data = await res.json();
|
|
101
|
+
|
|
102
|
+
const issues = data.user_issues || [];
|
|
103
|
+
const comments = (data.recent_comments || []).slice(0, 50);
|
|
104
|
+
const unacknowledgedIssues = issues.filter(i => !i.acknowledged_at);
|
|
105
|
+
const totalCount = unacknowledgedIssues.length;
|
|
106
|
+
|
|
107
|
+
// Detect new action-required issues and play notification sound
|
|
108
|
+
const currentIds = new Set(unacknowledgedIssues.map(i => i.id || i.number));
|
|
109
|
+
if (_knownActionIssueIds === null) {
|
|
110
|
+
_knownActionIssueIds = currentIds;
|
|
111
|
+
} else {
|
|
112
|
+
let hasNew = false;
|
|
113
|
+
for (const id of currentIds) {
|
|
114
|
+
if (!_knownActionIssueIds.has(id)) { hasNew = true; break; }
|
|
115
|
+
}
|
|
116
|
+
_knownActionIssueIds = currentIds;
|
|
117
|
+
if (hasNew && typeof playNotificationSound === 'function') {
|
|
118
|
+
playNotificationSound();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Always show the Inbox panel
|
|
123
|
+
document.getElementById('notifications-panel').style.display = '';
|
|
124
|
+
const badge = document.getElementById('notif-count');
|
|
125
|
+
if (totalCount > 0) {
|
|
126
|
+
const prevCount = parseInt(badge.textContent, 10) || 0;
|
|
127
|
+
badge.textContent = totalCount;
|
|
128
|
+
badge.style.display = '';
|
|
129
|
+
if (totalCount > prevCount) {
|
|
130
|
+
badge.classList.remove('pulse');
|
|
131
|
+
void badge.offsetWidth;
|
|
132
|
+
badge.classList.add('pulse');
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
badge.style.display = 'none';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Build items: action-required (unacknowledged) issues first, then acknowledged, then comments
|
|
139
|
+
// Sync local acknowledged set with server state:
|
|
140
|
+
// - If server says acknowledged_at is set, keep in local set
|
|
141
|
+
// - If server says acknowledged_at is NULL (e.g. new comment reset it), remove from local set
|
|
142
|
+
for (const issue of issues) {
|
|
143
|
+
if (issue.acknowledged_at) {
|
|
144
|
+
_acknowledgedIds.add(issue.id);
|
|
145
|
+
} else {
|
|
146
|
+
_acknowledgedIds.delete(issue.id);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const items = [];
|
|
150
|
+
for (const issue of issues) {
|
|
151
|
+
const isAcknowledged = !!issue.acknowledged_at || _acknowledgedIds.has(issue.id);
|
|
152
|
+
items.push({ type: 'issue', time: issue.updated_at, data: issue, actionRequired: !isAcknowledged });
|
|
153
|
+
}
|
|
154
|
+
for (const c of comments) {
|
|
155
|
+
items.push({ type: 'comment', time: c.created_at, data: c, actionRequired: false });
|
|
156
|
+
}
|
|
157
|
+
// Sort: action-required first, then by time desc
|
|
158
|
+
items.sort((a, b) => {
|
|
159
|
+
if (a.actionRequired !== b.actionRequired) return a.actionRequired ? -1 : 1;
|
|
160
|
+
return (b.time || '') > (a.time || '') ? 1 : -1;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
_inboxAllItems = items;
|
|
164
|
+
renderInboxItems(items);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error('Failed to load notifications', e);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderInboxItems(items) {
|
|
171
|
+
const body = document.getElementById('notifications-body');
|
|
172
|
+
const query = _inboxSearchQuery.toLowerCase().trim();
|
|
173
|
+
|
|
174
|
+
let html = '';
|
|
175
|
+
for (const item of items) {
|
|
176
|
+
// Apply filter — but keep recently-acknowledged issues visible (not red)
|
|
177
|
+
const isLocallyAcked = item.type === 'issue' && item.data && _acknowledgedIds.has(item.data.id);
|
|
178
|
+
if (_notifFilter === 'action' && !item.actionRequired && !isLocallyAcked) continue;
|
|
179
|
+
|
|
180
|
+
if (item.type === 'issue') {
|
|
181
|
+
const issue = item.data;
|
|
182
|
+
// Apply search
|
|
183
|
+
if (query && !matchesSearch(query, '#' + issue.number, issue.title, issue.body || '')) continue;
|
|
184
|
+
const isAction = item.actionRequired;
|
|
185
|
+
const isAcked = _acknowledgedIds.has(issue.id) || !!issue.acknowledged_at;
|
|
186
|
+
const ackBtnHtml = isAcked ? '' : `<button class="notif-ack-btn" onclick="event.stopPropagation();acknowledgeIssue('${issue.id}')" title="Mark read">✓</button>`;
|
|
187
|
+
html += `<div class="notif-item${isAction ? ' notif-action-required' : ''}" id="notif-issue-${issue.id}" onclick="openIssuePanel('${issue.id}')" style="cursor:pointer">
|
|
188
|
+
<span class="notif-icon" style="color:${isAction ? 'var(--warning)' : 'var(--text-secondary)'}">●</span>
|
|
189
|
+
<span class="notif-text">
|
|
190
|
+
<span style="color:var(--text-secondary);font-size:10px">[${esc(issue.project_name || '')}]</span>
|
|
191
|
+
<a href="/projects/${issue.project_id}/issues/${issue.number}" onclick="event.stopPropagation()">#${issue.number}</a>
|
|
192
|
+
${esc(issue.title)}
|
|
193
|
+
</span>
|
|
194
|
+
${ackBtnHtml}
|
|
195
|
+
<span class="notif-time">${timeAgo(issue.updated_at) || ''}</span>
|
|
196
|
+
</div>`;
|
|
197
|
+
} else {
|
|
198
|
+
const c = item.data;
|
|
199
|
+
if (query && !matchesSearch(query, '#' + c.issue_number, c.issue_title || '', c.body || '')) continue;
|
|
200
|
+
const preview = (c.body || '').slice(0, 60) + ((c.body || '').length > 60 ? '...' : '');
|
|
201
|
+
html += `<div class="notif-item notif-comment" onclick="openIssuePanelByProject('${c.project_id}', ${c.issue_number})" style="cursor:pointer">
|
|
202
|
+
<span class="notif-icon" style="color:var(--text-secondary)">✎</span>
|
|
203
|
+
<span class="notif-text">
|
|
204
|
+
<span style="color:var(--text-secondary);font-size:10px">[${esc(c.project_name || '')}]</span>
|
|
205
|
+
<a href="/projects/${c.project_id}/issues/${c.issue_number}" onclick="event.stopPropagation()">#${c.issue_number}</a>
|
|
206
|
+
<span style="color:var(--text-secondary)">${esc(preview)}</span>
|
|
207
|
+
</span>
|
|
208
|
+
<span class="notif-time">${timeAgo(c.created_at) || ''}</span>
|
|
209
|
+
</div>`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!html && query) {
|
|
214
|
+
html = '<div style="padding:12px 16px;color:var(--text-secondary);font-size:12px;text-align:center">No results</div>';
|
|
215
|
+
} else if (!html) {
|
|
216
|
+
html = '<div style="padding:12px 16px;color:var(--text-secondary);font-size:12px;text-align:center">No notifications</div>';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
body.innerHTML = html;
|
|
220
|
+
if (_notificationsCollapsed) {
|
|
221
|
+
body.classList.add('collapsed');
|
|
222
|
+
document.getElementById('notif-toggle-icon').classList.add('collapsed');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function matchesSearch(query, ...fields) {
|
|
227
|
+
for (const f of fields) {
|
|
228
|
+
if (f.toLowerCase().includes(query)) return true;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function filterInbox(query) {
|
|
234
|
+
_inboxSearchQuery = query;
|
|
235
|
+
if (query.trim()) {
|
|
236
|
+
// When searching, fetch all issues across projects
|
|
237
|
+
searchInboxIssues(query.trim());
|
|
238
|
+
} else {
|
|
239
|
+
// No search query — show normal inbox items
|
|
240
|
+
renderInboxItems(_inboxAllItems);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function searchInboxIssues(query) {
|
|
245
|
+
try {
|
|
246
|
+
const res = await fetch('/api/inbox/search?q=' + encodeURIComponent(query), { headers: apiHeaders() });
|
|
247
|
+
if (!res.ok) return;
|
|
248
|
+
const results = await res.json();
|
|
249
|
+
// Only mark items as action-required if they are already in the inbox notifications
|
|
250
|
+
const actionIds = new Set(_inboxAllItems.filter(i => i.actionRequired && i.data && i.data.id).map(i => i.data.id));
|
|
251
|
+
const items = results.map(issue => ({
|
|
252
|
+
type: 'issue',
|
|
253
|
+
time: issue.updated_at,
|
|
254
|
+
data: issue,
|
|
255
|
+
actionRequired: actionIds.has(issue.id)
|
|
256
|
+
}));
|
|
257
|
+
// Sort: action-required first, then by time desc
|
|
258
|
+
items.sort((a, b) => {
|
|
259
|
+
if (a.actionRequired !== b.actionRequired) return a.actionRequired ? -1 : 1;
|
|
260
|
+
return (b.time || '') > (a.time || '') ? 1 : -1;
|
|
261
|
+
});
|
|
262
|
+
renderInboxItems(items);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
console.error('Failed to search inbox', e);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function toggleNotifications() {
|
|
269
|
+
const body = document.getElementById('notifications-body');
|
|
270
|
+
const icon = document.getElementById('notif-toggle-icon');
|
|
271
|
+
_notificationsCollapsed = !_notificationsCollapsed;
|
|
272
|
+
body.classList.toggle('collapsed');
|
|
273
|
+
icon.classList.toggle('collapsed');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function toggleNotifFilter(filter) {
|
|
277
|
+
_notifFilter = filter;
|
|
278
|
+
document.querySelectorAll('.notif-filter-btn').forEach(btn => {
|
|
279
|
+
btn.classList.toggle('active', btn.dataset.filter === filter);
|
|
280
|
+
});
|
|
281
|
+
if (filter === 'my') {
|
|
282
|
+
loadMyIssues();
|
|
283
|
+
} else if (_inboxSearchQuery.trim()) {
|
|
284
|
+
searchInboxIssues(_inboxSearchQuery.trim());
|
|
285
|
+
} else {
|
|
286
|
+
renderInboxItems(_inboxAllItems);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function loadMyIssues() {
|
|
291
|
+
try {
|
|
292
|
+
const res = await fetch('/api/my-issues', { headers: apiHeaders() });
|
|
293
|
+
if (!res.ok) return;
|
|
294
|
+
const issues = await res.json();
|
|
295
|
+
const items = issues.map(issue => ({
|
|
296
|
+
type: 'issue',
|
|
297
|
+
time: issue.updated_at,
|
|
298
|
+
data: issue,
|
|
299
|
+
actionRequired: issue.assigned_to === 'user' && ['open', 'in_progress'].includes(issue.status)
|
|
300
|
+
}));
|
|
301
|
+
renderInboxItems(items);
|
|
302
|
+
} catch (e) {
|
|
303
|
+
console.error('Failed to load my issues', e);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function acknowledgeIssue(issueId) {
|
|
308
|
+
try {
|
|
309
|
+
const res = await fetch(`/api/issues/${issueId}/acknowledge`, { method: 'POST' });
|
|
310
|
+
if (res.ok) {
|
|
311
|
+
// Track locally so the item survives inbox refresh
|
|
312
|
+
_acknowledgedIds.add(issueId);
|
|
313
|
+
const el = document.getElementById('notif-issue-' + issueId);
|
|
314
|
+
if (el) {
|
|
315
|
+
el.classList.remove('notif-action-required');
|
|
316
|
+
const dot = el.querySelector('.notif-icon');
|
|
317
|
+
if (dot) dot.style.color = 'var(--text-secondary)';
|
|
318
|
+
const ackBtn = el.querySelector('.notif-ack-btn');
|
|
319
|
+
if (ackBtn) ackBtn.style.display = 'none';
|
|
320
|
+
}
|
|
321
|
+
// Update cached items
|
|
322
|
+
const cached = _inboxAllItems.find(i => i.data && i.data.id === issueId);
|
|
323
|
+
if (cached) cached.actionRequired = false;
|
|
324
|
+
// Update badge count
|
|
325
|
+
const remaining = document.querySelectorAll('.notif-action-required').length;
|
|
326
|
+
const badge = document.getElementById('notif-count');
|
|
327
|
+
if (badge) {
|
|
328
|
+
badge.textContent = remaining;
|
|
329
|
+
if (remaining === 0) badge.style.display = 'none';
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} catch (e) {
|
|
333
|
+
console.error('Failed to acknowledge issue', e);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function loadProjects() {
|
|
338
|
+
const container = document.getElementById('projects');
|
|
339
|
+
try {
|
|
340
|
+
const res = await fetch('/api/projects?with_stats=1', { headers: apiHeaders() });
|
|
341
|
+
if (!res.ok) {
|
|
342
|
+
container.innerHTML = renderError(null, 'loadProjects()');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const projects = await res.json();
|
|
346
|
+
_dashboardProjectsById = Object.fromEntries(projects.map((project) => [project.id, project]));
|
|
347
|
+
if (!projects.length) {
|
|
348
|
+
container.innerHTML = '<div class="empty-state">No projects yet. Create one to get started.</div>';
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Preserve quick-cmd input values before re-render
|
|
353
|
+
const savedInputs = {};
|
|
354
|
+
container.querySelectorAll('.quick-cmd-input').forEach(input => {
|
|
355
|
+
if (input.value) savedInputs[input.id] = input.value;
|
|
356
|
+
});
|
|
357
|
+
const savedBodies = {};
|
|
358
|
+
container.querySelectorAll('.quick-cmd-body').forEach(ta => {
|
|
359
|
+
if (ta.value) savedBodies[ta.id] = ta.value;
|
|
360
|
+
});
|
|
361
|
+
const focusedEl = document.activeElement;
|
|
362
|
+
const focusedId = (focusedEl?.classList.contains('quick-cmd-input') || focusedEl?.classList.contains('quick-cmd-body')) ? focusedEl.id : null;
|
|
363
|
+
|
|
364
|
+
container.innerHTML = projects.map(p => {
|
|
365
|
+
const s = p.stats || { agents: 0, running: 0, agentError: 0, issues: 0, openIssues: 0, userIssues: [] };
|
|
366
|
+
const link = `/projects/${p.id}`;
|
|
367
|
+
const access = getProjectAccessMeta(p);
|
|
368
|
+
const ownerName = displayProjectUser(p.owner);
|
|
369
|
+
const ownerRole = p.owner?.role === 'admin' ? 'Global Admin' : 'Project Member';
|
|
370
|
+
const memberCount = Number.isFinite(p.member_count) ? p.member_count : 0;
|
|
371
|
+
const toggleButton = p.can_manage
|
|
372
|
+
? `<button onclick="event.stopPropagation();toggleProjectStatus('${p.id}','${p.status}')" title="${p.status === 'active' ? 'Pause' : 'Resume'}" style="background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1">${p.status === 'active' ? '⏸' : '▶'}</button>`
|
|
373
|
+
: '';
|
|
374
|
+
const userCount = s.userIssues?.length || 0;
|
|
375
|
+
const notifBadge = userCount > 0
|
|
376
|
+
? `<span onclick="event.stopPropagation();window.location='${link}#issues'" style="background:var(--error);color:#fff;font-size:11px;padding:1px 8px;border-radius:10px;cursor:pointer;margin-left:6px" title="${userCount} issue(s) need your attention">${userCount}</span>`
|
|
377
|
+
: '';
|
|
378
|
+
const lastAct = _lastActivityMap[p.id];
|
|
379
|
+
const activityText = lastAct ? timeAgo(lastAct) : null;
|
|
380
|
+
const activityLine = activityText
|
|
381
|
+
? `<div class="last-activity">Last activity: ${activityText}</div>`
|
|
382
|
+
: '';
|
|
383
|
+
const quickCmdBar = p.can_manage ? `
|
|
384
|
+
<div class="quick-cmd-bar" onclick="event.stopPropagation()">
|
|
385
|
+
<div class="quick-cmd-row">
|
|
386
|
+
<input type="text" class="quick-cmd-input" id="quick-cmd-${p.id}" placeholder="Quick command..." oninput="toggleQuickCmdBody('${p.id}')" onkeydown="if(event.key==='Enter'&&event.shiftKey){event.preventDefault();sendQuickCmd('${p.id}')}">
|
|
387
|
+
<button class="quick-cmd-btn" onclick="sendQuickCmd('${p.id}')" title="Send">▶</button>
|
|
388
|
+
</div>
|
|
389
|
+
<textarea class="quick-cmd-body" id="quick-cmd-body-${p.id}" placeholder="Details (optional)..." rows="3" data-collapsed></textarea>
|
|
390
|
+
</div>
|
|
391
|
+
` : '';
|
|
392
|
+
return `
|
|
393
|
+
<div class="card project-card" style="cursor:pointer" onclick="window.location='${link}'">
|
|
394
|
+
<div class="project-card-head">
|
|
395
|
+
<div class="project-card-main">
|
|
396
|
+
<strong class="project-card-title">${esc(p.name)}${notifBadge}</strong>
|
|
397
|
+
<div class="project-card-tags">
|
|
398
|
+
<span class="permission-badge permission-${access.tone}" title="${esc(access.summary)}">${access.badge}</span>
|
|
399
|
+
<span class="meta-chip" title="Project owner">
|
|
400
|
+
<span class="meta-chip-label">Owner</span>
|
|
401
|
+
<span>${esc(ownerName)}</span>
|
|
402
|
+
</span>
|
|
403
|
+
<span class="meta-chip" title="Project member count">
|
|
404
|
+
<span class="meta-chip-label">Members</span>
|
|
405
|
+
<span>${memberCount}</span>
|
|
406
|
+
</span>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
<div style="display:flex;align-items:center;gap:6px">
|
|
410
|
+
<span class="status-badge status-${p.status}">${p.status}</span>
|
|
411
|
+
${toggleButton}
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
<div class="project-card-note">
|
|
415
|
+
<span>${esc(access.detail)}</span>
|
|
416
|
+
<span>·</span>
|
|
417
|
+
<span>${esc(ownerRole)}</span>
|
|
418
|
+
</div>
|
|
419
|
+
<p class="project-card-desc">${esc(p.description || '')}</p>
|
|
420
|
+
<div class="project-card-stats">
|
|
421
|
+
<div style="display:flex;align-items:center;gap:4px;color:var(--text-secondary)">
|
|
422
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 8a3 3 0 100-6 3 3 0 000 6zm5 7c0-2.8-2.2-5-5-5s-5 2.2-5 5h10z"/></svg>
|
|
423
|
+
<span>${s.running} running</span>
|
|
424
|
+
<span style="opacity:0.5">/ ${s.agents}</span>
|
|
425
|
+
${s.agentError > 0 ? `<span style="color:var(--error)">${s.agentError} error</span>` : ''}
|
|
426
|
+
</div>
|
|
427
|
+
<div style="display:flex;align-items:center;gap:4px;color:var(--text-secondary)">
|
|
428
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
|
429
|
+
<span>${s.openIssues} open</span>
|
|
430
|
+
<span style="opacity:0.5">/ ${s.issues}</span>
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
${activityLine}
|
|
434
|
+
${quickCmdBar}
|
|
435
|
+
</div>
|
|
436
|
+
`}).join('');
|
|
437
|
+
|
|
438
|
+
// Restore quick-cmd input values after re-render
|
|
439
|
+
for (const [id, value] of Object.entries(savedInputs)) {
|
|
440
|
+
const input = document.getElementById(id);
|
|
441
|
+
if (input) {
|
|
442
|
+
input.value = value;
|
|
443
|
+
// Also restore body textarea visibility
|
|
444
|
+
const pId = id.replace('quick-cmd-', '');
|
|
445
|
+
const body = document.getElementById('quick-cmd-body-' + pId);
|
|
446
|
+
if (body) body.removeAttribute('data-collapsed');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const [id, value] of Object.entries(savedBodies)) {
|
|
450
|
+
const ta = document.getElementById(id);
|
|
451
|
+
if (ta) ta.value = value;
|
|
452
|
+
}
|
|
453
|
+
if (focusedId) {
|
|
454
|
+
const el = document.getElementById(focusedId);
|
|
455
|
+
if (el) el.focus();
|
|
456
|
+
}
|
|
457
|
+
} catch (e) {
|
|
458
|
+
container.innerHTML = '<div class="empty-state"></div>';
|
|
459
|
+
container.querySelector('.empty-state').textContent = 'Error loading projects: ' + e.message;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function showCreateModal() { document.getElementById('createModal').classList.add('active'); }
|
|
464
|
+
function hideCreateModal() { document.getElementById('createModal').classList.remove('active'); }
|
|
465
|
+
|
|
466
|
+
async function createProject() {
|
|
467
|
+
const btn = document.querySelector('#createModal button[onclick="createProject()"]');
|
|
468
|
+
await withLoading(btn, async () => {
|
|
469
|
+
const task = document.getElementById('proj-task').value.trim();
|
|
470
|
+
const toolPath = document.getElementById('proj-cmd').value.trim() || 'cld';
|
|
471
|
+
if (!task) { showToast('Please describe the task to execute', 'error'); return; }
|
|
472
|
+
|
|
473
|
+
// Step 1: Call AI to generate project metadata
|
|
474
|
+
btn.textContent = 'Generating...';
|
|
475
|
+
const genRes = await fetch('/api/generate-project', {
|
|
476
|
+
method: 'POST', headers: apiHeaders(),
|
|
477
|
+
body: JSON.stringify({ description: task, tool_path: toolPath }),
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
let name, description, taskDesc, workDir, ctrlRole;
|
|
481
|
+
if (genRes.ok) {
|
|
482
|
+
const gen = await genRes.json();
|
|
483
|
+
name = gen.name || 'project';
|
|
484
|
+
description = gen.description || task.slice(0, 100);
|
|
485
|
+
taskDesc = gen.task_description || task;
|
|
486
|
+
workDir = gen.working_directory || null;
|
|
487
|
+
ctrlRole = gen.controller_role || null;
|
|
488
|
+
} else {
|
|
489
|
+
// Fallback if AI fails
|
|
490
|
+
name = task.slice(0, 30).replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, '-').toLowerCase() || 'project';
|
|
491
|
+
description = task.slice(0, 100);
|
|
492
|
+
taskDesc = task;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Step 2: Create the project
|
|
496
|
+
btn.textContent = 'Creating...';
|
|
497
|
+
const body = {
|
|
498
|
+
name,
|
|
499
|
+
description,
|
|
500
|
+
task_description: taskDesc,
|
|
501
|
+
command_template: toolPath,
|
|
502
|
+
working_directory: workDir,
|
|
503
|
+
controller_role: ctrlRole,
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const res = await fetch('/api/projects', { method: 'POST', headers: apiHeaders(), body: JSON.stringify(body) });
|
|
507
|
+
if (res.ok) {
|
|
508
|
+
const proj = await res.json();
|
|
509
|
+
hideCreateModal();
|
|
510
|
+
window.location.href = '/projects/' + proj.id;
|
|
511
|
+
} else {
|
|
512
|
+
const err = await res.json();
|
|
513
|
+
showToast(err.error || 'Failed to create', 'error');
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function toggleProjectStatus(projectId, currentStatus) {
|
|
519
|
+
if (!_dashboardProjectsById[projectId]?.can_manage) {
|
|
520
|
+
showToast('Insufficient permission to update project status', 'error');
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const newStatus = currentStatus === 'active' ? 'paused' : 'active';
|
|
524
|
+
try {
|
|
525
|
+
const res = await fetch(`/api/projects/${projectId}`, {
|
|
526
|
+
method: 'PUT', headers: apiHeaders(), body: JSON.stringify({ status: newStatus })
|
|
527
|
+
});
|
|
528
|
+
if (res.ok) { showToast('Status updated', 'success'); loadProjects(); }
|
|
529
|
+
else showToast('Failed to update status', 'error');
|
|
530
|
+
} catch { showToast('Network error', 'error'); }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function toggleQuickCmdBody(projectId) {
|
|
534
|
+
const input = document.getElementById('quick-cmd-' + projectId);
|
|
535
|
+
const body = document.getElementById('quick-cmd-body-' + projectId);
|
|
536
|
+
if (!body) return;
|
|
537
|
+
if (input.value.trim()) {
|
|
538
|
+
body.removeAttribute('data-collapsed');
|
|
539
|
+
} else {
|
|
540
|
+
body.setAttribute('data-collapsed', '');
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function sendQuickCmd(projectId) {
|
|
545
|
+
if (!_dashboardProjectsById[projectId]?.can_manage) {
|
|
546
|
+
showToast('Insufficient permission to create task', 'error');
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const input = document.getElementById('quick-cmd-' + projectId);
|
|
550
|
+
const bodyEl = document.getElementById('quick-cmd-body-' + projectId);
|
|
551
|
+
const msg = input.value.trim();
|
|
552
|
+
if (!msg) return;
|
|
553
|
+
const bodyText = bodyEl ? bodyEl.value.trim() : '';
|
|
554
|
+
const btn = input.parentElement.querySelector('.quick-cmd-btn');
|
|
555
|
+
btn.disabled = true;
|
|
556
|
+
btn.textContent = '...';
|
|
557
|
+
try {
|
|
558
|
+
// Find controller agent for this project
|
|
559
|
+
const agentsRes = await fetch(`/api/projects/${projectId}/agents`, { headers: apiHeaders() });
|
|
560
|
+
if (!agentsRes.ok) { showToast('Failed to find controller', 'error'); return; }
|
|
561
|
+
const agents = await agentsRes.json();
|
|
562
|
+
const controller = agents.find(a => a.is_controller);
|
|
563
|
+
if (!controller) { showToast('No controller agent found', 'error'); return; }
|
|
564
|
+
|
|
565
|
+
// Create issue assigned to controller
|
|
566
|
+
const res = await fetch(`/api/projects/${projectId}/issues`, {
|
|
567
|
+
method: 'POST', headers: apiHeaders(),
|
|
568
|
+
body: JSON.stringify({ title: msg, body: bodyText || msg, created_by: 'user', assigned_to: controller.id })
|
|
569
|
+
});
|
|
570
|
+
if (res.ok) {
|
|
571
|
+
// Re-query DOM elements: loadProjects() may have re-rendered during await,
|
|
572
|
+
// replacing the original elements with new ones
|
|
573
|
+
const curInput = document.getElementById('quick-cmd-' + projectId);
|
|
574
|
+
const curBody = document.getElementById('quick-cmd-body-' + projectId);
|
|
575
|
+
if (curInput) curInput.value = '';
|
|
576
|
+
if (curBody) { curBody.value = ''; curBody.setAttribute('data-collapsed', ''); }
|
|
577
|
+
showToast('Issue created', 'success');
|
|
578
|
+
} else {
|
|
579
|
+
const err = await res.json();
|
|
580
|
+
showToast(err.error || 'Failed', 'error');
|
|
581
|
+
}
|
|
582
|
+
} catch (e) {
|
|
583
|
+
showToast('Network error', 'error');
|
|
584
|
+
} finally {
|
|
585
|
+
// Re-query button in case DOM was re-rendered
|
|
586
|
+
const curBtn = document.getElementById('quick-cmd-' + projectId)?.parentElement?.querySelector('.quick-cmd-btn');
|
|
587
|
+
if (curBtn) { curBtn.disabled = false; curBtn.innerHTML = '▶'; }
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ─── Usage by Project Chart ───
|
|
592
|
+
|
|
593
|
+
let _usagePeriod = 'day';
|
|
594
|
+
const _projectColors = ['#58a6ff','#3fb950','#d29922','#f85149','#bc8cff','#39d2c0','#ff7b72','#79c0ff','#7ee787','#e3b341'];
|
|
595
|
+
|
|
596
|
+
function switchUsagePeriod(period) {
|
|
597
|
+
_usagePeriod = period;
|
|
598
|
+
document.querySelectorAll('.usage-period-btn').forEach(btn => {
|
|
599
|
+
btn.classList.toggle('active', btn.dataset.period === period);
|
|
600
|
+
});
|
|
601
|
+
loadUsageByProject();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function loadUsageByProject() {
|
|
605
|
+
try {
|
|
606
|
+
const res = await fetch(`/api/dashboard/usage-by-project?period=${_usagePeriod}`, { headers: apiHeaders() });
|
|
607
|
+
if (!res.ok) return;
|
|
608
|
+
const data = await res.json();
|
|
609
|
+
|
|
610
|
+
const panel = document.getElementById('usage-by-project-panel');
|
|
611
|
+
const container = document.getElementById('usage-by-project-chart');
|
|
612
|
+
if (!data.time_buckets || !data.time_buckets.length) {
|
|
613
|
+
panel.style.display = 'none';
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
panel.style.display = '';
|
|
617
|
+
|
|
618
|
+
const projects = data.projects;
|
|
619
|
+
const buckets = data.time_buckets;
|
|
620
|
+
const chartData = data.data;
|
|
621
|
+
|
|
622
|
+
// Calculate max stacked cost per bucket
|
|
623
|
+
let maxCost = 0.001;
|
|
624
|
+
for (const t of buckets) {
|
|
625
|
+
let sum = 0;
|
|
626
|
+
for (const p of projects) {
|
|
627
|
+
sum += (chartData[t] && chartData[t][p.id]) ? chartData[t][p.id].cost : 0;
|
|
628
|
+
}
|
|
629
|
+
if (sum > maxCost) maxCost = sum;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const W = 600, H = 200;
|
|
633
|
+
const PAD_L = 50, PAD_R = 16, PAD_T = 12, PAD_B = 32;
|
|
634
|
+
const cw = W - PAD_L - PAD_R, ch = H - PAD_T - PAD_B;
|
|
635
|
+
const n = buckets.length;
|
|
636
|
+
const barW = Math.max(2, (cw / n) * 0.7);
|
|
637
|
+
const gap = cw / n;
|
|
638
|
+
|
|
639
|
+
// Y-axis
|
|
640
|
+
const yLabels = [0, maxCost / 2, maxCost].map(v => {
|
|
641
|
+
const y = PAD_T + ch - (v / maxCost) * ch;
|
|
642
|
+
return `<text x="${PAD_L - 6}" y="${y + 3}" text-anchor="end" fill="var(--text-secondary)" font-size="9">$${v < 0.01 ? v.toFixed(4) : v < 1 ? v.toFixed(3) : v.toFixed(2)}</text>
|
|
643
|
+
<line x1="${PAD_L}" y1="${y}" x2="${W - PAD_R}" y2="${y}" stroke="var(--border)" stroke-width="0.5" opacity="0.5"/>`;
|
|
644
|
+
}).join('');
|
|
645
|
+
|
|
646
|
+
// X-axis
|
|
647
|
+
const step = Math.max(1, Math.floor(n / 6));
|
|
648
|
+
const xLabels = buckets.map((d, i) => {
|
|
649
|
+
if (i % step !== 0 && i !== n - 1) return '';
|
|
650
|
+
const x = PAD_L + i * gap + gap / 2;
|
|
651
|
+
const label = d.length > 10 ? d.slice(5) : d.slice(5);
|
|
652
|
+
return `<text x="${x}" y="${H - 4}" text-anchor="middle" fill="var(--text-secondary)" font-size="8">${label}</text>`;
|
|
653
|
+
}).join('');
|
|
654
|
+
|
|
655
|
+
// Stacked bars
|
|
656
|
+
let bars = '';
|
|
657
|
+
for (let i = 0; i < n; i++) {
|
|
658
|
+
const t = buckets[i];
|
|
659
|
+
const x = PAD_L + i * gap + (gap - barW) / 2;
|
|
660
|
+
let yOffset = 0;
|
|
661
|
+
for (let j = 0; j < projects.length; j++) {
|
|
662
|
+
const p = projects[j];
|
|
663
|
+
const entry = chartData[t] && chartData[t][p.id];
|
|
664
|
+
const cost = entry ? entry.cost : 0;
|
|
665
|
+
if (cost <= 0) continue;
|
|
666
|
+
const barH = (cost / maxCost) * ch;
|
|
667
|
+
const y = PAD_T + ch - yOffset - barH;
|
|
668
|
+
const color = _projectColors[j % _projectColors.length];
|
|
669
|
+
bars += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${barH.toFixed(1)}" fill="${color}" opacity="0.85" rx="1">
|
|
670
|
+
<title>${esc(p.name)} ${t}: $${cost.toFixed(4)}</title>
|
|
671
|
+
</rect>`;
|
|
672
|
+
yOffset += barH;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Legend
|
|
677
|
+
const legend = projects.map((p, i) => {
|
|
678
|
+
const color = _projectColors[i % _projectColors.length];
|
|
679
|
+
const name = p.name.length > 20 ? p.name.slice(0, 19) + '…' : p.name;
|
|
680
|
+
return `<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;font-size:11px;color:var(--text-secondary)">
|
|
681
|
+
<span style="width:10px;height:10px;background:${color};border-radius:2px;display:inline-block"></span>${esc(name)}
|
|
682
|
+
</span>`;
|
|
683
|
+
}).join('');
|
|
684
|
+
|
|
685
|
+
container.innerHTML = `<svg width="100%" viewBox="0 0 ${W} ${H}" style="display:block">
|
|
686
|
+
${yLabels}${xLabels}${bars}
|
|
687
|
+
</svg>
|
|
688
|
+
<div style="margin-top:6px;line-height:1.8">${legend}</div>`;
|
|
689
|
+
} catch (e) {
|
|
690
|
+
console.error('Failed to load usage by project', e);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Initial load: summary + notifications + projects + usage chart in parallel
|
|
695
|
+
async function loadDashboard() {
|
|
696
|
+
await Promise.all([loadDashboardSummary(), loadNotifications(), loadProjects(), loadUsageByProject()]);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
loadDashboard();
|
|
700
|
+
// Polling: 10s for lightweight data, 30s for full project list, 60s for usage chart
|
|
701
|
+
setInterval(() => { loadDashboardSummary(); loadNotifications(); }, 10000);
|
|
702
|
+
setInterval(loadProjects, 30000);
|
|
703
|
+
setInterval(loadUsageByProject, 60000);
|
|
704
|
+
window.addEventListener('argus:user-ready', () => { loadProjects(); });
|
|
705
|
+
|
|
706
|
+
// ─── Floating Issue Panel ───
|
|
707
|
+
|
|
708
|
+
let _panelIssueId = null;
|
|
709
|
+
let _panelAgents = [];
|
|
710
|
+
|
|
711
|
+
function openIssuePanel(issueId) {
|
|
712
|
+
_panelIssueId = issueId;
|
|
713
|
+
document.getElementById('issueDetailModal').classList.add('active');
|
|
714
|
+
loadIssuePanel(issueId);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function openIssuePanelByProject(projectId, issueNumber) {
|
|
718
|
+
document.getElementById('issueDetailModal').classList.add('active');
|
|
719
|
+
document.getElementById('issueDetailContent').innerHTML = renderLoading('Loading issue...');
|
|
720
|
+
try {
|
|
721
|
+
const res = await fetch(`/api/projects/${projectId}/issues/number/${issueNumber}`, { headers: apiHeaders() });
|
|
722
|
+
if (!res.ok) { document.getElementById('issueDetailContent').innerHTML = renderError({ status: res.status }); return; }
|
|
723
|
+
const data = await res.json();
|
|
724
|
+
_panelIssueId = data.id;
|
|
725
|
+
loadIssuePanel(data.id);
|
|
726
|
+
} catch (e) {
|
|
727
|
+
document.getElementById('issueDetailContent').innerHTML = renderError(e, 'openIssuePanelByProject(\'' + projectId + '\',' + issueNumber + ')');
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function closeIssuePanel() {
|
|
732
|
+
document.getElementById('issueDetailModal').classList.remove('active');
|
|
733
|
+
_panelIssueId = null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function loadIssuePanel(issueId) {
|
|
737
|
+
try {
|
|
738
|
+
const res = await fetch(`/api/issues/${issueId}`, { headers: apiHeaders() });
|
|
739
|
+
if (!res.ok) { document.getElementById('issueDetailContent').innerHTML = renderError({ status: res.status }); return; }
|
|
740
|
+
const issue = await res.json();
|
|
741
|
+
|
|
742
|
+
// Load agents for this project
|
|
743
|
+
try {
|
|
744
|
+
const agentsRes = await fetch(`/api/projects/${issue.project_id}/agents`, { headers: apiHeaders() });
|
|
745
|
+
if (agentsRes.ok) _panelAgents = await agentsRes.json();
|
|
746
|
+
} catch {}
|
|
747
|
+
|
|
748
|
+
IssueRenderer.render(issue, _panelAgents, document.getElementById('issueDetailContent'), {
|
|
749
|
+
reload: function() { loadIssuePanel(_panelIssueId); },
|
|
750
|
+
onAfterAction: function() { loadNotifications(); },
|
|
751
|
+
});
|
|
752
|
+
} catch (e) {
|
|
753
|
+
document.getElementById('issueDetailContent').innerHTML = renderError(e, 'loadIssuePanel(\'' + issueId + '\')');
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Listen for events from all projects and refresh dashboard on changes
|
|
758
|
+
(async function setupDashboardWS() {
|
|
759
|
+
try {
|
|
760
|
+
const res = await fetch('/api/projects', { headers: apiHeaders() });
|
|
761
|
+
if (!res.ok) return;
|
|
762
|
+
const projects = await res.json();
|
|
763
|
+
for (const p of projects) {
|
|
764
|
+
const ev = connectProjectEvents(p.id);
|
|
765
|
+
ev.on('*', function() {
|
|
766
|
+
loadDashboardSummary();
|
|
767
|
+
loadNotifications();
|
|
768
|
+
loadProjects();
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
} catch {}
|
|
772
|
+
})();
|