diff --git a/_reference/localEmailViewer/index.js b/_reference/localEmailViewer/index.js index 2022198c7..88e28f8b4 100644 --- a/_reference/localEmailViewer/index.js +++ b/_reference/localEmailViewer/index.js @@ -689,6 +689,8 @@ function renderHtml() {
+ +
@@ -721,11 +723,11 @@ function renderStyles() { return ` :root{--panel:rgba(255,255,255,.82);--panel-strong:#fff;--card-shell:linear-gradient(180deg,rgba(255,246,236,.98),rgba(255,252,247,.99));--card-body:#fffdf9;--log-shell:linear-gradient(180deg,rgba(239,246,255,.98),rgba(248,251,255,.99));--log-body:#f8fbff;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--card-line:rgba(207,109,60,.24);--log-line:rgba(48,113,169,.22);--accent:#cf6d3c;--accent-soft:rgba(207,109,60,.1);--info:#3071a9;--info-soft:rgba(48,113,169,.1);--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 12px 28px rgba(35,43,53,.08);--card-shadow:0 18px 34px rgba(122,78,34,.12);--log-shadow:0 16px 32px rgba(48,113,169,.12);} *{box-sizing:border-box} - html,body{margin:0;min-height:100%} + html,body{margin:0;height:100%;overflow:hidden} body{color-scheme:light;background:radial-gradient(circle at top left,rgba(207,109,60,.18),transparent 28%),radial-gradient(circle at top right,rgba(31,143,101,.12),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:15px/1.45 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif;transition:background-color .18s ease,color .18s ease} button,input,select,textarea{font:inherit} button{cursor:pointer} - .page{display:grid;gap:10px;max-width:1360px;align-content:start;margin:0 auto;padding:14px} + .page{display:grid;grid-template-rows:auto minmax(0,1fr);gap:10px;max-width:1360px;height:100vh;height:100dvh;margin:0 auto;padding:14px;overflow:hidden} .hero{display:block;margin-bottom:0} .heroShell,.toolControls,.stat{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--line);box-shadow:var(--shadow)} .card{background:var(--card-shell);border:1px solid var(--card-line);box-shadow:var(--card-shadow)} @@ -743,10 +745,10 @@ function renderStyles() { .workspaceTab{min-height:32px;padding:0 12px;background:transparent;color:var(--muted);font-weight:700} .workspaceTab.active{background:#fff;border-color:rgba(31,41,51,.08);color:var(--ink)} .themeToggle{white-space:nowrap} - .workspacePanel{display:grid;gap:6px} + .workspacePanel{display:grid;grid-template-rows:auto auto minmax(0,1fr);gap:6px;min-height:0} .workspacePanel[hidden]{display:none} .toolControls{display:grid;gap:8px} - .contentPane{height:clamp(360px,50vh,720px);overflow:auto;scroll-behavior:smooth;padding-right:4px} + .contentPane{height:100%;min-height:0;overflow:auto;scroll-behavior:smooth;padding-right:4px} .contentStack{display:grid;gap:8px;min-width:100%;padding-bottom:18px} .paneTopWrap{display:flex;justify-content:flex-end;position:sticky;bottom:14px;pointer-events:none;padding-right:10px} .paneTopButton{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;border-radius:999px;border:1px solid rgba(255,255,255,.32);background:rgba(31,41,51,.42);color:#fff;font-size:1.1rem;line-height:1;backdrop-filter:blur(12px);box-shadow:0 10px 24px rgba(31,41,51,.18);opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none;transition:opacity .16s ease,transform .16s ease,background-color .12s ease;z-index:6} @@ -810,12 +812,17 @@ function renderStyles() { .logSummary::-webkit-details-marker{display:none} .logSummaryTop{display:flex;flex-wrap:wrap;gap:8px;justify-content:space-between;align-items:center} .logMeta{display:flex;flex-wrap:wrap;gap:8px;align-items:center} + .logSummaryActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end} .logTag{background:var(--info-soft);color:var(--info);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .logPreview{margin:0;color:#324150;font:600 .88rem/1.45 "Cascadia Code","Consolas",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .logBody{padding:8px 12px 12px;border-top:1px solid rgba(48,113,169,.14);background:var(--log-body)} - .logCodeWrap{position:relative} - .logCopyButton{position:absolute;top:10px;right:10px;z-index:1;backdrop-filter:blur(12px);box-shadow:0 8px 18px rgba(31,41,51,.16)} - .logBody pre{border-radius:12px;border:1px solid rgba(48,113,169,.14);padding:42px 12px 12px;background:linear-gradient(180deg,rgba(48,113,169,.04),transparent 140px),#fff} + .logCopyButton{box-shadow:none} + .logBody pre{border-radius:12px;border:1px solid rgba(48,113,169,.14);padding:12px;background:linear-gradient(180deg,rgba(48,113,169,.04),transparent 140px),#fff} + .jsonSyntax .jsonKey{color:#b55f2d} + .jsonSyntax .jsonString{color:#1f8f65} + .jsonSyntax .jsonNumber{color:#2f6ea9} + .jsonSyntax .jsonBoolean{color:#9d5f00} + .jsonSyntax .jsonNull{color:#b33a3a} iframe{width:100%;min-height:560px;border:none;background:#fff} pre{margin:0;padding:12px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:12.5px/1.45 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff} .placeholder,.inlineError{padding:12px} @@ -865,6 +872,11 @@ function renderStyles() { body[data-theme="dark"] .head h2, body[data-theme="dark"] .stat strong, body[data-theme="dark"] h1{color:#edf2f7} + body[data-theme="dark"] .jsonSyntax .jsonKey{color:#f0b08a} + body[data-theme="dark"] .jsonSyntax .jsonString{color:#80d5b0} + body[data-theme="dark"] .jsonSyntax .jsonNumber{color:#94c9ff} + body[data-theme="dark"] .jsonSyntax .jsonBoolean{color:#f0c274} + body[data-theme="dark"] .jsonSyntax .jsonNull{color:#ff9c9c} body[data-theme="dark"] .meta, body[data-theme="dark"] .helper, body[data-theme="dark"] .lede, @@ -876,7 +888,7 @@ function renderStyles() { body[data-theme="dark"] .paneTopButton{border-color:rgba(255,255,255,.18);background:rgba(8,12,18,.58);color:#edf2f7} body[data-theme="dark"] .paneTopButton.visible:hover{background:rgba(8,12,18,.8)} @media (max-width:1080px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}} - @media (max-width:720px){.page{padding:12px}.heroShell,.heroTopRow,.toolbar,.row{align-items:stretch}.stats{grid-template-columns:1fr}.heroTopRow{justify-content:stretch;flex-basis:100%}.workspaceTab,.primary,.ghost,.chip,.themeToggle{width:100%;justify-content:center}.logSummaryTop{align-items:flex-start}.logCopyButton{top:8px;right:8px}.contentPane{height:clamp(300px,48vh,560px)}iframe{min-height:420px}} + @media (max-width:720px){.page{padding:12px}.heroShell,.heroTopRow,.toolbar,.row{align-items:stretch}.stats{grid-template-columns:1fr}.heroTopRow{justify-content:stretch;flex-basis:100%}.workspaceTab,.primary,.ghost,.chip,.themeToggle{width:100%;justify-content:center}.logSummaryTop,.logSummaryActions{align-items:flex-start}.contentPane{min-height:300px}iframe{min-height:420px}} `; } @@ -890,19 +902,28 @@ function escapeHtml(value) { } function clientApp(config) { + const THEME_STORAGE_KEY = "localstack-inspector-theme"; + const PANEL_STORAGE_KEY = "localstack-inspector-panel"; + const EMAIL_PREFERENCES_STORAGE_KEY = "localstack-inspector-email-preferences"; + const LOG_PREFERENCES_STORAGE_KEY = "localstack-inspector-log-preferences"; + const REFRESH_INTERVALS = [5000, 10000, 15000, 30000, 60000]; + const LOG_WINDOWS = [300000, 900000, 3600000, 21600000, 86400000]; + const LOG_LIMITS = [100, 200, 300, 500]; + const storedEmailPreferences = getStoredPreferences(EMAIL_PREFERENCES_STORAGE_KEY); + const storedLogPreferences = getStoredPreferences(LOG_PREFERENCES_STORAGE_KEY); + const appState = { - panel: "emails", + panel: getInitialPanel(), logsReady: false, theme: getInitialTheme() }; - const THEME_STORAGE_KEY = "localstack-inspector-theme"; const state = { messages: [], filtered: [], - search: "", - auto: true, - interval: config.defaultRefreshMs, + search: getStoredText(storedEmailPreferences?.search), + auto: getStoredBoolean(storedEmailPreferences?.auto, true), + interval: getStoredNumber(storedEmailPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), loading: false, error: "", updatedAt: 0, @@ -923,13 +944,13 @@ function clientApp(config) { streams: [], events: [], filtered: [], - group: config.defaultLogGroup || "", - stream: "", - search: "", - auto: true, - interval: config.defaultRefreshMs, - windowMs: config.defaultLogWindowMs, - limit: config.defaultLogLimit, + group: getStoredText(storedLogPreferences?.group, config.defaultLogGroup || ""), + stream: getStoredText(storedLogPreferences?.stream), + search: getStoredText(storedLogPreferences?.search), + auto: getStoredBoolean(storedLogPreferences?.auto, true), + interval: getStoredNumber(storedLogPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), + windowMs: getStoredNumber(storedLogPreferences?.windowMs, LOG_WINDOWS, config.defaultLogWindowMs), + limit: getStoredNumber(storedLogPreferences?.limit, LOG_LIMITS, config.defaultLogLimit), loading: false, error: "", updatedAt: 0, @@ -938,7 +959,6 @@ function clientApp(config) { newest: 0, searchedLogStreams: 0, openIds: new Set(), - hasInteracted: false, listSignature: "" }; @@ -976,6 +996,8 @@ function clientApp(config) { logsLimitSelect: document.getElementById("logsLimitSelect"), logsSearchInput: document.getElementById("logsSearchInput"), logsClearSearchButton: document.getElementById("logsClearSearchButton"), + logsExpandAllButton: document.getElementById("logsExpandAllButton"), + logsCollapseAllButton: document.getElementById("logsCollapseAllButton"), logsScrollToTopButton: document.getElementById("logsScrollToTopButton"), logsStatusChip: document.getElementById("logsStatusChip"), logsTotalStat: document.getElementById("logsTotalStat"), @@ -991,16 +1013,27 @@ function clientApp(config) { logsContentPane: document.getElementById("logsContentPane") }; - el.intervalSelect.value = String(config.defaultRefreshMs); - el.logsIntervalSelect.value = String(config.defaultRefreshMs); - el.logsWindowSelect.value = String(config.defaultLogWindowMs); - el.logsLimitSelect.value = String(config.defaultLogLimit); + el.autoToggle.checked = state.auto; + el.intervalSelect.value = String(state.interval); + el.searchInput.value = state.search; + el.logsAutoToggle.checked = logsState.auto; + el.logsIntervalSelect.value = String(logsState.interval); + el.logsWindowSelect.value = String(logsState.windowMs); + el.logsLimitSelect.value = String(logsState.limit); + el.logsSearchInput.value = logsState.search; applyTheme(appState.theme); + persistPanel(); + persistEmailPreferences(); + persistLogPreferences(); wire(); renderWorkspace(); renderAll(); renderLogsAll(); - refreshMessages("initial"); + if (appState.panel === "logs") { + ensureLogsReady(); + } else { + refreshMessages("initial"); + } window.setInterval(() => { renderLiveClock(); renderLogsLiveClock(); @@ -1025,12 +1058,14 @@ function clientApp(config) { el.autoToggle.addEventListener("change", () => { state.auto = el.autoToggle.checked; + persistEmailPreferences(); scheduleRefresh(); renderStatus(); }); el.intervalSelect.addEventListener("change", () => { state.interval = Number(el.intervalSelect.value) || config.defaultRefreshMs; + persistEmailPreferences(); scheduleRefresh(); renderStatus(); }); @@ -1104,23 +1139,27 @@ function clientApp(config) { el.logsAutoToggle.addEventListener("change", () => { logsState.auto = el.logsAutoToggle.checked; + persistLogPreferences(); scheduleLogsRefresh(); renderLogsStatus(); }); el.logsIntervalSelect.addEventListener("change", () => { logsState.interval = Number(el.logsIntervalSelect.value) || config.defaultRefreshMs; + persistLogPreferences(); scheduleLogsRefresh(); renderLogsStatus(); }); el.logsWindowSelect.addEventListener("change", () => { logsState.windowMs = Number(el.logsWindowSelect.value) || config.defaultLogWindowMs; + persistLogPreferences(); refreshLogs("window"); }); el.logsLimitSelect.addEventListener("change", () => { logsState.limit = Number(el.logsLimitSelect.value) || config.defaultLogLimit; + persistLogPreferences(); refreshLogs("limit"); }); @@ -1135,6 +1174,16 @@ function clientApp(config) { applyLogsFilter(); }); + el.logsExpandAllButton.addEventListener("click", () => { + logsState.filtered.forEach((event) => logsState.openIds.add(event.id)); + renderLogsList(); + }); + + el.logsCollapseAllButton.addEventListener("click", () => { + logsState.openIds.clear(); + renderLogsList(); + }); + el.logsScrollToTopButton.addEventListener("click", () => { scrollPaneToTop(el.logsContentPane); }); @@ -1146,12 +1195,14 @@ function clientApp(config) { el.logsGroupSelect.addEventListener("change", async () => { logsState.group = el.logsGroupSelect.value; logsState.stream = ""; + persistLogPreferences(); await refreshLogStreams(); await refreshLogs("group"); }); el.logsStreamSelect.addEventListener("change", () => { logsState.stream = el.logsStreamSelect.value; + persistLogPreferences(); refreshLogs("stream"); }); @@ -1162,6 +1213,9 @@ function clientApp(config) { return; } + event.preventDefault(); + event.stopPropagation(); + const logEvent = getLogEvent(button.dataset.id); if (!logEvent) { @@ -1228,10 +1282,13 @@ function clientApp(config) { } appState.panel = panel; + persistPanel(); renderWorkspace(); if (panel === "logs") { await ensureLogsReady(); + } else if (!state.updatedAt && !state.loading) { + await refreshMessages("panel"); } scheduleRefresh(); @@ -1341,6 +1398,7 @@ function clientApp(config) { } catch (error) { logsState.error = error.message || "Unknown log group refresh error"; } finally { + persistLogPreferences(); renderLogsAll(); } } @@ -1376,6 +1434,7 @@ function clientApp(config) { logsState.stream = ""; logsState.error = error.message || "Unknown log stream refresh error"; } finally { + persistLogPreferences(); renderLogsAll(); } } @@ -1451,6 +1510,7 @@ function clientApp(config) { logsState.filtered = !search ? [...logsState.events] : logsState.events.filter((event) => logHaystack(event).includes(search)); + persistLogPreferences(); renderLogsAll({ renderList: shouldRenderList }); } @@ -1482,6 +1542,7 @@ function clientApp(config) { state.filtered = !search ? [...state.messages] : state.messages.filter((message) => haystack(message).includes(search)); + persistEmailPreferences(); renderAll({ renderList: shouldRenderList }); } @@ -1791,31 +1852,29 @@ function clientApp(config) { } el.logsEmpty.hidden = true; - el.logsList.innerHTML = logsState.filtered.map((event, index) => renderLogEvent(event, index)).join(""); + el.logsList.innerHTML = logsState.filtered.map((event) => renderLogEvent(event)).join(""); bindLogToggles(); updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); } - function renderLogEvent(event, index) { - const open = logsState.openIds.has(event.id) || (!logsState.hasInteracted && index === 0) ? "open" : ""; - + function renderLogEvent(event) { return ` -
+
${escapeHtml(formatDateTime(event.timestamp))} ${escapeHtml(event.logStreamName || "Unknown stream")}
- ${escapeHtml(formatRelative(event.timestamp || Date.now()))} +
+ + ${escapeHtml(formatRelative(event.timestamp || Date.now()))} +
-

${escapeHtml(event.preview || "No preview available.")}

+

${renderLogPreviewContent(event)}

-
- -
${escapeHtml(formatLogMessage(event.message))}
-
+
${renderLogBodyContent(event.message)}
`; @@ -1830,8 +1889,6 @@ function clientApp(config) { return; } - logsState.hasInteracted = true; - if (details.open) { logsState.openIds.add(id); } else { @@ -2065,14 +2122,17 @@ function clientApp(config) { el.logsStatusChip.textContent = message; } - function getInitialTheme() { - try { - const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY); + function getInitialPanel() { + const storedPanel = readStoredValue(PANEL_STORAGE_KEY); + return storedPanel === "logs" ? "logs" : "emails"; + } - if (storedTheme === "dark" || storedTheme === "light") { - return storedTheme; - } - } catch {} + function getInitialTheme() { + const storedTheme = readStoredValue(THEME_STORAGE_KEY); + + if (storedTheme === "dark" || storedTheme === "light") { + return storedTheme; + } return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light"; } @@ -2101,6 +2161,78 @@ function clientApp(config) { el.themeToggle.title = isDark ? "Switch to light theme" : "Switch to dark theme"; } + function persistPanel() { + writeStoredValue(PANEL_STORAGE_KEY, appState.panel); + } + + function persistEmailPreferences() { + writeStoredJson(EMAIL_PREFERENCES_STORAGE_KEY, { + search: state.search, + auto: state.auto, + interval: state.interval + }); + } + + function persistLogPreferences() { + writeStoredJson(LOG_PREFERENCES_STORAGE_KEY, { + group: logsState.group, + stream: logsState.stream, + search: logsState.search, + auto: logsState.auto, + interval: logsState.interval, + windowMs: logsState.windowMs, + limit: logsState.limit + }); + } + + function getStoredPreferences(key) { + try { + const rawValue = window.localStorage.getItem(key); + + if (!rawValue) { + return null; + } + + const parsedValue = JSON.parse(rawValue); + return parsedValue && typeof parsedValue === "object" && !Array.isArray(parsedValue) ? parsedValue : null; + } catch { + return null; + } + } + + function readStoredValue(key) { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } + } + + function writeStoredValue(key, value) { + try { + window.localStorage.setItem(key, String(value)); + } catch {} + } + + function writeStoredJson(key, value) { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch {} + } + + function getStoredText(value, fallback = "") { + return typeof value === "string" ? value : fallback; + } + + function getStoredBoolean(value, fallback) { + return typeof value === "boolean" ? value : fallback; + } + + function getStoredNumber(value, allowedValues, fallback) { + const normalizedValue = Number(value); + return Number.isFinite(normalizedValue) && allowedValues.includes(normalizedValue) ? normalizedValue : fallback; + } + function scrollPaneToTop(element) { if (!element) { return; @@ -2181,6 +2313,74 @@ function clientApp(config) { } } + function renderLogPreviewContent(event) { + const parsedLog = tryParseLogJson(event?.message); + + if (!parsedLog.ok) { + return escapeHtml(event?.preview || "No preview available."); + } + + const compactJson = JSON.stringify(parsedLog.value); + const previewText = compactJson.length > 220 ? `${compactJson.slice(0, 217)}...` : compactJson; + return highlightJsonText(previewText); + } + + function renderLogBodyContent(message) { + const parsedLog = tryParseLogJson(message); + + if (!parsedLog.ok) { + return escapeHtml(formatLogMessage(message)); + } + + return highlightJsonText(JSON.stringify(parsedLog.value, null, 2)); + } + + function tryParseLogJson(message) { + const value = String(message || "").trim(); + + if (!value) { + return { ok: false, value: null }; + } + + try { + return { ok: true, value: JSON.parse(value) }; + } catch { + return { ok: false, value: null }; + } + } + + function highlightJsonText(value) { + const source = String(value ?? ""); + const tokenRegex = + /("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?)/g; + let html = ""; + let lastIndex = 0; + + for (const match of source.matchAll(tokenRegex)) { + const [token] = match; + const index = match.index ?? 0; + let className = "jsonNumber"; + + html += escapeHtml(source.slice(lastIndex, index)); + + if (token.endsWith(":")) { + className = "jsonKey"; + } else if (token === "true" || token === "false") { + className = "jsonBoolean"; + } else if (token === "null") { + className = "jsonNull"; + } else if (token.startsWith('"')) { + className = "jsonString"; + } + + html += `${escapeHtml(token)}`; + lastIndex = index + token.length; + } + + html += escapeHtml(source.slice(lastIndex)); + return html; + } + function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&")