@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
package/views/live.ejs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
<%- include('partials/head', { title: 'Live Stream' }) %>
|
|
2
|
+
<style>
|
|
3
|
+
.live-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:10px 16px; background:var(--surface); border-bottom:1px solid var(--border); flex-shrink:0; }
|
|
4
|
+
.conn-indicator { display:flex; align-items:center; gap:6px; font-size:12px; font-weight:600; }
|
|
5
|
+
#stream-box { flex:1; overflow-y:auto; padding:8px 4px; font-family:'JetBrains Mono',monospace; font-size:12px; }
|
|
6
|
+
.stream-line { display:flex; align-items:flex-start; gap:8px; padding:5px 10px; border-radius:4px; cursor:pointer; transition:background .1s; border-left:3px solid transparent; }
|
|
7
|
+
.stream-line:hover { background:var(--surface2); }
|
|
8
|
+
.stream-line.level-error { border-left-color:var(--red); background:rgba(239,68,68,.04); }
|
|
9
|
+
.stream-line.level-warn { border-left-color:var(--yellow); background:rgba(245,158,11,.04); }
|
|
10
|
+
.stream-line.level-info { border-left-color:var(--blue); }
|
|
11
|
+
.stream-line.level-debug { border-left-color:var(--gray); opacity:.7; }
|
|
12
|
+
.stream-ts { color:var(--text3); white-space:nowrap; font-size:11px; padding-top:2px; min-width:155px; }
|
|
13
|
+
.stream-svc { color:var(--accent-l); white-space:nowrap; font-size:11px; min-width:80px; max-width:100px; overflow:hidden; text-overflow:ellipsis; padding-top:2px; }
|
|
14
|
+
.stream-msg { flex:1; word-break:break-all; color:var(--text); }
|
|
15
|
+
.stream-exp { display:none; padding:6px 10px 8px; background:var(--surface3); border-radius:4px; margin:0 10px 4px; }
|
|
16
|
+
.stream-exp pre { font-size:11px; white-space:pre-wrap; word-break:break-all; color:var(--text); }
|
|
17
|
+
#count-badge { background:var(--accent-dim); color:var(--accent-l); padding:2px 10px; border-radius:10px; font-size:11px; font-weight:600; }
|
|
18
|
+
#err-badge { background:rgba(239,68,68,.15); color:var(--red); padding:2px 10px; border-radius:10px; font-size:11px; font-weight:600; }
|
|
19
|
+
/* Level icon in stream lines */
|
|
20
|
+
.slvl-icon { display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; border-radius:3px; flex-shrink:0; margin-top:1px; }
|
|
21
|
+
.slvl-icon.error { background:rgba(239,68,68,.15); }
|
|
22
|
+
.slvl-icon.warn { background:rgba(245,158,11,.15); }
|
|
23
|
+
.slvl-icon.info { background:rgba(59,130,246,.15); }
|
|
24
|
+
.slvl-icon.debug { background:rgba(107,114,128,.15); }
|
|
25
|
+
</style>
|
|
26
|
+
<div class="app-shell">
|
|
27
|
+
<%- include('partials/sidebar') %>
|
|
28
|
+
<div class="main-area">
|
|
29
|
+
<header class="top-header">
|
|
30
|
+
<div class="page-title">Live Stream</div>
|
|
31
|
+
<div class="header-actions">
|
|
32
|
+
<div class="conn-indicator">
|
|
33
|
+
<span class="dot dot-gray dot-pulse" id="conn-dot"></span>
|
|
34
|
+
<span id="conn-label">Connecting…</span>
|
|
35
|
+
</div>
|
|
36
|
+
<span id="count-badge">0 logs</span>
|
|
37
|
+
<span id="err-badge" style="display:none">0 errors</span>
|
|
38
|
+
</div>
|
|
39
|
+
</header>
|
|
40
|
+
|
|
41
|
+
<!-- Toolbar -->
|
|
42
|
+
<div class="live-toolbar">
|
|
43
|
+
<!-- Level chips -->
|
|
44
|
+
<div class="chip-bar" style="margin-bottom:0;flex-wrap:nowrap;">
|
|
45
|
+
<span style="font-size:11px;color:var(--text3);white-space:nowrap;">Filter:</span>
|
|
46
|
+
<span class="chip active" id="chip-all" onclick="setLevel('')">All</span>
|
|
47
|
+
<span class="chip error" id="chip-error" onclick="setLevel('error')">
|
|
48
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
49
|
+
Error
|
|
50
|
+
</span>
|
|
51
|
+
<span class="chip warn" id="chip-warn" onclick="setLevel('warn')">
|
|
52
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
|
53
|
+
Warn
|
|
54
|
+
</span>
|
|
55
|
+
<span class="chip info" id="chip-info" onclick="setLevel('info')">
|
|
56
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
|
57
|
+
Info
|
|
58
|
+
</span>
|
|
59
|
+
<span class="chip debug" id="chip-debug" onclick="setLevel('debug')">
|
|
60
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>
|
|
61
|
+
Debug
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div style="flex:1"></div>
|
|
66
|
+
|
|
67
|
+
<!-- Service filter -->
|
|
68
|
+
<select id="svc-filter" class="form-select" style="width:160px;" onchange="applyFilters()">
|
|
69
|
+
<option value="">All Services</option>
|
|
70
|
+
<% services.forEach(function(s) { %>
|
|
71
|
+
<option value="<%= s.appName %>"><%= s.appName %></option>
|
|
72
|
+
<% }) %>
|
|
73
|
+
</select>
|
|
74
|
+
|
|
75
|
+
<!-- Keyword search -->
|
|
76
|
+
<div class="search-wrap" style="width:200px;">
|
|
77
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
78
|
+
<input id="kw-filter" type="text" class="form-input search-input" placeholder="Keyword filter…" oninput="applyFilters()"/>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<button id="scroll-btn" class="btn btn-secondary btn-sm" onclick="toggleScroll()">
|
|
82
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23,18 13.5,8.5 8.5,13.5 1,6"/><polyline points="17,18 23,18 23,12"/></svg>
|
|
83
|
+
Auto-scroll ON
|
|
84
|
+
</button>
|
|
85
|
+
<button id="pause-btn" class="btn btn-secondary btn-sm" onclick="togglePause()">
|
|
86
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
|
|
87
|
+
Pause
|
|
88
|
+
</button>
|
|
89
|
+
<button class="btn btn-secondary btn-sm" onclick="clearStream()">
|
|
90
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,6 5,6 21,6"/><path d="M19,6v14a2,2,0,0,1-2,2H7a2,2,0,0,1-2-2V6m3,0V4a1,1,0,0,1,1-1h4a1,1,0,0,1,1,1v2"/></svg>
|
|
91
|
+
Clear
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<!-- Stream box -->
|
|
96
|
+
<div id="stream-box">
|
|
97
|
+
<div class="empty-state" id="empty-hint">
|
|
98
|
+
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49"/><path d="M7.76 7.76a6 6 0 0 0 0 8.49"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>
|
|
99
|
+
<p>Waiting for live logs…</p>
|
|
100
|
+
<p style="font-size:11px;margin-top:4px;">Send logs via the ingest API to see them here in real time</p>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<script>
|
|
107
|
+
const MAX_LINES = 1000;
|
|
108
|
+
let paused = false;
|
|
109
|
+
let autoScroll = true;
|
|
110
|
+
let levelFilter = '';
|
|
111
|
+
let svcFilter = '';
|
|
112
|
+
let kwFilter = '';
|
|
113
|
+
let totalCount = 0;
|
|
114
|
+
let errCount = 0;
|
|
115
|
+
const pendingBuf = [];
|
|
116
|
+
|
|
117
|
+
const box = document.getElementById('stream-box');
|
|
118
|
+
const connDot = document.getElementById('conn-dot');
|
|
119
|
+
const connLbl = document.getElementById('conn-label');
|
|
120
|
+
const countEl = document.getElementById('count-badge');
|
|
121
|
+
const errEl = document.getElementById('err-badge');
|
|
122
|
+
|
|
123
|
+
const LEVEL_ICONS = {
|
|
124
|
+
error: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`,
|
|
125
|
+
warn: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
|
|
126
|
+
info: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
|
|
127
|
+
debug: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>`,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ── SSE ────────────────────────────────────────────────────────────────────
|
|
131
|
+
let es;
|
|
132
|
+
function connect() {
|
|
133
|
+
es = new EventSource('/api/logs/stream', { withCredentials: true });
|
|
134
|
+
es.onopen = () => { setConn('green','Live'); };
|
|
135
|
+
es.addEventListener('connected', () => setConn('green','Connected'));
|
|
136
|
+
es.onmessage = (e) => {
|
|
137
|
+
if (paused) { pendingBuf.push(e.data); if (pendingBuf.length > 500) pendingBuf.shift(); return; }
|
|
138
|
+
addLine(e.data);
|
|
139
|
+
};
|
|
140
|
+
es.onerror = () => {
|
|
141
|
+
setConn('red','Reconnecting…');
|
|
142
|
+
es.close();
|
|
143
|
+
setTimeout(connect, 3000);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function setConn(color, label) {
|
|
148
|
+
connDot.className = 'dot dot-' + color + (color === 'green' ? ' dot-pulse' : '');
|
|
149
|
+
connLbl.textContent = label;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
connect();
|
|
153
|
+
|
|
154
|
+
// ── Add line ───────────────────────────────────────────────────────────────
|
|
155
|
+
function addLine(raw) {
|
|
156
|
+
const hint = document.getElementById('empty-hint');
|
|
157
|
+
if (hint) hint.remove();
|
|
158
|
+
|
|
159
|
+
let parsed = null, level = 'info', ts = '', svc = '', msg = '';
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(raw);
|
|
162
|
+
level = (parsed.level || 'info').toLowerCase();
|
|
163
|
+
ts = parsed.ts ? parsed.ts.replace('T',' ').slice(0,19) : new Date().toISOString().replace('T',' ').slice(0,19);
|
|
164
|
+
svc = parsed.appName || '';
|
|
165
|
+
msg = parsed.message || parsed.msg || raw;
|
|
166
|
+
} catch { msg = raw; }
|
|
167
|
+
|
|
168
|
+
totalCount++;
|
|
169
|
+
if (level === 'error') { errCount++; errEl.style.display=''; errEl.textContent = errCount + ' errors'; }
|
|
170
|
+
countEl.textContent = totalCount.toLocaleString() + ' logs';
|
|
171
|
+
|
|
172
|
+
const wrap = document.createElement('div');
|
|
173
|
+
wrap.className = 'stream-pair';
|
|
174
|
+
|
|
175
|
+
const div = document.createElement('div');
|
|
176
|
+
div.className = `stream-line level-${level}`;
|
|
177
|
+
div.dataset.raw = raw;
|
|
178
|
+
div.dataset.level = level;
|
|
179
|
+
div.dataset.svc = svc.toLowerCase();
|
|
180
|
+
|
|
181
|
+
div.innerHTML =
|
|
182
|
+
`<span class="slvl-icon ${level}">${LEVEL_ICONS[level]||LEVEL_ICONS.debug}</span>` +
|
|
183
|
+
`<span class="stream-ts">${ts}</span>` +
|
|
184
|
+
(svc ? `<span class="stream-svc">${escHtml(svc)}</span>` : '') +
|
|
185
|
+
`<span class="stream-msg">${escHtml(String(msg).replace(/\n/g,"↵ ").replace(/\r/g,"").replace(/\t/g," "))}</span>`;
|
|
186
|
+
|
|
187
|
+
const expDiv = document.createElement('div');
|
|
188
|
+
expDiv.className = 'stream-exp';
|
|
189
|
+
expDiv.innerHTML = `<pre>${escHtml(parsed ? JSON.stringify(parsed, null, 2) : raw)}</pre>`;
|
|
190
|
+
|
|
191
|
+
div.onclick = () => {
|
|
192
|
+
const open = expDiv.style.display === '';
|
|
193
|
+
expDiv.style.display = open ? 'none' : '';
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
wrap.appendChild(div);
|
|
197
|
+
wrap.appendChild(expDiv);
|
|
198
|
+
wrap.style.display = shouldShow(level, svc.toLowerCase(), raw) ? '' : 'none';
|
|
199
|
+
box.appendChild(wrap);
|
|
200
|
+
|
|
201
|
+
// Trim old
|
|
202
|
+
while (box.children.length > MAX_LINES * 2) box.removeChild(box.firstChild);
|
|
203
|
+
if (autoScroll) box.scrollTop = box.scrollHeight;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Filters ────────────────────────────────────────────────────────────────
|
|
207
|
+
function shouldShow(level, svc, raw) {
|
|
208
|
+
if (levelFilter && level !== levelFilter) return false;
|
|
209
|
+
if (svcFilter && svc !== svcFilter.toLowerCase()) return false;
|
|
210
|
+
// keyword: dimming handled in applyFilters, not hide
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function applyFilters() {
|
|
215
|
+
kwFilter = document.getElementById('kw-filter').value.trim().toLowerCase();
|
|
216
|
+
svcFilter = document.getElementById('svc-filter').value;
|
|
217
|
+
box.querySelectorAll('.stream-pair').forEach(wrap => {
|
|
218
|
+
const line = wrap.querySelector('.stream-line');
|
|
219
|
+
if (!line) return;
|
|
220
|
+
const lv = line.dataset.level || 'info';
|
|
221
|
+
const sv = line.dataset.svc || '';
|
|
222
|
+
const raw = line.dataset.raw || '';
|
|
223
|
+
const matchLevel = !levelFilter || lv === levelFilter;
|
|
224
|
+
const matchSvc = !svcFilter || sv === svcFilter.toLowerCase();
|
|
225
|
+
const matchKw = !kwFilter || raw.toLowerCase().includes(kwFilter);
|
|
226
|
+
// Hide if level/svc don't match; dim if keyword doesn't match
|
|
227
|
+
if (!matchLevel || !matchSvc) { wrap.style.display = 'none'; return; }
|
|
228
|
+
wrap.style.display = '';
|
|
229
|
+
wrap.style.opacity = (!kwFilter || matchKw) ? '1' : '0.25';
|
|
230
|
+
// Highlight matching text in message
|
|
231
|
+
const msgEl = line.querySelector('.stream-msg');
|
|
232
|
+
if (msgEl) {
|
|
233
|
+
const orig = msgEl.dataset.orig || msgEl.textContent;
|
|
234
|
+
msgEl.dataset.orig = orig;
|
|
235
|
+
if (kwFilter && matchKw) {
|
|
236
|
+
const re = new RegExp('(' + kwFilter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
|
|
237
|
+
msgEl.innerHTML = orig.replace(re, '<mark style="background:rgba(245,158,11,.4);border-radius:2px;padding:0 1px;">$1</mark>');
|
|
238
|
+
} else {
|
|
239
|
+
msgEl.textContent = orig;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function setLevel(lvl) {
|
|
246
|
+
levelFilter = lvl;
|
|
247
|
+
// Update chip states
|
|
248
|
+
['all','error','warn','info','debug'].forEach(k => {
|
|
249
|
+
const chip = document.getElementById('chip-' + k);
|
|
250
|
+
if (!chip) return;
|
|
251
|
+
const isActive = (k === (lvl || 'all'));
|
|
252
|
+
chip.classList.toggle('active', isActive);
|
|
253
|
+
});
|
|
254
|
+
applyFilters();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Controls ───────────────────────────────────────────────────────────────
|
|
258
|
+
function togglePause() {
|
|
259
|
+
paused = !paused;
|
|
260
|
+
const btn = document.getElementById('pause-btn');
|
|
261
|
+
if (paused) {
|
|
262
|
+
btn.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5,3 19,12 5,21 5,3"/></svg> Resume`;
|
|
263
|
+
btn.style.borderColor = 'var(--yellow)';
|
|
264
|
+
btn.style.color = 'var(--yellow)';
|
|
265
|
+
} else {
|
|
266
|
+
btn.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> Pause`;
|
|
267
|
+
btn.style.borderColor = '';
|
|
268
|
+
btn.style.color = '';
|
|
269
|
+
pendingBuf.forEach(addLine);
|
|
270
|
+
pendingBuf.length = 0;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function toggleScroll() {
|
|
275
|
+
autoScroll = !autoScroll;
|
|
276
|
+
const btn = document.getElementById('scroll-btn');
|
|
277
|
+
const icon = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23,18 13.5,8.5 8.5,13.5 1,6"/><polyline points="17,18 23,18 23,12"/></svg>`;
|
|
278
|
+
btn.innerHTML = icon + (autoScroll ? ' Auto-scroll ON' : ' Auto-scroll OFF');
|
|
279
|
+
btn.style.color = autoScroll ? '' : 'var(--text3)';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function clearStream() {
|
|
283
|
+
box.innerHTML = `<div class="empty-state" id="empty-hint"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49"/><path d="M7.76 7.76a6 6 0 0 0 0 8.49"/></svg><p>Stream cleared</p></div>`;
|
|
284
|
+
totalCount = errCount = 0;
|
|
285
|
+
countEl.textContent = '0 logs';
|
|
286
|
+
errEl.style.display = 'none';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function escHtml(s) {
|
|
290
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Keyboard shortcuts
|
|
294
|
+
document.addEventListener('keydown', e => {
|
|
295
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); clearStream(); }
|
|
296
|
+
if (e.key === ' ' && e.target === document.body) { e.preventDefault(); togglePause(); }
|
|
297
|
+
});
|
|
298
|
+
</script>
|
|
299
|
+
</body></html>
|
package/views/login.ejs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<%- include('partials/head', { title: 'Login' }) %>
|
|
2
|
+
<div class="login-bg">
|
|
3
|
+
<div class="login-card">
|
|
4
|
+
<div class="login-logo">
|
|
5
|
+
<img src="/public/logo.png" alt="LogBoard"/>
|
|
6
|
+
<span>LogBoard</span>
|
|
7
|
+
</div>
|
|
8
|
+
<% if (error) { %><div class="login-error"><%= error %></div><% } %>
|
|
9
|
+
<div id="login-error" class="login-error" style="display:none;"></div>
|
|
10
|
+
<div class="form-group">
|
|
11
|
+
<label class="form-label">Username</label>
|
|
12
|
+
<input type="text" id="un" class="form-input" placeholder="admin" autocomplete="username" autofocus/>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="form-group">
|
|
15
|
+
<label class="form-label">Password</label>
|
|
16
|
+
<input type="password" id="pw" class="form-input" placeholder="••••••••" autocomplete="current-password"/>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="form-group" id="totp-group" style="display:none;">
|
|
19
|
+
<label class="form-label">2FA Code</label>
|
|
20
|
+
<input type="text" id="totp" class="form-input" placeholder="000000" maxlength="6" style="font-family:'JetBrains Mono',monospace;letter-spacing:4px;text-align:center;"/>
|
|
21
|
+
</div>
|
|
22
|
+
<button class="btn btn-primary w-full" id="login-btn" onclick="doLogin()" style="width:100%;justify-content:center;padding:10px;">
|
|
23
|
+
Sign In
|
|
24
|
+
</button>
|
|
25
|
+
<div style="margin-top:20px;padding:12px;background:var(--surface2);border-radius:var(--radius);font-size:11px;color:var(--text3);line-height:1.7;">
|
|
26
|
+
Default: <code style="color:var(--accent-l)">admin / admin123</code><br>
|
|
27
|
+
Run <code style="color:var(--accent-l)">npm run setup</code> to initialise.
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<script>
|
|
32
|
+
async function doLogin(){
|
|
33
|
+
const errEl=document.getElementById('login-error');
|
|
34
|
+
const btn=document.getElementById('login-btn');
|
|
35
|
+
errEl.style.display='none';
|
|
36
|
+
btn.disabled=true; btn.textContent='Signing in…';
|
|
37
|
+
try{
|
|
38
|
+
const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('un').value,password:document.getElementById('pw').value,token:document.getElementById('totp').value||undefined})});
|
|
39
|
+
const d=await r.json();
|
|
40
|
+
if(!r.ok){
|
|
41
|
+
if(d.error==='2FA token required'){document.getElementById('totp-group').style.display='';document.getElementById('totp').focus();btn.disabled=false;btn.textContent='Sign In';return;}
|
|
42
|
+
errEl.textContent=d.error||'Login failed'; errEl.style.display=''; btn.disabled=false; btn.textContent='Sign In'; return;
|
|
43
|
+
}
|
|
44
|
+
location.href = d.redirect || '/dashboard';
|
|
45
|
+
}catch(e){errEl.textContent='Network error';errEl.style.display='';btn.disabled=false;btn.textContent='Sign In';}
|
|
46
|
+
}
|
|
47
|
+
document.addEventListener('keydown',e=>{if(e.key==='Enter')doLogin();});
|
|
48
|
+
// Add register link
|
|
49
|
+
(function(){
|
|
50
|
+
const card = document.querySelector('.login-card,.card');
|
|
51
|
+
if(card){
|
|
52
|
+
const p = document.createElement('p');
|
|
53
|
+
p.className = 'login-register-link';
|
|
54
|
+
p.innerHTML = "Don't have an account? <a href='/register'>Create an organisation →</a>";
|
|
55
|
+
card.appendChild(p);
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
</script>
|
|
59
|
+
<style>
|
|
60
|
+
.login-register-link{text-align:center;margin-top:18px;font-size:13px;color:var(--text3);}
|
|
61
|
+
.login-register-link a{color:var(--accent-l);font-weight:500;text-decoration:none;}
|
|
62
|
+
.login-register-link a:hover{text-decoration:underline;}
|
|
63
|
+
</style>
|
|
64
|
+
</body></html>
|
package/views/logo.png
ADDED
|
Binary file
|