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