@logboard/cli 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/.env.example +37 -0
- package/README.md +200 -0
- package/bin/logboard +536 -0
- package/client/logger.js +309 -0
- package/config/index.js +142 -0
- package/config.js +2 -0
- package/controllers/AnalyticsController.js +46 -0
- package/controllers/ApiAnalyticsController.js +129 -0
- package/controllers/ApiKeyController.js +58 -0
- package/controllers/AuthController.js +131 -0
- package/controllers/HealthController.js +56 -0
- package/controllers/LogController.js +197 -0
- package/controllers/OrgController.js +152 -0
- package/controllers/RoleConfigController.js +20 -0
- package/controllers/SettingsController.js +39 -0
- package/controllers/StreamController.js +55 -0
- package/controllers/UiController.js +789 -0
- package/controllers/UserController.js +79 -0
- package/lib/batchWriter.js +57 -0
- package/lib/cleanup.js +67 -0
- package/lib/ejs.js +103 -0
- package/lib/emitter.js +5 -0
- package/lib/healthMonitor.js +245 -0
- package/lib/logger.js +21 -0
- package/lib/streams.js +32 -0
- package/lib/theme.js +77 -0
- package/lib/userStore.js +13 -0
- package/lib/utils.js +44 -0
- package/middleware/apiKey.js +82 -0
- package/middleware/auth.js +55 -0
- package/middleware/ipWhitelist.js +59 -0
- package/middleware/org.js +85 -0
- package/middleware/pageAccess.js +20 -0
- package/middleware/rateLimit.js +29 -0
- package/middleware/roles.js +11 -0
- package/package.json +77 -0
- package/routes/alerts.js +18 -0
- package/routes/analytics.js +26 -0
- package/routes/api-analytics.js +30 -0
- package/routes/api-keys.js +12 -0
- package/routes/archive.js +91 -0
- package/routes/audit.js +50 -0
- package/routes/auth.js +22 -0
- package/routes/bookmarks.js +13 -0
- package/routes/health.js +11 -0
- package/routes/logs.js +88 -0
- package/routes/metrics.js +66 -0
- package/routes/notifications.js +14 -0
- package/routes/orgs.js +98 -0
- package/routes/registration.js +202 -0
- package/routes/role-config.js +97 -0
- package/routes/saved-searches.js +12 -0
- package/routes/server.js +151 -0
- package/routes/settings.js +28 -0
- package/routes/status.js +21 -0
- package/routes/stream.js +11 -0
- package/routes/super.js +129 -0
- package/routes/ui.js +120 -0
- package/routes/users.js +13 -0
- package/server.js +172 -0
- package/services/AlertRulesService.js +323 -0
- package/services/AnalyticsService.js +665 -0
- package/services/ApiAnalyticsService.js +471 -0
- package/services/ApiKeyService.js +166 -0
- package/services/AuditService.js +249 -0
- package/services/AuthService.js +234 -0
- package/services/BookmarkService.js +49 -0
- package/services/GlobalSettingsService.js +44 -0
- package/services/LogService.js +1066 -0
- package/services/MetricsService.js +116 -0
- package/services/NotificationService.js +70 -0
- package/services/OrgService.js +217 -0
- package/services/ReportService.js +247 -0
- package/services/RoleConfigService.js +201 -0
- package/services/SavedSearchService.js +63 -0
- package/services/SettingsService.js +220 -0
- package/services/UserService.js +121 -0
- package/setup.js +132 -0
- package/views/404.ejs +8 -0
- package/views/alerts.ejs +190 -0
- package/views/analytics.ejs +209 -0
- package/views/api-analytics.ejs +660 -0
- package/views/api-keys.ejs +150 -0
- package/views/archive.ejs +123 -0
- package/views/audit.ejs +314 -0
- package/views/bookmarks.ejs +54 -0
- package/views/custom-dashboard.ejs +162 -0
- package/views/dashboard.ejs +186 -0
- package/views/diff.ejs +98 -0
- package/views/health.ejs +269 -0
- package/views/heatmap.ejs +126 -0
- package/views/insights.ejs +334 -0
- package/views/invite.ejs +74 -0
- package/views/live.ejs +299 -0
- package/views/login.ejs +64 -0
- package/views/logo.png +0 -0
- package/views/logs.ejs +754 -0
- package/views/notifications.ejs +58 -0
- package/views/partials/head.ejs +282 -0
- package/views/partials/sidebar.ejs +168 -0
- package/views/register.ejs +100 -0
- package/views/roles.ejs +279 -0
- package/views/saved-searches.ejs +51 -0
- package/views/service-map.ejs +142 -0
- package/views/settings.ejs +1159 -0
- package/views/sidebar.ejs +129 -0
- package/views/status.ejs +100 -0
- package/views/super-admin-admins.ejs +58 -0
- package/views/super-admin-analytics.ejs +49 -0
- package/views/super-admin-orgs.ejs +310 -0
- package/views/super-admin-profile.ejs +77 -0
- package/views/super-admin-settings.ejs +108 -0
- package/views/super-admin-system.ejs +46 -0
- package/views/users.ejs +153 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
<%- include('partials/head', { title: 'Manage Orgs' }) %>
|
|
2
|
+
<div class="app-shell">
|
|
3
|
+
<%- include('partials/sidebar') %>
|
|
4
|
+
<div class="main-area">
|
|
5
|
+
<header class="top-header">
|
|
6
|
+
<div class="page-title">
|
|
7
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px;vertical-align:-2px"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>
|
|
8
|
+
Organisation Management
|
|
9
|
+
<span style="font-size:11px;background:rgba(239,68,68,.15);color:#f87171;padding:2px 8px;border-radius:4px;margin-left:8px;font-weight:600;letter-spacing:.3px;">SUPER ADMIN</span>
|
|
10
|
+
</div>
|
|
11
|
+
<button class="btn btn-primary btn-sm" onclick="showCreate()">+ New Org</button>
|
|
12
|
+
</header>
|
|
13
|
+
<div class="page-content">
|
|
14
|
+
|
|
15
|
+
<!-- Stats row -->
|
|
16
|
+
<div class="stats-grid" style="margin-bottom:14px;">
|
|
17
|
+
<div class="stat-card"><div><div class="stat-value" id="stat-orgs"><%= orgs.length %></div><div class="stat-label">Total Orgs</div></div></div>
|
|
18
|
+
<div class="stat-card"><div><div class="stat-value" id="stat-pro"><%= orgs.filter(function(o){return o.plan==='pro';}).length %></div><div class="stat-label">Pro Plan</div></div></div>
|
|
19
|
+
<div class="stat-card"><div><div class="stat-value" id="stat-users">—</div><div class="stat-label">Total Users</div></div></div>
|
|
20
|
+
<div class="stat-card"><div><div class="stat-value" id="stat-new"><%= orgs.filter(function(o){return new Date(o.createdAt)>new Date(Date.now()-7*864e5);}).length %></div><div class="stat-label">New this week</div></div></div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<!-- Orgs accordion -->
|
|
24
|
+
<div id="orgs-list">
|
|
25
|
+
<% orgs.forEach(function(org, oi){ %>
|
|
26
|
+
<div class="card" style="margin-bottom:10px;" id="org-card-<%=org.slug%>">
|
|
27
|
+
<!-- Org header row -->
|
|
28
|
+
<div style="display:flex;align-items:center;gap:12px;cursor:pointer;" onclick="toggleOrg('<%=org.slug%>')">
|
|
29
|
+
<div style="width:36px;height:36px;border-radius:8px;background:var(--accent-dim);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;color:var(--accent-l);flex-shrink:0;">
|
|
30
|
+
<%=org.name.charAt(0).toUpperCase()%>
|
|
31
|
+
</div>
|
|
32
|
+
<div style="flex:1;min-width:0;">
|
|
33
|
+
<div style="font-weight:600;font-size:14px;"><%= org.name %></div>
|
|
34
|
+
<div style="font-size:11px;color:var(--text3);">
|
|
35
|
+
<code style="background:var(--surface2);padding:1px 5px;border-radius:3px;"><%=org.slug%></code>
|
|
36
|
+
· <%=org.plan||'free'%> plan
|
|
37
|
+
· created <%=new Date(org.createdAt).toLocaleDateString()%>
|
|
38
|
+
<% if(org.migratedAt){ %> · <span style="color:var(--blue);">migrated</span><% } %>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<span class="badge <%=org.plan==='pro'?'badge-green':'badge-debug'%>" style="font-size:10px;"><%=org.plan||'free'%></span>
|
|
42
|
+
<% if(org.slug!=='default'){ %>
|
|
43
|
+
<button class="btn btn-danger btn-xs" onclick="deleteOrg(event,'<%=org.slug%>','<%=org.name%>')">Delete</button>
|
|
44
|
+
<% } %>
|
|
45
|
+
<svg id="chevron-<%=org.slug%>" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="transition:transform .2s;flex-shrink:0;"><polyline points="6,9 12,15 18,9"/></svg>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- Org details (collapsed by default) -->
|
|
49
|
+
<div id="org-detail-<%=org.slug%>" style="display:none;margin-top:14px;padding-top:14px;border-top:1px solid var(--border);">
|
|
50
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
|
|
51
|
+
<!-- Users list -->
|
|
52
|
+
<div>
|
|
53
|
+
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:8px;">Users</div>
|
|
54
|
+
<div id="users-<%=org.slug%>" style="font-size:12px;color:var(--text2);">Loading…</div>
|
|
55
|
+
<!-- Invite user -->
|
|
56
|
+
<div style="margin-top:10px;display:flex;gap:6px;">
|
|
57
|
+
<input type="email" id="inv-email-<%=org.slug%>" class="form-input" style="flex:1;font-size:11px;" placeholder="email@example.com"/>
|
|
58
|
+
<select id="inv-role-<%=org.slug%>" class="form-select" style="width:90px;font-size:11px;">
|
|
59
|
+
<option value="viewer">Viewer</option>
|
|
60
|
+
<option value="admin">Admin</option>
|
|
61
|
+
</select>
|
|
62
|
+
<button class="btn btn-secondary btn-xs" onclick="inviteUser('<%=org.slug%>')">Invite</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div id="inv-result-<%=org.slug%>" style="font-size:11px;margin-top:6px;"></div>
|
|
65
|
+
</div>
|
|
66
|
+
<!-- Org settings -->
|
|
67
|
+
<div>
|
|
68
|
+
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:8px;">Details</div>
|
|
69
|
+
<div id="settings-<%=org.slug%>" style="font-size:12px;color:var(--text2);">Loading…</div>
|
|
70
|
+
<!-- Create admin -->
|
|
71
|
+
<div style="margin-top:12px;border-top:1px solid var(--border);padding-top:10px;">
|
|
72
|
+
<div style="font-size:11px;font-weight:500;color:var(--text2);margin-bottom:6px;">Create admin account</div>
|
|
73
|
+
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
|
74
|
+
<input type="text" id="admin-user-<%=org.slug%>" class="form-input" style="width:120px;font-size:11px;" placeholder="username"/>
|
|
75
|
+
<input type="password" id="admin-pass-<%=org.slug%>" class="form-input" style="width:120px;font-size:11px;" placeholder="password (8+)"/>
|
|
76
|
+
<button class="btn btn-primary btn-xs" onclick="createAdmin('<%=org.slug%>')">Create Admin</button>
|
|
77
|
+
</div>
|
|
78
|
+
<div id="admin-result-<%=org.slug%>" style="font-size:11px;margin-top:6px;"></div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<% }) %>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<!-- Migration card -->
|
|
88
|
+
<div class="card" style="margin-top:4px;">
|
|
89
|
+
<div class="card-title" style="margin-bottom:8px;">
|
|
90
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
|
91
|
+
Data Migration
|
|
92
|
+
</div>
|
|
93
|
+
<p style="font-size:12px;color:var(--text2);margin-bottom:10px;">Move existing flat data + logs → <code>data/orgs/default/</code> and <code>logs/default/</code>. Safe to run multiple times.</p>
|
|
94
|
+
<div style="display:flex;gap:10px;align-items:center;">
|
|
95
|
+
<button class="btn btn-secondary btn-sm" onclick="runMigration()">Run Migration → Default Org</button>
|
|
96
|
+
<span id="mig-status" style="font-size:12px;color:var(--text3);"></span>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<!-- Create org modal -->
|
|
105
|
+
<div id="create-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:999;align-items:center;justify-content:center;">
|
|
106
|
+
<div class="card" style="width:440px;max-width:calc(100vw - 32px);padding:24px;">
|
|
107
|
+
<div class="card-title" style="margin-bottom:20px;">Create New Organisation</div>
|
|
108
|
+
<div class="form-group">
|
|
109
|
+
<label class="form-label">Org Name</label>
|
|
110
|
+
<input type="text" id="new-name" class="form-input" placeholder="Acme Inc"/>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="form-group">
|
|
113
|
+
<label class="form-label">Admin Username</label>
|
|
114
|
+
<input type="text" id="new-owner" class="form-input" placeholder="admin"/>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="form-group">
|
|
117
|
+
<label class="form-label">Admin Password <span style="color:var(--text3);font-weight:400;">(min 8 chars)</span></label>
|
|
118
|
+
<input type="password" id="new-pass" class="form-input" placeholder="••••••••"/>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="form-group">
|
|
121
|
+
<label class="form-label">Plan</label>
|
|
122
|
+
<select id="new-plan" class="form-select">
|
|
123
|
+
<option value="free">Free</option>
|
|
124
|
+
<option value="pro">Pro</option>
|
|
125
|
+
</select>
|
|
126
|
+
</div>
|
|
127
|
+
<div style="display:flex;gap:8px;margin-top:4px;">
|
|
128
|
+
<button class="btn btn-primary btn-sm" onclick="createOrg()">Create Organisation</button>
|
|
129
|
+
<button class="btn btn-secondary btn-sm" onclick="hideCreate()">Cancel</button>
|
|
130
|
+
</div>
|
|
131
|
+
<div id="create-error" style="display:none;color:var(--red);font-size:12px;margin-top:8px;"></div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<script>
|
|
136
|
+
// ── Org accordion ──────────────────────────────────────────────────────────
|
|
137
|
+
const loadedOrgs = new Set();
|
|
138
|
+
|
|
139
|
+
function toggleOrg(slug) {
|
|
140
|
+
const detail = document.getElementById('org-detail-'+slug);
|
|
141
|
+
const chevron = document.getElementById('chevron-'+slug);
|
|
142
|
+
const isOpen = detail.style.display !== 'none';
|
|
143
|
+
detail.style.display = isOpen ? 'none' : '';
|
|
144
|
+
chevron.style.transform = isOpen ? '' : 'rotate(180deg)';
|
|
145
|
+
if (!isOpen && !loadedOrgs.has(slug)) {
|
|
146
|
+
loadedOrgs.add(slug);
|
|
147
|
+
loadOrgDetail(slug);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function loadOrgDetail(slug) {
|
|
152
|
+
try {
|
|
153
|
+
const r = await fetch('/api/orgs/'+slug+'/detail', {credentials:'include'});
|
|
154
|
+
if (!r.ok) {
|
|
155
|
+
let errMsg = 'Error '+r.status;
|
|
156
|
+
try { errMsg = (await r.json()).error || errMsg; } catch {}
|
|
157
|
+
throw new Error(errMsg);
|
|
158
|
+
}
|
|
159
|
+
const d = await r.json();
|
|
160
|
+
|
|
161
|
+
// Users
|
|
162
|
+
const usersEl = document.getElementById('users-'+slug);
|
|
163
|
+
if (d.users && d.users.length) {
|
|
164
|
+
usersEl.innerHTML = d.users.map(u =>
|
|
165
|
+
'<div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);">'
|
|
166
|
+
+'<span style="font-weight:500;">'+escHtml(u.username)+'</span>'
|
|
167
|
+
+'<span class="badge badge-'+(u.role==='admin'?'error':'info')+'" style="font-size:9px;">'+u.role+'</span>'
|
|
168
|
+
+(u.email?'<span style="color:var(--text3);font-size:11px;">'+escHtml(u.email)+'</span>':'')
|
|
169
|
+
+'<button class="btn btn-danger btn-xs" style="margin-left:auto;" onclick="removeUser(\''+slug+'\',\''+u.username+'\')">Remove</button>'
|
|
170
|
+
+'</div>'
|
|
171
|
+
).join('');
|
|
172
|
+
} else {
|
|
173
|
+
usersEl.innerHTML = '<span style="color:var(--text3);">No users yet</span>';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Settings / details
|
|
177
|
+
const settingsEl = document.getElementById('settings-'+slug);
|
|
178
|
+
settingsEl.innerHTML = [
|
|
179
|
+
['App name', d.settings.appName||'—'],
|
|
180
|
+
['Log retention', (d.settings.retentionDays||7)+' days'],
|
|
181
|
+
['Logs dir', '<code style="font-size:10px;word-break:break-all;">'+escHtml(d.logsDir||'')+'</code>'],
|
|
182
|
+
['Services', (d.services||[]).join(', ')||'none'],
|
|
183
|
+
['Total users', d.users.length],
|
|
184
|
+
].map(([k,v]) =>
|
|
185
|
+
'<div style="display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--border);">'
|
|
186
|
+
+'<span style="color:var(--text3);min-width:90px;">'+k+'</span>'
|
|
187
|
+
+'<span style="font-weight:400;">'+v+'</span></div>'
|
|
188
|
+
).join('');
|
|
189
|
+
|
|
190
|
+
// Update total users stat
|
|
191
|
+
updateUserStat();
|
|
192
|
+
} catch(e) {
|
|
193
|
+
document.getElementById('users-'+slug).textContent = 'Error: '+e.message;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function updateUserStat() {
|
|
198
|
+
try {
|
|
199
|
+
const r = await fetch('/api/orgs');
|
|
200
|
+
const d = await r.json();
|
|
201
|
+
// Just show loaded count
|
|
202
|
+
document.getElementById('stat-users').textContent = '…';
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Create org ──────────────────────────────────────────────────────────────
|
|
207
|
+
function showCreate() { document.getElementById('create-modal').style.display='flex'; document.getElementById('new-name').focus(); }
|
|
208
|
+
function hideCreate() { document.getElementById('create-modal').style.display='none'; document.getElementById('create-error').style.display='none'; }
|
|
209
|
+
|
|
210
|
+
async function createOrg() {
|
|
211
|
+
const name = document.getElementById('new-name').value.trim();
|
|
212
|
+
const owner = document.getElementById('new-owner').value.trim();
|
|
213
|
+
const pass = document.getElementById('new-pass').value;
|
|
214
|
+
const plan = document.getElementById('new-plan').value;
|
|
215
|
+
const errEl = document.getElementById('create-error');
|
|
216
|
+
errEl.style.display = 'none';
|
|
217
|
+
if (!name) { errEl.textContent='Org name required'; errEl.style.display=''; return; }
|
|
218
|
+
if (!owner) { errEl.textContent='Admin username required'; errEl.style.display=''; return; }
|
|
219
|
+
if (pass.length<8) { errEl.textContent='Password must be at least 8 characters'; errEl.style.display=''; return; }
|
|
220
|
+
const r = await fetch('/api/orgs', {credentials:'include',
|
|
221
|
+
method:'POST', headers:{'Content-Type':'application/json'},
|
|
222
|
+
body: JSON.stringify({ name, ownerUsername:owner, ownerPassword:pass, plan }),
|
|
223
|
+
});
|
|
224
|
+
const d = await r.json();
|
|
225
|
+
if (!r.ok) { errEl.textContent=d.error||'Create failed'; errEl.style.display=''; return; }
|
|
226
|
+
toast('Org "'+d.org.slug+'" created!','success');
|
|
227
|
+
setTimeout(() => location.reload(), 1200);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Delete org ──────────────────────────────────────────────────────────────
|
|
231
|
+
async function deleteOrg(evt, slug, name) {
|
|
232
|
+
evt.stopPropagation();
|
|
233
|
+
if (!confirm('Delete org "'+name+'" ('+slug+')?\n\nData files will NOT be deleted.')) return;
|
|
234
|
+
const r = await fetch('/api/orgs/'+slug, { method:'DELETE', credentials:'include' });
|
|
235
|
+
const d = await r.json();
|
|
236
|
+
if (!r.ok) { toast(d.error,'error'); return; }
|
|
237
|
+
toast('Org deleted','success');
|
|
238
|
+
document.getElementById('org-card-'+slug).remove();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Create admin in org ────────────────────────────────────────────────────
|
|
242
|
+
async function createAdmin(slug) {
|
|
243
|
+
const username = document.getElementById('admin-user-'+slug).value.trim();
|
|
244
|
+
const password = document.getElementById('admin-pass-'+slug).value;
|
|
245
|
+
const resEl = document.getElementById('admin-result-'+slug);
|
|
246
|
+
if (!username || password.length < 8) { resEl.innerHTML='<span style="color:var(--red)">Username and password (8+) required</span>'; return; }
|
|
247
|
+
const r = await fetch('/api/orgs/'+slug+'/create-user', {credentials:'include',
|
|
248
|
+
method:'POST', headers:{'Content-Type':'application/json'},
|
|
249
|
+
body: JSON.stringify({ username, password, role:'admin' }),
|
|
250
|
+
});
|
|
251
|
+
const d = await r.json();
|
|
252
|
+
if (!r.ok) { resEl.innerHTML='<span style="color:var(--red)">'+escHtml(d.error||'Failed')+'</span>'; return; }
|
|
253
|
+
resEl.innerHTML = '<span style="color:var(--green)">✓ Admin "'+escHtml(username)+'" created</span>';
|
|
254
|
+
document.getElementById('admin-user-'+slug).value = '';
|
|
255
|
+
document.getElementById('admin-pass-'+slug).value = '';
|
|
256
|
+
loadedOrgs.delete(slug);
|
|
257
|
+
loadOrgDetail(slug);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Remove user ─────────────────────────────────────────────────────────────
|
|
261
|
+
async function removeUser(slug, username) {
|
|
262
|
+
if (!confirm('Remove user "'+username+'" from org "'+slug+'"?')) return;
|
|
263
|
+
const r = await fetch('/api/orgs/'+slug+'/users/'+encodeURIComponent(username), { method:'DELETE', credentials:'include' });
|
|
264
|
+
const d = await r.json();
|
|
265
|
+
if (!r.ok) { toast(d.error,'error'); return; }
|
|
266
|
+
toast('User removed','success');
|
|
267
|
+
loadedOrgs.delete(slug);
|
|
268
|
+
loadOrgDetail(slug);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Invite user ─────────────────────────────────────────────────────────────
|
|
272
|
+
async function inviteUser(slug) {
|
|
273
|
+
const email = document.getElementById('inv-email-'+slug).value.trim();
|
|
274
|
+
const role = document.getElementById('inv-role-'+slug).value;
|
|
275
|
+
const resEl = document.getElementById('inv-result-'+slug);
|
|
276
|
+
if (!email) { resEl.innerHTML='<span style="color:var(--red)">Email required</span>'; return; }
|
|
277
|
+
const r = await fetch('/api/orgs/invites', {credentials:'include',
|
|
278
|
+
method:'POST', headers:{'Content-Type':'application/json'},
|
|
279
|
+
body: JSON.stringify({ email, role, orgSlug: slug }),
|
|
280
|
+
});
|
|
281
|
+
const d = await r.json();
|
|
282
|
+
if (!r.ok) { resEl.innerHTML='<span style="color:var(--red)">'+escHtml(d.error||'Failed')+'</span>'; return; }
|
|
283
|
+
const link = window.location.origin+'/invite/'+d.token;
|
|
284
|
+
resEl.innerHTML = '<span style="color:var(--green)">✓ Invite link: </span><a href="'+link+'" style="color:var(--accent-l);font-size:10px;word-break:break-all;">'+link+'</a>';
|
|
285
|
+
document.getElementById('inv-email-'+slug).value = '';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Migration ───────────────────────────────────────────────────────────────
|
|
289
|
+
async function runMigration() {
|
|
290
|
+
document.getElementById('mig-status').textContent = 'Running…';
|
|
291
|
+
const r = await fetch('/api/orgs/migrate', { method:'POST', credentials:'include' });
|
|
292
|
+
const d = await r.json();
|
|
293
|
+
if (!r.ok) { toast(d.error,'error'); document.getElementById('mig-status').textContent='Error'; return; }
|
|
294
|
+
if (d.skipped) { document.getElementById('mig-status').textContent='Already migrated ✓'; return; }
|
|
295
|
+
toast('Migration complete!','success');
|
|
296
|
+
document.getElementById('mig-status').textContent='Done ✓ — moved: '+(d.moved||[]).join(', ');
|
|
297
|
+
setTimeout(() => location.reload(), 1500);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Auto-expand first org on page load
|
|
301
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
302
|
+
const firstOrg = document.querySelector('[id^="org-card-"]');
|
|
303
|
+
if (firstOrg) {
|
|
304
|
+
const slug = firstOrg.id.replace('org-card-','');
|
|
305
|
+
toggleOrg(slug);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
function escHtml(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
309
|
+
</script>
|
|
310
|
+
</body></html>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<%- include('partials/head', { title: 'My Profile' }) %>
|
|
2
|
+
<div class="app-shell">
|
|
3
|
+
<%- include('partials/sidebar') %>
|
|
4
|
+
<div class="main-area">
|
|
5
|
+
<header class="top-header">
|
|
6
|
+
<div class="page-title">My Profile <span style="font-size:11px;background:rgba(239,68,68,.15);color:#f87171;padding:2px 8px;border-radius:4px;margin-left:8px;font-weight:600;">SUPER ADMIN</span></div>
|
|
7
|
+
</header>
|
|
8
|
+
<div class="page-content" style="max-width:560px;">
|
|
9
|
+
|
|
10
|
+
<!-- Profile info -->
|
|
11
|
+
<div class="card" style="margin-bottom:14px;padding:20px 24px;">
|
|
12
|
+
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px;">
|
|
13
|
+
<div style="width:52px;height:52px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:700;color:#fff;flex-shrink:0;">
|
|
14
|
+
<%= user.username[0].toUpperCase() %>
|
|
15
|
+
</div>
|
|
16
|
+
<div>
|
|
17
|
+
<div style="font-size:16px;font-weight:600;"><%= user.username %></div>
|
|
18
|
+
<div style="font-size:12px;color:var(--text3);">Super Administrator</div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<!-- Change password -->
|
|
24
|
+
<div class="card" style="padding:20px 24px;">
|
|
25
|
+
<div style="font-size:13px;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:8px;">
|
|
26
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
27
|
+
Change Password
|
|
28
|
+
</div>
|
|
29
|
+
<div id="pw-error" style="display:none;color:var(--red);font-size:12px;margin-bottom:12px;padding:8px 12px;background:rgba(239,68,68,.1);border-radius:6px;"></div>
|
|
30
|
+
<div id="pw-success" style="display:none;color:var(--green);font-size:12px;margin-bottom:12px;padding:8px 12px;background:rgba(34,197,94,.1);border-radius:6px;">✓ Password updated</div>
|
|
31
|
+
<div class="form-group">
|
|
32
|
+
<label class="form-label">Current Password</label>
|
|
33
|
+
<input type="password" id="pw-current" class="form-input" placeholder="Enter current password" autocomplete="current-password"/>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="form-group">
|
|
36
|
+
<label class="form-label">New Password <span style="color:var(--text3);font-weight:400;">(min 8 chars)</span></label>
|
|
37
|
+
<input type="password" id="pw-new" class="form-input" placeholder="Enter new password" autocomplete="new-password"/>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="form-group">
|
|
40
|
+
<label class="form-label">Confirm New Password</label>
|
|
41
|
+
<input type="password" id="pw-confirm" class="form-input" placeholder="Repeat new password"/>
|
|
42
|
+
</div>
|
|
43
|
+
<button id="pw-btn" class="btn btn-primary" onclick="changePassword()">Update Password</button>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<script>
|
|
50
|
+
async function changePassword() {
|
|
51
|
+
const curr = document.getElementById('pw-current').value;
|
|
52
|
+
const newPw = document.getElementById('pw-new').value;
|
|
53
|
+
const confirm = document.getElementById('pw-confirm').value;
|
|
54
|
+
const errEl = document.getElementById('pw-error');
|
|
55
|
+
const okEl = document.getElementById('pw-success');
|
|
56
|
+
const btn = document.getElementById('pw-btn');
|
|
57
|
+
errEl.style.display = okEl.style.display = 'none';
|
|
58
|
+
|
|
59
|
+
if (!curr) { errEl.textContent='Current password required'; errEl.style.display=''; return; }
|
|
60
|
+
if (newPw.length<8) { errEl.textContent='New password must be at least 8 characters'; errEl.style.display=''; return; }
|
|
61
|
+
if (newPw!==confirm) { errEl.textContent='Passwords do not match'; errEl.style.display=''; return; }
|
|
62
|
+
|
|
63
|
+
btn.disabled=true; btn.textContent='Updating…';
|
|
64
|
+
try {
|
|
65
|
+
const r = await fetch('/api/super/change-password', {
|
|
66
|
+
method: 'POST', credentials: 'include',
|
|
67
|
+
headers: {'Content-Type':'application/json'},
|
|
68
|
+
body: JSON.stringify({ currentPassword: curr, newPassword: newPw }),
|
|
69
|
+
});
|
|
70
|
+
const d = await r.json();
|
|
71
|
+
if (!r.ok) { errEl.textContent = d.error||'Failed'; errEl.style.display=''; }
|
|
72
|
+
else { okEl.style.display=''; document.getElementById('pw-current').value=''; document.getElementById('pw-new').value=''; document.getElementById('pw-confirm').value=''; }
|
|
73
|
+
} catch(e) { errEl.textContent='Network error'; errEl.style.display=''; }
|
|
74
|
+
btn.disabled=false; btn.textContent='Update Password';
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
</body></html>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<%- include('partials/head', { title: 'Global Settings' }) %>
|
|
2
|
+
<div class="app-shell">
|
|
3
|
+
<%- include('partials/sidebar') %>
|
|
4
|
+
<div class="main-area">
|
|
5
|
+
<header class="top-header">
|
|
6
|
+
<div class="page-title">Global Settings <span style="font-size:11px;background:rgba(239,68,68,.15);color:#f87171;padding:2px 8px;border-radius:4px;margin-left:8px;font-weight:600;">SUPER ADMIN</span></div>
|
|
7
|
+
</header>
|
|
8
|
+
<div class="page-content">
|
|
9
|
+
|
|
10
|
+
<!-- Registration -->
|
|
11
|
+
<div class="card" style="margin-bottom:12px;">
|
|
12
|
+
<div class="card-title" style="margin-bottom:12px;">Registration</div>
|
|
13
|
+
<div class="form-group">
|
|
14
|
+
<label class="form-label">Public Registration</label>
|
|
15
|
+
<div style="display:flex;align-items:center;gap:12px;">
|
|
16
|
+
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;">
|
|
17
|
+
<input type="radio" name="reg-mode" value="open" <%=globalSettings.registrationMode==='open'?'checked':''%>> Open (anyone can register)
|
|
18
|
+
</label>
|
|
19
|
+
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;">
|
|
20
|
+
<input type="radio" name="reg-mode" value="invite" <%=globalSettings.registrationMode!=='open'?'checked':''%>> Invite only (super-admin creates orgs)
|
|
21
|
+
</label>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="form-group">
|
|
25
|
+
<label class="form-label">Default Plan for new orgs</label>
|
|
26
|
+
<select id="default-plan" class="form-select" style="width:160px;">
|
|
27
|
+
<option value="free" <%=globalSettings.defaultPlan==='free'?'selected':''%>>Free</option>
|
|
28
|
+
<option value="pro" <%=globalSettings.defaultPlan==='pro'?'selected':''%>>Pro</option>
|
|
29
|
+
</select>
|
|
30
|
+
</div>
|
|
31
|
+
<button class="btn btn-primary btn-sm" onclick="saveRegistration()">Save</button>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<!-- OAuth / SSO -->
|
|
35
|
+
<div class="card" style="margin-bottom:12px;">
|
|
36
|
+
<div class="card-title" style="margin-bottom:12px;">OAuth / SSO Providers</div>
|
|
37
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
|
38
|
+
<div>
|
|
39
|
+
<div style="font-size:12px;font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:6px;">
|
|
40
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
|
41
|
+
GitHub
|
|
42
|
+
</div>
|
|
43
|
+
<div class="form-group"><label class="form-label">Client ID</label><input type="text" id="gh-id" class="form-input" value="<%=globalSettings.githubClientId||''%>" placeholder="Ghp_xxxxxxx"/></div>
|
|
44
|
+
<div class="form-group"><label class="form-label">Client Secret</label><input type="password" id="gh-secret" class="form-input" value="<%=globalSettings.githubClientSecret?'••••••••':''%>" placeholder="Leave blank to keep"/></div>
|
|
45
|
+
</div>
|
|
46
|
+
<div>
|
|
47
|
+
<div style="font-size:12px;font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:6px;">
|
|
48
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
|
|
49
|
+
Google
|
|
50
|
+
</div>
|
|
51
|
+
<div class="form-group"><label class="form-label">Client ID</label><input type="text" id="gg-id" class="form-input" value="<%=globalSettings.googleClientId||''%>" placeholder="xxxxx.apps.googleusercontent.com"/></div>
|
|
52
|
+
<div class="form-group"><label class="form-label">Client Secret</label><input type="password" id="gg-secret" class="form-input" value="<%=globalSettings.googleClientSecret?'••••••••':''%>" placeholder="Leave blank to keep"/></div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
<div style="margin-top:4px;font-size:11px;color:var(--text3);">Callback URL: <code><%=baseUrl%>/auth/github/callback</code> | <code><%=baseUrl%>/auth/google/callback</code></div>
|
|
56
|
+
<button class="btn btn-primary btn-sm" style="margin-top:12px;" onclick="saveOAuth()">Save OAuth Config</button>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<!-- SMTP -->
|
|
60
|
+
<div class="card">
|
|
61
|
+
<div class="card-title" style="margin-bottom:12px;">Global SMTP (for registration emails)</div>
|
|
62
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
|
63
|
+
<div class="form-group" style="margin:0"><label class="form-label">SMTP Host</label><input type="text" id="smtp-host" class="form-input" value="<%=globalSettings.smtpHost||''%>" placeholder="smtp.sendgrid.net"/></div>
|
|
64
|
+
<div class="form-group" style="margin:0"><label class="form-label">Port</label><input type="number" id="smtp-port" class="form-input" value="<%=globalSettings.smtpPort||587%>"/></div>
|
|
65
|
+
<div class="form-group" style="margin:0"><label class="form-label">User</label><input type="text" id="smtp-user" class="form-input" value="<%=globalSettings.smtpUser||''%>"/></div>
|
|
66
|
+
<div class="form-group" style="margin:0"><label class="form-label">Password</label><input type="password" id="smtp-pass" class="form-input" placeholder="Leave blank to keep"/></div>
|
|
67
|
+
<div class="form-group" style="margin:0"><label class="form-label">From</label><input type="email" id="smtp-from" class="form-input" value="<%=globalSettings.smtpFrom||''%>" placeholder="noreply@yourapp.com"/></div>
|
|
68
|
+
</div>
|
|
69
|
+
<button class="btn btn-primary btn-sm" style="margin-top:12px;" onclick="saveSMTP()">Save SMTP</button>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<script>
|
|
76
|
+
async function saveRegistration() {
|
|
77
|
+
const mode = document.querySelector('input[name="reg-mode"]:checked').value;
|
|
78
|
+
const plan = document.getElementById('default-plan').value;
|
|
79
|
+
const r = await fetch('/api/super/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ registrationMode:mode, defaultPlan:plan }) });
|
|
80
|
+
const d = await r.json();
|
|
81
|
+
if (!r.ok) { toast(d.error,'error'); return; }
|
|
82
|
+
toast('Registration settings saved','success');
|
|
83
|
+
}
|
|
84
|
+
async function saveOAuth() {
|
|
85
|
+
const body = {
|
|
86
|
+
githubClientId: document.getElementById('gh-id').value.trim(),
|
|
87
|
+
googleClientId: document.getElementById('gg-id').value.trim(),
|
|
88
|
+
};
|
|
89
|
+
const ghSecret = document.getElementById('gh-secret').value;
|
|
90
|
+
const ggSecret = document.getElementById('gg-secret').value;
|
|
91
|
+
if (ghSecret && ghSecret !== '••••••••') body.githubClientSecret = ghSecret;
|
|
92
|
+
if (ggSecret && ggSecret !== '••••••••') body.googleClientSecret = ggSecret;
|
|
93
|
+
const r = await fetch('/api/super/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
94
|
+
const d = await r.json();
|
|
95
|
+
if (!r.ok) { toast(d.error,'error'); return; }
|
|
96
|
+
toast('OAuth config saved — restart server for changes to take effect','success');
|
|
97
|
+
}
|
|
98
|
+
async function saveSMTP() {
|
|
99
|
+
const body = { smtpHost: document.getElementById('smtp-host').value.trim(), smtpPort: +document.getElementById('smtp-port').value, smtpUser: document.getElementById('smtp-user').value.trim(), smtpFrom: document.getElementById('smtp-from').value.trim() };
|
|
100
|
+
const pass = document.getElementById('smtp-pass').value;
|
|
101
|
+
if (pass) body.smtpPass = pass;
|
|
102
|
+
const r = await fetch('/api/super/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
103
|
+
const d = await r.json();
|
|
104
|
+
if (!r.ok) { toast(d.error,'error'); return; }
|
|
105
|
+
toast('SMTP saved','success');
|
|
106
|
+
}
|
|
107
|
+
</script>
|
|
108
|
+
</body></html>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<%- include('partials/head', { title: 'System Health' }) %>
|
|
2
|
+
<div class="app-shell">
|
|
3
|
+
<%- include('partials/sidebar') %>
|
|
4
|
+
<div class="main-area">
|
|
5
|
+
<header class="top-header">
|
|
6
|
+
<div class="page-title">System Health <span style="font-size:11px;background:rgba(239,68,68,.15);color:#f87171;padding:2px 8px;border-radius:4px;margin-left:8px;font-weight:600;">SUPER ADMIN</span></div>
|
|
7
|
+
<button class="btn btn-secondary btn-sm" onclick="load()">Refresh</button>
|
|
8
|
+
</header>
|
|
9
|
+
<div class="page-content">
|
|
10
|
+
<div class="stats-grid" style="margin-bottom:14px;">
|
|
11
|
+
<div class="stat-card"><div><div class="stat-value" id="s-ram">—</div><div class="stat-label">RAM Used %</div></div></div>
|
|
12
|
+
<div class="stat-card"><div><div class="stat-value" id="s-uptime">—</div><div class="stat-label">Uptime</div></div></div>
|
|
13
|
+
<div class="stat-card"><div><div class="stat-value" id="s-node">—</div><div class="stat-label">Node.js</div></div></div>
|
|
14
|
+
<div class="stat-card"><div><div class="stat-value" id="s-env">—</div><div class="stat-label">Environment</div></div></div>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="card">
|
|
17
|
+
<div class="card-title" style="margin-bottom:12px;">Process Details</div>
|
|
18
|
+
<div id="details" style="font-size:12px;color:var(--text2);">Loading…</div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
<script>
|
|
24
|
+
async function load() {
|
|
25
|
+
const r = await fetch('/api/super/system-health', {credentials:'include'});
|
|
26
|
+
if (!r.ok) { document.getElementById('details').textContent='Error '+r.status+': '+((await r.json()).error||r.statusText); return; }
|
|
27
|
+
const d = await r.json();
|
|
28
|
+
document.getElementById('s-ram').textContent = d.ramPct+'%';
|
|
29
|
+
document.getElementById('s-uptime').textContent = formatUptime(d.uptime);
|
|
30
|
+
document.getElementById('s-node').textContent = d.node;
|
|
31
|
+
document.getElementById('s-env').textContent = d.env;
|
|
32
|
+
document.getElementById('details').innerHTML = [
|
|
33
|
+
['PID', d.pid], ['Platform', d.platform], ['Arch', d.arch],
|
|
34
|
+
['Hostname', d.hostname], ['CPUs', d.cpus],
|
|
35
|
+
['RAM', d.ramUsed+'MB / '+d.ramTotal+'MB'],
|
|
36
|
+
].map(([k,v])=>`<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);">
|
|
37
|
+
<span style="color:var(--text3);min-width:100px;">${k}</span>
|
|
38
|
+
<span style="font-family:monospace;">${v}</span></div>`).join('');
|
|
39
|
+
}
|
|
40
|
+
function formatUptime(s) {
|
|
41
|
+
const h=Math.floor(s/3600), m=Math.floor((s%3600)/60);
|
|
42
|
+
return h>0?h+'h '+m+'m':m+'m '+(s%60)+'s';
|
|
43
|
+
}
|
|
44
|
+
load();
|
|
45
|
+
</script>
|
|
46
|
+
</body></html>
|