neoagent 2.4.1-beta.45 → 2.4.1-beta.47
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/.env.example +12 -0
- package/package.json +1 -1
- package/server/admin/access.js +198 -0
- package/server/admin/admin.js +2 -2
- package/server/admin/analytics.js +128 -0
- package/server/admin/index.html +484 -1
- package/server/admin/login.html +171 -45
- package/server/admin/sql.js +134 -0
- package/server/admin/users.js +147 -0
- package/server/db/database.js +19 -0
- package/server/http/static.js +20 -6
- package/server/middleware/adminAuth.js +37 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/routes/admin.js +316 -5
- package/server/services/account/admin_two_factor.js +132 -0
package/.env.example
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
# Core runtime
|
|
3
3
|
########################################
|
|
4
4
|
|
|
5
|
+
# Admin dashboard credentials — set during `neoagent setup`.
|
|
6
|
+
# Access the dashboard at http://localhost:<PORT>/admin
|
|
7
|
+
# Run `neoagent admin` to display these credentials at any time.
|
|
8
|
+
ADMIN_USERNAME=admin
|
|
9
|
+
ADMIN_PASSWORD=
|
|
10
|
+
|
|
11
|
+
# Admin API key for programmatic access to /admin/api/* endpoints.
|
|
12
|
+
# Use: Authorization: Bearer <key>
|
|
13
|
+
# Generate or rotate via the admin dashboard → Access → Generate key.
|
|
14
|
+
# Leave blank to disable API key auth (dashboard session auth still works).
|
|
15
|
+
ADMIN_API_KEY=
|
|
16
|
+
|
|
5
17
|
PORT=3333
|
|
6
18
|
NODE_ENV=production
|
|
7
19
|
# Auto-generated on first start or setup if left blank.
|
package/package.json
CHANGED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ── Access Settings ────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
let _revealedKey = null; // holds the API key shown after rotation (one-time)
|
|
6
|
+
let _revealTimer = null;
|
|
7
|
+
|
|
8
|
+
async function loadAccess() {
|
|
9
|
+
const el = document.getElementById('access-content');
|
|
10
|
+
if (!el) return;
|
|
11
|
+
try {
|
|
12
|
+
const data = await api('/admin/api/settings').then((r) => r.json());
|
|
13
|
+
renderAccess(el, data);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (err.message !== 'unauthorized') el.innerHTML = '<div class="empty">Failed to load settings</div>';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function renderAccess(el, { signupEnabled, apiKeyConfigured, apiKeyHint }) {
|
|
20
|
+
el.innerHTML = `
|
|
21
|
+
<!-- Signup -->
|
|
22
|
+
<div class="card">
|
|
23
|
+
<div class="card-title">User Signups</div>
|
|
24
|
+
<div class="access-row">
|
|
25
|
+
<div>
|
|
26
|
+
<div class="access-label">Allow new registrations</div>
|
|
27
|
+
<div class="access-desc">
|
|
28
|
+
When disabled, only existing users can log in. The first account can always be created regardless of this setting.
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<label class="toggle" aria-label="Toggle signups">
|
|
32
|
+
<input type="checkbox" id="signup-toggle" ${signupEnabled ? 'checked' : ''}
|
|
33
|
+
onchange="setSignup(this.checked)">
|
|
34
|
+
<span class="toggle-track"></span>
|
|
35
|
+
<span class="toggle-thumb"></span>
|
|
36
|
+
</label>
|
|
37
|
+
</div>
|
|
38
|
+
<div id="signup-status" class="access-status"></div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<!-- API key -->
|
|
42
|
+
<div class="card">
|
|
43
|
+
<div class="card-title">Admin API Key</div>
|
|
44
|
+
<div class="access-row">
|
|
45
|
+
<div>
|
|
46
|
+
<div class="access-label">Programmatic access</div>
|
|
47
|
+
<div class="access-desc">
|
|
48
|
+
Use <code>Authorization: Bearer <key></code> to authenticate against any <code>/admin/api/*</code> endpoint from scripts or automation — without a browser session.
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<span class="badge ${apiKeyConfigured ? 'badge-ok' : 'badge-idle'}">
|
|
52
|
+
${apiKeyConfigured ? 'configured' : 'not set'}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
${apiKeyConfigured ? `
|
|
57
|
+
<div class="access-hint-row">
|
|
58
|
+
<code class="api-key-hint">${esc(apiKeyHint)}</code>
|
|
59
|
+
<span style="font-size:11px;color:var(--text-muted);">stored in .env — rotate to reveal</span>
|
|
60
|
+
</div>` : ''}
|
|
61
|
+
|
|
62
|
+
<div id="key-reveal-wrap" style="display:none;">
|
|
63
|
+
<div class="key-reveal-box">
|
|
64
|
+
<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">
|
|
65
|
+
New API key — copy it now. It will not be shown again.
|
|
66
|
+
</div>
|
|
67
|
+
<div style="display:flex;align-items:center;gap:8px;">
|
|
68
|
+
<code id="key-reveal-value" class="api-key-full"></code>
|
|
69
|
+
<button class="btn btn-ghost" style="padding:5px 10px;font-size:11px;flex-shrink:0;"
|
|
70
|
+
onclick="copyApiKey()">Copy</button>
|
|
71
|
+
</div>
|
|
72
|
+
<div style="margin-top:8px;font-size:11px;color:var(--text-muted);" id="key-reveal-timer"></div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="access-actions">
|
|
77
|
+
<button class="btn btn-ghost" onclick="rotateApiKey(this)">
|
|
78
|
+
${apiKeyConfigured ? 'Rotate key' : 'Generate key'}
|
|
79
|
+
</button>
|
|
80
|
+
${apiKeyConfigured ? `
|
|
81
|
+
<button class="btn btn-danger" onclick="revokeApiKey(this)">Revoke key</button>` : ''}
|
|
82
|
+
</div>
|
|
83
|
+
<div id="apikey-status" class="access-status"></div>
|
|
84
|
+
</div>`;
|
|
85
|
+
|
|
86
|
+
// If we rotated in this page load, re-show the key
|
|
87
|
+
if (_revealedKey) showReveal(_revealedKey);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function setSignup(enabled) {
|
|
91
|
+
const statusEl = document.getElementById('signup-status');
|
|
92
|
+
if (statusEl) statusEl.textContent = '';
|
|
93
|
+
try {
|
|
94
|
+
const res = await api('/admin/api/settings/signup', {
|
|
95
|
+
method: 'PUT',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({ enabled }),
|
|
98
|
+
});
|
|
99
|
+
if (res.ok) {
|
|
100
|
+
showStatus('signup-status', enabled ? 'Signups enabled.' : 'Signups disabled.', false);
|
|
101
|
+
} else {
|
|
102
|
+
const b = await res.json().catch(() => ({}));
|
|
103
|
+
showStatus('signup-status', b.error || 'Failed to update', true);
|
|
104
|
+
// Revert the toggle
|
|
105
|
+
const toggle = document.getElementById('signup-toggle');
|
|
106
|
+
if (toggle) toggle.checked = !enabled;
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err.message !== 'unauthorized') showStatus('signup-status', 'Network error', true);
|
|
110
|
+
const toggle = document.getElementById('signup-toggle');
|
|
111
|
+
if (toggle) toggle.checked = !enabled;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function rotateApiKey(btn) {
|
|
116
|
+
if (!confirm(
|
|
117
|
+
'Rotate admin API key?\n\n' +
|
|
118
|
+
'The current key will stop working immediately.\n' +
|
|
119
|
+
'Any scripts using it must be updated.\n\n' +
|
|
120
|
+
'The new key will be shown once — copy it before leaving.'
|
|
121
|
+
)) return;
|
|
122
|
+
btn.disabled = true;
|
|
123
|
+
btn.textContent = 'Rotating…';
|
|
124
|
+
try {
|
|
125
|
+
const res = await api('/admin/api/settings/apikey/rotate', { method: 'POST' });
|
|
126
|
+
const data = await res.json();
|
|
127
|
+
if (res.ok && data.apiKey) {
|
|
128
|
+
_revealedKey = data.apiKey;
|
|
129
|
+
await loadAccess(); // re-render to show badge + hint
|
|
130
|
+
} else {
|
|
131
|
+
showStatus('apikey-status', data.error || 'Failed to rotate', true);
|
|
132
|
+
btn.disabled = false;
|
|
133
|
+
btn.textContent = 'Rotate key';
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.message !== 'unauthorized') showStatus('apikey-status', 'Network error', true);
|
|
137
|
+
btn.disabled = false;
|
|
138
|
+
btn.textContent = 'Rotate key';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function revokeApiKey(btn) {
|
|
143
|
+
if (!confirm('Revoke admin API key?\n\nAll programmatic access using this key will stop immediately.')) return;
|
|
144
|
+
btn.disabled = true;
|
|
145
|
+
btn.textContent = 'Revoking…';
|
|
146
|
+
_revealedKey = null;
|
|
147
|
+
try {
|
|
148
|
+
const res = await api('/admin/api/settings/apikey', { method: 'DELETE' });
|
|
149
|
+
if (res.ok) {
|
|
150
|
+
await loadAccess();
|
|
151
|
+
} else {
|
|
152
|
+
const b = await res.json().catch(() => ({}));
|
|
153
|
+
showStatus('apikey-status', b.error || 'Failed', true);
|
|
154
|
+
btn.disabled = false;
|
|
155
|
+
btn.textContent = 'Revoke key';
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err.message !== 'unauthorized') showStatus('apikey-status', 'Network error', true);
|
|
159
|
+
btn.disabled = false;
|
|
160
|
+
btn.textContent = 'Revoke key';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function copyApiKey() {
|
|
165
|
+
if (_revealedKey) navigator.clipboard.writeText(_revealedKey).catch(() => {});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function showReveal(key) {
|
|
169
|
+
const wrap = document.getElementById('key-reveal-wrap');
|
|
170
|
+
const value = document.getElementById('key-reveal-value');
|
|
171
|
+
if (!wrap || !value) return;
|
|
172
|
+
value.textContent = key;
|
|
173
|
+
wrap.style.display = 'block';
|
|
174
|
+
// Auto-clear after 120 seconds
|
|
175
|
+
clearTimeout(_revealTimer);
|
|
176
|
+
let secs = 120;
|
|
177
|
+
const timerEl = document.getElementById('key-reveal-timer');
|
|
178
|
+
const tick = () => {
|
|
179
|
+
if (!timerEl) return;
|
|
180
|
+
if (secs <= 0) {
|
|
181
|
+
wrap.style.display = 'none';
|
|
182
|
+
_revealedKey = null;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
timerEl.textContent = `Hides in ${secs}s`;
|
|
186
|
+
secs--;
|
|
187
|
+
_revealTimer = setTimeout(tick, 1000);
|
|
188
|
+
};
|
|
189
|
+
tick();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function showStatus(id, msg, isError) {
|
|
193
|
+
const el = document.getElementById(id);
|
|
194
|
+
if (!el) return;
|
|
195
|
+
el.textContent = msg;
|
|
196
|
+
el.style.color = isError ? 'var(--danger)' : 'var(--success)';
|
|
197
|
+
setTimeout(() => { if (el) el.textContent = ''; }, 4000);
|
|
198
|
+
}
|
package/server/admin/admin.js
CHANGED
|
@@ -16,7 +16,7 @@ function showPage(page, btn) {
|
|
|
16
16
|
if (btn) btn.classList.add('active');
|
|
17
17
|
currentPage = page;
|
|
18
18
|
|
|
19
|
-
const loaders = { overview: loadHealth, logs: loadLogs, updates: loadVersion, config: loadConfig, providers: loadProviders };
|
|
19
|
+
const loaders = { overview: loadHealth, logs: loadLogs, updates: loadVersion, config: loadConfig, providers: loadProviders, analytics: loadAnalytics, users: loadUsers, sql: loadSql, access: loadAccess };
|
|
20
20
|
loaders[page]?.();
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -37,7 +37,7 @@ async function api(path, opts = {}) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function esc(str) {
|
|
40
|
-
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
40
|
+
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function fmtTime(iso) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ── Analytics ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
async function loadAnalytics() {
|
|
6
|
+
const el = document.getElementById('analytics-content');
|
|
7
|
+
if (!el) return;
|
|
8
|
+
el.innerHTML = '<div class="empty"><span class="spinner"></span></div>';
|
|
9
|
+
try {
|
|
10
|
+
const data = await api('/admin/api/analytics').then((r) => r.json());
|
|
11
|
+
renderAnalytics(el, data);
|
|
12
|
+
setTs('analytics-ts', 'Updated');
|
|
13
|
+
} catch (err) {
|
|
14
|
+
if (err.message !== 'unauthorized') el.innerHTML = '<div class="empty">Failed to load analytics</div>';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderAnalytics(el, { stats, topUsers, recentRuns }) {
|
|
19
|
+
const s = stats || {};
|
|
20
|
+
el.innerHTML = `
|
|
21
|
+
<div class="stat-grid">
|
|
22
|
+
<div class="stat-card">
|
|
23
|
+
<div class="stat-value">${fmtNum(s.totalUsers)}</div>
|
|
24
|
+
<div class="stat-label">Total Users</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="stat-card">
|
|
27
|
+
<div class="stat-value">${fmtNum(s.activeToday)}</div>
|
|
28
|
+
<div class="stat-label">Active Today</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="stat-card">
|
|
31
|
+
<div class="stat-value">${fmtNum(s.newThisWeek)}</div>
|
|
32
|
+
<div class="stat-label">New This Week</div>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="stat-card">
|
|
35
|
+
<div class="stat-value">${fmtNum(s.totalRuns)}</div>
|
|
36
|
+
<div class="stat-label">Total Runs</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="stat-card">
|
|
39
|
+
<div class="stat-value">${fmtNum(s.runsToday)}</div>
|
|
40
|
+
<div class="stat-label">Runs Today</div>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="stat-card">
|
|
43
|
+
<div class="stat-value">${fmtTokens(s.totalTokens)}</div>
|
|
44
|
+
<div class="stat-label">Total Tokens</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="stat-card">
|
|
47
|
+
<div class="stat-value">${fmtNum(s.activeSessions)}</div>
|
|
48
|
+
<div class="stat-label">Active Sessions</div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="stat-card">
|
|
51
|
+
<div class="stat-value">${fmtBytes(s.totalStorage)}</div>
|
|
52
|
+
<div class="stat-label">Artifact Storage</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
${renderTopUsers(topUsers || [])}
|
|
57
|
+
${renderRecentRuns(recentRuns || [])}
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderTopUsers(users) {
|
|
62
|
+
if (!users.length) return '';
|
|
63
|
+
const maxRuns = Math.max(...users.map((u) => u.runs), 1);
|
|
64
|
+
return `
|
|
65
|
+
<div class="card" style="margin-top:20px;">
|
|
66
|
+
<div class="card-title">Top Users by Runs</div>
|
|
67
|
+
<table class="analytics-table">
|
|
68
|
+
<thead><tr>
|
|
69
|
+
<th>User</th><th>Runs</th><th>Tokens</th><th>Storage</th><th>Activity</th>
|
|
70
|
+
</tr></thead>
|
|
71
|
+
<tbody>${users.map((u) => `
|
|
72
|
+
<tr>
|
|
73
|
+
<td><span class="user-avatar">${esc((u.display_name || u.username || '?')[0]).toUpperCase()}</span> ${esc(u.display_name || u.username)}</td>
|
|
74
|
+
<td>${fmtNum(u.runs)}</td>
|
|
75
|
+
<td>${fmtTokens(u.tokens)}</td>
|
|
76
|
+
<td>${fmtBytes(u.storage)}</td>
|
|
77
|
+
<td>
|
|
78
|
+
<div class="mini-bar"><div class="mini-bar-fill" style="width:${Math.round((u.runs / maxRuns) * 100)}%"></div></div>
|
|
79
|
+
</td>
|
|
80
|
+
</tr>`).join('')}
|
|
81
|
+
</tbody>
|
|
82
|
+
</table>
|
|
83
|
+
</div>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderRecentRuns(runs) {
|
|
87
|
+
if (!runs.length) return '';
|
|
88
|
+
return `
|
|
89
|
+
<div class="card" style="margin-top:16px;">
|
|
90
|
+
<div class="card-title">Recent Runs</div>
|
|
91
|
+
<table class="analytics-table">
|
|
92
|
+
<thead><tr>
|
|
93
|
+
<th>User</th><th>Title</th><th>Status</th><th>Tokens</th><th>Started</th>
|
|
94
|
+
</tr></thead>
|
|
95
|
+
<tbody>${runs.map((r) => `
|
|
96
|
+
<tr>
|
|
97
|
+
<td>${esc(r.username)}</td>
|
|
98
|
+
<td class="cell-truncate">${esc(r.title || '(untitled)')}</td>
|
|
99
|
+
<td><span class="run-badge run-${esc(r.status)}">${esc(r.status)}</span></td>
|
|
100
|
+
<td>${fmtTokens(r.total_tokens)}</td>
|
|
101
|
+
<td>${fmtTime(r.created_at)}</td>
|
|
102
|
+
</tr>`).join('')}
|
|
103
|
+
</tbody>
|
|
104
|
+
</table>
|
|
105
|
+
</div>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Shared number helpers (also used by other modules) ────────────────────
|
|
109
|
+
|
|
110
|
+
function fmtNum(n) {
|
|
111
|
+
if (n == null) return '—';
|
|
112
|
+
return Number(n).toLocaleString();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function fmtTokens(n) {
|
|
116
|
+
if (!n) return '0';
|
|
117
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
118
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
|
119
|
+
return String(n);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fmtBytes(n) {
|
|
123
|
+
if (!n) return '0 B';
|
|
124
|
+
if (n >= 1_073_741_824) return (n / 1_073_741_824).toFixed(1) + ' GB';
|
|
125
|
+
if (n >= 1_048_576) return (n / 1_048_576).toFixed(1) + ' MB';
|
|
126
|
+
if (n >= 1_024) return (n / 1_024).toFixed(1) + ' KB';
|
|
127
|
+
return n + ' B';
|
|
128
|
+
}
|