feature/IO-3587-Commision-Cut - Improved localstack client

This commit is contained in:
Dave
2026-03-19 17:03:17 -04:00
parent debc67cc49
commit f0f5c09fd7

View File

@@ -689,6 +689,8 @@ function renderHtml() {
<div class="row">
<input id="logsSearchInput" class="search" type="search" placeholder="Search stream name or log content..." autocomplete="off">
<button id="logsClearSearchButton" class="ghost" type="button">Clear</button>
<button id="logsExpandAllButton" class="ghost" type="button">Open all</button>
<button id="logsCollapseAllButton" class="ghost" type="button">Close all</button>
</div>
</section>
@@ -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();
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 `
<details class="logEvent" data-id="${escapeHtml(event.id)}" ${open}>
<details class="logEvent" data-id="${escapeHtml(event.id)}" ${logsState.openIds.has(event.id) ? "open" : ""}>
<summary class="logSummary">
<div class="logSummaryTop">
<div class="logMeta">
<span class="time">${escapeHtml(formatDateTime(event.timestamp))}</span>
<span class="tag logTag" title="${escapeHtml(event.logStreamName || "Unknown stream")}">${escapeHtml(event.logStreamName || "Unknown stream")}</span>
</div>
<div class="logSummaryActions">
<button class="mini logCopyButton" type="button" data-log-action="copy" data-id="${escapeHtml(event.id)}">Copy JSON</button>
<span class="tag">${escapeHtml(formatRelative(event.timestamp || Date.now()))}</span>
</div>
<p class="logPreview">${escapeHtml(event.preview || "No preview available.")}</p>
</div>
<p class="logPreview jsonSyntax">${renderLogPreviewContent(event)}</p>
</summary>
<div class="logBody">
<div class="logCodeWrap">
<button class="mini logCopyButton" type="button" data-log-action="copy" data-id="${escapeHtml(event.id)}">Copy JSON</button>
<pre>${escapeHtml(formatLogMessage(event.message))}</pre>
</div>
<pre class="jsonSyntax">${renderLogBodyContent(event.message)}</pre>
</div>
</details>
`;
@@ -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 getInitialPanel() {
const storedPanel = readStoredValue(PANEL_STORAGE_KEY);
return storedPanel === "logs" ? "logs" : "emails";
}
function getInitialTheme() {
try {
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
const storedTheme = readStoredValue(THEME_STORAGE_KEY);
if (storedTheme === "dark" || storedTheme === "light") {
return storedTheme;
}
} catch {}
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 += `<span class="${className}">${escapeHtml(token)}</span>`;
lastIndex = index + token.length;
}
html += escapeHtml(source.slice(lastIndex));
return html;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")