LogView.svelte raw
1 <script>
2 import { createEventDispatcher, onMount, onDestroy } from "svelte";
3 import { getApiBase } from "./config.js";
4
5 export let isLoggedIn = false;
6 export let userRole = "";
7 export let userSigner = null;
8
9 const dispatch = createEventDispatcher();
10
11 let logs = [];
12 let isLoading = false;
13 let hasMore = true;
14 let offset = 0;
15 let totalLogs = 0;
16 let error = "";
17 let currentLogLevel = "info";
18 let selectedLevel = "info";
19
20 const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"];
21 const LIMIT = 100;
22
23 let scrollContainer;
24 let loadMoreTrigger;
25 let observer;
26
27 $: canAccess = isLoggedIn && userRole === "owner";
28
29 onMount(() => {
30 if (canAccess) {
31 loadLogs(true);
32 loadLogLevel();
33 setupIntersectionObserver();
34 }
35 });
36
37 onDestroy(() => {
38 if (observer) {
39 observer.disconnect();
40 }
41 });
42
43 $: if (canAccess && logs.length === 0 && !isLoading) {
44 loadLogs(true);
45 loadLogLevel();
46 }
47
48 function setupIntersectionObserver() {
49 if (!loadMoreTrigger) return;
50
51 observer = new IntersectionObserver(
52 (entries) => {
53 if (entries[0].isIntersecting && hasMore && !isLoading) {
54 loadMoreLogs();
55 }
56 },
57 { threshold: 0.1 }
58 );
59
60 observer.observe(loadMoreTrigger);
61 }
62
63 async function createAuthHeader(method = "GET", path = "/api/logs") {
64 if (!userSigner) return null;
65
66 try {
67 const now = Math.floor(Date.now() / 1000);
68 const authEvent = {
69 kind: 27235,
70 created_at: now,
71 tags: [
72 ["u", `${getApiBase()}${path}`],
73 ["method", method],
74 ],
75 content: "",
76 };
77
78 const signedEvent = await userSigner.signEvent(authEvent);
79 // Use standard base64 encoding per BUD-01 spec
80 return btoa(JSON.stringify(signedEvent));
81 } catch (err) {
82 console.error("Error creating auth header:", err);
83 return null;
84 }
85 }
86
87 async function loadLogs(refresh = false) {
88 if (isLoading) return;
89
90 isLoading = true;
91 error = "";
92
93 if (refresh) {
94 offset = 0;
95 logs = [];
96 }
97
98 try {
99 const path = `/api/logs?offset=${offset}&limit=${LIMIT}`;
100 const authHeader = await createAuthHeader("GET", path);
101 const url = `${getApiBase()}${path}`;
102 const response = await fetch(url, {
103 headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
104 });
105
106 if (!response.ok) {
107 throw new Error(`Failed to load logs: ${response.statusText}`);
108 }
109
110 const data = await response.json();
111 if (refresh) {
112 logs = data.logs || [];
113 } else {
114 logs = [...logs, ...(data.logs || [])];
115 }
116 totalLogs = data.total || 0;
117 hasMore = data.has_more || false;
118 offset = logs.length;
119 } catch (err) {
120 console.error("Error loading logs:", err);
121 error = err.message || "Failed to load logs";
122 } finally {
123 isLoading = false;
124 }
125 }
126
127 function loadMoreLogs() {
128 if (hasMore && !isLoading) {
129 loadLogs(false);
130 }
131 }
132
133 async function loadLogLevel() {
134 try {
135 const response = await fetch(`${getApiBase()}/api/logs/level`);
136 if (response.ok) {
137 const data = await response.json();
138 currentLogLevel = data.level || "info";
139 selectedLevel = currentLogLevel;
140 }
141 } catch (err) {
142 console.error("Error loading log level:", err);
143 }
144 }
145
146 async function setLogLevel() {
147 if (selectedLevel === currentLogLevel) return;
148
149 try {
150 const authHeader = await createAuthHeader("POST", "/api/logs/level");
151 const response = await fetch(`${getApiBase()}/api/logs/level`, {
152 method: "POST",
153 headers: {
154 "Content-Type": "application/json",
155 ...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}),
156 },
157 body: JSON.stringify({ level: selectedLevel }),
158 });
159
160 if (!response.ok) {
161 throw new Error(`Failed to set log level: ${response.statusText}`);
162 }
163
164 const data = await response.json();
165 currentLogLevel = data.level;
166 selectedLevel = currentLogLevel;
167 } catch (err) {
168 console.error("Error setting log level:", err);
169 error = err.message || "Failed to set log level";
170 selectedLevel = currentLogLevel;
171 }
172 }
173
174 async function clearLogs() {
175 if (!confirm("Are you sure you want to clear all logs?")) return;
176
177 try {
178 const authHeader = await createAuthHeader("POST", "/api/logs/clear");
179 const response = await fetch(`${getApiBase()}/api/logs/clear`, {
180 method: "POST",
181 headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
182 });
183
184 if (!response.ok) {
185 throw new Error(`Failed to clear logs: ${response.statusText}`);
186 }
187
188 logs = [];
189 offset = 0;
190 hasMore = false;
191 totalLogs = 0;
192 } catch (err) {
193 console.error("Error clearing logs:", err);
194 error = err.message || "Failed to clear logs";
195 }
196 }
197
198 function formatTimestamp(timestamp) {
199 if (!timestamp) return "";
200 const date = new Date(timestamp);
201 return date.toLocaleString();
202 }
203
204 function getLevelClass(level) {
205 switch (level?.toUpperCase()) {
206 case "TRC":
207 case "TRACE":
208 return "level-trace";
209 case "DBG":
210 case "DEBUG":
211 return "level-debug";
212 case "INF":
213 case "INFO":
214 return "level-info";
215 case "WRN":
216 case "WARN":
217 return "level-warn";
218 case "ERR":
219 case "ERROR":
220 return "level-error";
221 case "FTL":
222 case "FATAL":
223 return "level-fatal";
224 default:
225 return "level-info";
226 }
227 }
228
229 function openLoginModal() {
230 dispatch("openLoginModal");
231 }
232 </script>
233
234 {#if canAccess}
235 <div class="log-view">
236 <div class="header-section">
237 <h3>Logs</h3>
238 <div class="header-controls">
239 <div class="level-selector">
240 <label for="log-level">Level:</label>
241 <select
242 id="log-level"
243 bind:value={selectedLevel}
244 on:change={setLogLevel}
245 >
246 {#each LOG_LEVELS as level}
247 <option value={level}>{level}</option>
248 {/each}
249 </select>
250 </div>
251 <button class="clear-btn" on:click={clearLogs} disabled={isLoading || logs.length === 0}>
252 Clear
253 </button>
254 <button class="refresh-btn" on:click={() => loadLogs(true)} disabled={isLoading}>
255 🔄 {isLoading ? "Loading..." : "Refresh"}
256 </button>
257 </div>
258 </div>
259
260 {#if error}
261 <div class="error-message">{error}</div>
262 {/if}
263
264 <div class="log-info">
265 Showing {logs.length} of {totalLogs} logs (Level: {currentLogLevel})
266 </div>
267
268 <div class="log-list" bind:this={scrollContainer}>
269 {#if logs.length === 0 && !isLoading}
270 <div class="empty-state">
271 <p>No logs available.</p>
272 </div>
273 {:else}
274 {#each logs as log}
275 <div class="log-entry">
276 <span class="log-timestamp">{formatTimestamp(log.timestamp)}</span>
277 <span class="log-level {getLevelClass(log.level)}">{log.level}</span>
278 {#if log.file}
279 <span class="log-location">{log.file}:{log.line}</span>
280 {/if}
281 <span class="log-message">{log.message}</span>
282 </div>
283 {/each}
284 <div bind:this={loadMoreTrigger} class="load-more-trigger">
285 {#if isLoading}
286 <span>Loading more...</span>
287 {:else if hasMore}
288 <span>Scroll for more</span>
289 {:else}
290 <span>End of logs</span>
291 {/if}
292 </div>
293 {/if}
294 </div>
295 </div>
296 {:else}
297 <div class="login-prompt">
298 <p>Log viewer is only available to relay owners.</p>
299 {#if !isLoggedIn}
300 <button class="login-btn" on:click={openLoginModal}>Log In</button>
301 {:else}
302 <p class="access-denied">Your role ({userRole}) does not have access to this feature.</p>
303 {/if}
304 </div>
305 {/if}
306
307 <style>
308 .log-view {
309 padding: 1em;
310 box-sizing: border-box;
311 width: 100%;
312 }
313
314 .header-section {
315 display: flex;
316 justify-content: space-between;
317 align-items: center;
318 margin-bottom: 1em;
319 flex-wrap: wrap;
320 gap: 0.5em;
321 }
322
323 .header-section h3 {
324 margin: 0;
325 color: var(--text-color);
326 }
327
328 .header-controls {
329 display: flex;
330 align-items: center;
331 gap: 0.75em;
332 flex-wrap: wrap;
333 }
334
335 .level-selector {
336 display: flex;
337 align-items: center;
338 gap: 0.5em;
339 }
340
341 .level-selector label {
342 color: var(--text-color);
343 font-size: 0.9em;
344 }
345
346 .level-selector select {
347 padding: 0.4em 0.6em;
348 border: 1px solid var(--border-color);
349 border-radius: 4px;
350 background-color: var(--card-bg);
351 color: var(--text-color);
352 font-size: 0.9em;
353 }
354
355 .clear-btn {
356 background-color: transparent;
357 border: 1px solid var(--warning);
358 color: var(--warning);
359 padding: 0.5em 1em;
360 border-radius: 4px;
361 cursor: pointer;
362 font-size: 0.9em;
363 }
364
365 .clear-btn:hover:not(:disabled) {
366 background-color: var(--warning);
367 color: var(--text-color);
368 }
369
370 .clear-btn:disabled {
371 opacity: 0.5;
372 cursor: not-allowed;
373 }
374
375 .refresh-btn {
376 background-color: var(--primary);
377 color: var(--text-color);
378 border: none;
379 padding: 0.5em 1em;
380 border-radius: 4px;
381 cursor: pointer;
382 font-size: 0.9em;
383 }
384
385 .refresh-btn:hover:not(:disabled) {
386 background-color: var(--accent-hover-color);
387 }
388
389 .refresh-btn:disabled {
390 opacity: 0.6;
391 cursor: not-allowed;
392 }
393
394 .error-message {
395 background-color: var(--warning);
396 color: var(--text-color);
397 padding: 0.75em 1em;
398 border-radius: 4px;
399 margin-bottom: 1em;
400 }
401
402 .log-info {
403 font-size: 0.85em;
404 color: var(--text-color);
405 opacity: 0.7;
406 margin-bottom: 0.75em;
407 }
408
409 .log-list {
410 display: flex;
411 flex-direction: column;
412 gap: 0.25em;
413 width: 100%;
414 }
415
416 .log-entry {
417 display: flex;
418 align-items: flex-start;
419 gap: 0.75em;
420 padding: 0.5em 0.75em;
421 background-color: var(--card-bg);
422 border-radius: 4px;
423 font-family: monospace;
424 font-size: 0.85em;
425 word-break: break-word;
426 }
427
428 .log-timestamp {
429 color: var(--text-color);
430 opacity: 0.6;
431 white-space: nowrap;
432 flex-shrink: 0;
433 }
434
435 .log-level {
436 font-weight: bold;
437 padding: 0.1em 0.4em;
438 border-radius: 3px;
439 text-transform: uppercase;
440 flex-shrink: 0;
441 min-width: 3.5em;
442 text-align: center;
443 }
444
445 .level-trace {
446 background-color: #6c757d;
447 color: white;
448 }
449
450 .level-debug {
451 background-color: #17a2b8;
452 color: white;
453 }
454
455 .level-info {
456 background-color: var(--success);
457 color: white;
458 }
459
460 .level-warn {
461 background-color: #ffc107;
462 color: #212529;
463 }
464
465 .level-error {
466 background-color: #dc3545;
467 color: white;
468 }
469
470 .level-fatal {
471 background-color: #721c24;
472 color: white;
473 }
474
475 .log-location {
476 color: var(--text-color);
477 opacity: 0.5;
478 flex-shrink: 0;
479 }
480
481 .log-message {
482 color: var(--text-color);
483 flex: 1;
484 }
485
486 .load-more-trigger {
487 padding: 1em;
488 text-align: center;
489 color: var(--text-color);
490 opacity: 0.6;
491 font-size: 0.9em;
492 }
493
494 .empty-state {
495 text-align: center;
496 padding: 2em;
497 color: var(--text-color);
498 opacity: 0.7;
499 }
500
501 .login-prompt {
502 text-align: center;
503 padding: 2em;
504 background-color: var(--card-bg);
505 border-radius: 8px;
506 border: 1px solid var(--border-color);
507 max-width: 32em;
508 margin: 1em;
509 }
510
511 .login-prompt p {
512 margin: 0 0 1.5rem 0;
513 color: var(--text-color);
514 font-size: 1.1rem;
515 }
516
517 .login-btn {
518 background-color: var(--primary);
519 color: var(--text-color);
520 border: none;
521 padding: 0.75em 1.5em;
522 border-radius: 4px;
523 cursor: pointer;
524 font-weight: bold;
525 font-size: 0.9em;
526 }
527
528 .login-btn:hover {
529 background-color: var(--accent-hover-color);
530 }
531
532 .access-denied {
533 font-size: 0.9em;
534 opacity: 0.7;
535 }
536
537 @media (max-width: 600px) {
538 .header-section {
539 flex-direction: column;
540 align-items: flex-start;
541 }
542
543 .header-controls {
544 width: 100%;
545 justify-content: flex-end;
546 }
547
548 .log-entry {
549 flex-wrap: wrap;
550 }
551
552 .log-timestamp {
553 width: 100%;
554 margin-bottom: 0.25em;
555 }
556 }
557 </style>
558