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

This commit is contained in:
Dave
2026-03-19 16:46:25 -04:00
parent 281fe02820
commit debc67cc49

View File

@@ -587,17 +587,18 @@ function renderHtml() {
<body> <body>
<div class="page"> <div class="page">
<header class="hero"> <header class="hero">
<div class="hero-copy"> <div class="heroShell">
<p class="eyebrow">LocalStack Toolbox</p> <div class="heroIdentity">
<h1>Inspector</h1> <p class="eyebrow">LocalStack Toolbox</p>
<p class="lede">Inspect generated SES mail and CloudWatch logs from the same local stack console.</p> <h1>Inspector</h1>
</div> </div>
<div class="hero-controls"> <div class="heroTopRow">
<div id="workspaceTabs" class="workspaceTabs" role="tablist" aria-label="Inspector views"> <div id="workspaceTabs" class="workspaceTabs" role="tablist" aria-label="Inspector views">
<button class="workspaceTab active" type="button" data-panel="emails" aria-pressed="true">SES Emails</button> <button class="workspaceTab active" type="button" data-panel="emails" aria-pressed="true">SES Emails</button>
<button class="workspaceTab" type="button" data-panel="logs" aria-pressed="false">CloudWatch Logs</button> <button class="workspaceTab" type="button" data-panel="logs" aria-pressed="false">CloudWatch Logs</button>
</div>
<button id="themeToggle" class="ghost themeToggle" type="button" aria-pressed="false">Theme: Light</button>
</div> </div>
<p class="helper">Keep outbound email inspection and CloudWatch tailing in one local viewer without leaving the browser.</p>
</div> </div>
</header> </header>
@@ -632,11 +633,14 @@ function renderHtml() {
<article class="stat"><span>Fetch</span><strong id="fetchStat" class="small">Idle</strong><small id="fetchDetail">Endpoint: ${escapeHtml(SES_ENDPOINT)}</small></article> <article class="stat"><span>Fetch</span><strong id="fetchStat" class="small">Idle</strong><small id="fetchDetail">Endpoint: ${escapeHtml(SES_ENDPOINT)}</small></article>
</section> </section>
<div class="contentPane"> <div id="emailsContentPane" class="contentPane">
<div class="contentStack"> <div class="contentStack">
<div id="banner" class="banner" hidden></div> <div id="banner" class="banner" hidden></div>
<div id="empty" class="empty" hidden></div> <div id="empty" class="empty" hidden></div>
<section id="list" class="list" aria-live="polite"></section> <section id="list" class="list" aria-live="polite"></section>
<div class="paneTopWrap">
<button id="scrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">&#8593;</button>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -695,11 +699,14 @@ function renderHtml() {
<article class="stat"><span>Fetch</span><strong id="logsFetchStat" class="small">Idle</strong><small id="logsFetchDetail">Endpoint: ${escapeHtml(CLOUDWATCH_ENDPOINT)} (${escapeHtml(CLOUDWATCH_REGION)})</small></article> <article class="stat"><span>Fetch</span><strong id="logsFetchStat" class="small">Idle</strong><small id="logsFetchDetail">Endpoint: ${escapeHtml(CLOUDWATCH_ENDPOINT)} (${escapeHtml(CLOUDWATCH_REGION)})</small></article>
</section> </section>
<div class="contentPane"> <div id="logsContentPane" class="contentPane">
<div class="contentStack"> <div class="contentStack">
<div id="logsBanner" class="banner" hidden></div> <div id="logsBanner" class="banner" hidden></div>
<div id="logsEmpty" class="empty" hidden></div> <div id="logsEmpty" class="empty" hidden></div>
<section id="logsList" class="logList" aria-live="polite"></section> <section id="logsList" class="logList" aria-live="polite"></section>
<div class="paneTopWrap">
<button id="logsScrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">&#8593;</button>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -715,57 +722,65 @@ function renderStyles() {
: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);} :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} *{box-sizing:border-box}
html,body{margin:0;min-height:100%} html,body{margin:0;min-height:100%}
body{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} 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,input,select,textarea{font:inherit}
button{cursor:pointer} button{cursor:pointer}
.page{display:grid;gap:14px;max-width:1360px;min-height:100vh;margin:0 auto;padding:18px} .page{display:grid;gap:10px;max-width:1360px;align-content:start;margin:0 auto;padding:14px}
.hero{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(320px,.95fr);gap:14px;margin-bottom:0} .hero{display:block;margin-bottom:0}
.hero-copy,.hero-controls,.toolControls,.stat{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--line);box-shadow:var(--shadow)} .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)} .card{background:var(--card-shell);border:1px solid var(--card-line);box-shadow:var(--card-shadow)}
.hero-copy,.hero-controls,.toolControls{border-radius:18px;padding:18px} .heroShell,.toolControls{border-radius:18px}
.eyebrow{margin:0 0 6px;color:var(--accent);font-size:.76rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase} .heroShell{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px}
h1{margin:0;font-size:clamp(1.9rem,4vw,3.1rem);line-height:.98;letter-spacing:-.05em} .toolControls{padding:12px}
.lede{margin:10px 0 0;max-width:54ch;color:var(--muted);font-size:.96rem} .heroIdentity{display:grid;gap:3px;min-width:0}
.hero-controls{display:grid;gap:10px} .eyebrow{margin:0 0 4px;color:var(--accent);font-size:.72rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase}
.helper{margin:0;color:var(--muted);font-size:.95rem} h1{margin:0;font-size:clamp(1.8rem,3.6vw,2.85rem);line-height:.96;letter-spacing:-.05em}
.workspaceTabs{display:flex;flex-wrap:wrap;gap:8px;padding:6px;border-radius:999px;background:rgba(31,41,51,.05)} .lede{margin:8px 0 0;max-width:54ch;color:var(--muted);font-size:.92rem}
.heroTopRow{display:flex;flex:1 1 360px;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}
.helper{margin:0;color:var(--muted);font-size:.89rem}
.workspaceTabs{display:flex;flex-wrap:wrap;gap:6px;padding:4px;border-radius:999px;background:rgba(31,41,51,.05)}
.workspaceTab,.primary,.ghost,.mini,.tab{border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease} .workspaceTab,.primary,.ghost,.mini,.tab{border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease}
.workspaceTab{min-height:34px;padding:0 14px;background:transparent;color:var(--muted);font-weight:700} .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)} .workspaceTab.active{background:#fff;border-color:rgba(31,41,51,.08);color:var(--ink)}
.workspacePanel{display:grid;gap:12px} .themeToggle{white-space:nowrap}
.workspacePanel{display:grid;gap:6px}
.workspacePanel[hidden]{display:none} .workspacePanel[hidden]{display:none}
.toolControls{display:grid;gap:10px} .toolControls{display:grid;gap:8px}
.contentPane{height:clamp(360px,50vh,720px);overflow:auto;padding-right:4px} .contentPane{height:clamp(360px,50vh,720px);overflow:auto;scroll-behavior:smooth;padding-right:4px}
.contentStack{display:grid;gap:12px;min-width:min-content} .contentStack{display:grid;gap:8px;min-width:100%;padding-bottom:18px}
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center} .paneTopWrap{display:flex;justify-content:flex-end;position:sticky;bottom:14px;pointer-events:none;padding-right:10px}
.primary,.ghost{min-height:38px;padding:0 14px;font-weight:700} .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}
.mini,.tab{min-height:30px;padding:0 10px;font-weight:600} .paneTopButton.visible{opacity:.78;transform:translateY(0);visibility:visible;pointer-events:auto}
.paneTopButton.visible:hover{opacity:1;background:rgba(31,41,51,.62);transform:translateY(-1px)}
.row{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.primary,.ghost{min-height:34px;padding:0 12px;font-weight:700}
.mini,.tab{min-height:28px;padding:0 10px;font-weight:600}
.primary{background:var(--accent);color:#fff7f2} .primary{background:var(--accent);color:#fff7f2}
.ghost,.mini{background:rgba(255,255,255,.76);border-color:var(--line);color:var(--ink)} .ghost,.mini{background:rgba(255,255,255,.76);border-color:var(--line);color:var(--ink)}
.tab{background:transparent;color:var(--muted)} .tab{background:transparent;color:var(--muted)}
.tab.active{background:#fff;border-color:rgba(207,109,60,.18);color:var(--ink)} .tab.active{background:#fff;border-color:rgba(207,109,60,.18);color:var(--ink)}
.primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)} .primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)}
.chip{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 12px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600;font-size:.92rem} .chip{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:0 10px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600;font-size:.88rem}
.chip input{margin:0;accent-color:var(--accent)} .chip input{margin:0;accent-color:var(--accent)}
.chip select{border:none;background:transparent;outline:none;color:var(--ink)} .chip select{border:none;background:transparent;outline:none;color:var(--ink)}
.search{flex:1 1 260px;min-height:40px;padding:0 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none} .search{flex:1 1 260px;min-height:36px;padding:0 12px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none}
.status{display:inline-flex;align-items:center;min-height:34px;padding:0 12px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-size:.9rem;font-weight:600} .status{display:inline-flex;align-items:center;min-height:32px;padding:0 11px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-size:.86rem;font-weight:600}
.status.ok{color:var(--ok);border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.08)} .status.ok{color:var(--ok);border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.08)}
.status.warn{color:var(--warn);border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.08)} .status.warn{color:var(--warn);border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.08)}
.status.bad{color:var(--bad);border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.08)} .status.bad{color:var(--bad);border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.08)}
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:12px} .stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:0}
.stat{border-radius:16px;padding:14px} .stat{border-radius:16px;padding:10px 12px}
.stat span{display:block;margin-bottom:8px;color:var(--muted);font-size:.74rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} .stat span{display:block;margin-bottom:4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
.stat strong{display:block;font-size:clamp(1.6rem,3vw,2rem);line-height:1;letter-spacing:-.05em} .stat strong{display:block;font-size:clamp(1.6rem,3vw,2rem);line-height:1;letter-spacing:-.05em}
.stat strong.small{font-size:1.1rem;line-height:1.3;letter-spacing:-.02em} .stat strong.small{font-size:1.1rem;line-height:1.3;letter-spacing:-.02em}
.stat small{display:block;margin-top:8px;color:var(--muted);font-size:.85rem} .stat small{display:block;margin-top:4px;color:var(--muted);font-size:.82rem}
.banner,.empty{margin:0;padding:12px 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.82)} .banner,.empty{margin:0;padding:12px 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.82)}
.banner{color:var(--bad);border-color:rgba(179,58,58,.24);background:rgba(179,58,58,.08)} .banner{color:var(--bad);border-color:rgba(179,58,58,.24);background:rgba(179,58,58,.08)}
.list{display:grid;gap:12px;align-content:start} .list{display:grid;gap:12px;align-content:start}
.logList{display:grid;gap:10px;align-content:start} .logList{display:grid;gap:10px;align-content:start;width:100%}
.card{overflow:hidden;border-radius:16px} .card{overflow:hidden;border-radius:16px}
.card.new{border-color:rgba(31,143,101,.3);box-shadow:var(--card-shadow),0 0 0 1px rgba(31,143,101,.12)} .card.new{border-color:rgba(31,143,101,.3);box-shadow:var(--card-shadow),0 0 0 1px rgba(31,143,101,.12)}
.summary{list-style:none;display:grid;gap:8px;padding:14px 16px;cursor:pointer;background:linear-gradient(180deg,rgba(255,250,244,.88),rgba(255,246,238,.96))} .summary{list-style:none;display:grid;gap:7px;padding:12px 14px;cursor:pointer;background:linear-gradient(180deg,rgba(255,250,244,.88),rgba(255,246,238,.96))}
.summary::-webkit-details-marker{display:none} .summary::-webkit-details-marker{display:none}
.top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:8px;align-items:center} .top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.top{justify-content:space-between} .top{justify-content:space-between}
@@ -777,12 +792,12 @@ function renderStyles() {
.tag{background:var(--accent-soft);color:#8d5632} .tag{background:var(--accent-soft);color:#8d5632}
.tag.new{background:rgba(31,143,101,.1);color:var(--ok)} .tag.new{background:rgba(31,143,101,.1);color:var(--ok)}
.tag.bad{background:rgba(179,58,58,.1);color:var(--bad)} .tag.bad{background:rgba(179,58,58,.1);color:var(--bad)}
.preview{margin:0;color:#324150;font-size:.94rem} .preview{margin:0;color:#324150;font-size:.9rem}
.body{display:grid;gap:12px;padding:12px 16px 16px;border-top:1px solid rgba(207,109,60,.14);background:var(--card-body)} .body{display:grid;gap:10px;padding:10px 14px 14px;border-top:1px solid rgba(207,109,60,.14);background:var(--card-body)}
.toolbar{justify-content:space-between;align-items:center} .toolbar{justify-content:space-between;align-items:center}
.tabs{display:inline-flex;gap:4px;padding:4px;border-radius:999px;background:rgba(207,109,60,.08)} .tabs{display:inline-flex;gap:4px;padding:3px;border-radius:999px;background:rgba(207,109,60,.08)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px} .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}
.metaCard{padding:10px 12px;border-radius:12px;background:rgba(255,255,255,.78);border:1px solid rgba(207,109,60,.12)} .metaCard{padding:9px 11px;border-radius:12px;background:rgba(255,255,255,.78);border:1px solid rgba(207,109,60,.12)}
.metaCard dt{margin:0 0 4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} .metaCard dt{margin:0 0 4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
.metaCard dd{margin:0;word-break:break-word} .metaCard dd{margin:0;word-break:break-word}
.attachments{gap:6px} .attachments{gap:6px}
@@ -790,22 +805,78 @@ function renderStyles() {
.attachmentLink{color:#8d5632;text-decoration:none;transition:transform .12s ease,background-color .12s ease,border-color .12s ease} .attachmentLink{color:#8d5632;text-decoration:none;transition:transform .12s ease,background-color .12s ease,border-color .12s ease}
.attachmentLink:hover{transform:translateY(-1px);background:#fff;border-color:rgba(207,109,60,.28)} .attachmentLink:hover{transform:translateY(-1px);background:#fff;border-color:rgba(207,109,60,.28)}
.panel{overflow:hidden;border-radius:12px;border:1px solid rgba(207,109,60,.14);background:#fff} .panel{overflow:hidden;border-radius:12px;border:1px solid rgba(207,109,60,.14);background:#fff}
.logEvent{overflow:hidden;border-radius:16px;border:1px solid var(--log-line);background:var(--log-shell);box-shadow:var(--log-shadow)} .logEvent{width:100%;overflow:hidden;border-radius:16px;border:1px solid var(--log-line);background:var(--log-shell);box-shadow:var(--log-shadow)}
.logSummary{list-style:none;display:grid;gap:8px;padding:12px 14px;cursor:pointer} .logSummary{list-style:none;display:grid;gap:7px;padding:10px 12px;cursor:pointer}
.logSummary::-webkit-details-marker{display:none} .logSummary::-webkit-details-marker{display:none}
.logSummaryTop{display:flex;flex-wrap:wrap;gap:8px;justify-content:space-between;align-items:center} .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} .logMeta{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.logTag{background:var(--info-soft);color:var(--info);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .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-size:.9rem} .logPreview{margin:0;color:#324150;font:600 .88rem/1.45 "Cascadia Code","Consolas",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.logBody{padding:0 14px 14px;border-top:1px solid rgba(48,113,169,.14);background:var(--log-body)} .logBody{padding:8px 12px 12px;border-top:1px solid rgba(48,113,169,.14);background:var(--log-body)}
.logActions{display:flex;justify-content:flex-end;padding:12px 0 0} .logCodeWrap{position:relative}
.logBody pre{background:linear-gradient(180deg,rgba(48,113,169,.04),transparent 140px),#fff} .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}
iframe{width:100%;min-height:560px;border:none;background:#fff} 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} 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} .placeholder,.inlineError{padding:12px}
.inlineError{color:var(--bad)} .inlineError{color:var(--bad)}
@media (max-width:1080px){.hero{grid-template-columns:1fr}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}} body[data-theme="dark"]{color-scheme:dark;background:radial-gradient(circle at top left,rgba(207,109,60,.12),transparent 28%),radial-gradient(circle at top right,rgba(48,113,169,.12),transparent 26%),linear-gradient(180deg,#10161d,#17202a)}
@media (max-width:720px){.page{padding:12px}.stats{grid-template-columns:1fr}.workspaceTab,.primary,.ghost,.chip{width:100%;justify-content:center}.toolbar,.row{align-items:stretch}.logSummaryTop{align-items:flex-start}.contentPane{height:clamp(300px,48vh,560px)}iframe{min-height:420px}} body[data-theme="dark"] .heroShell,
body[data-theme="dark"] .toolControls,
body[data-theme="dark"] .stat{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.16);box-shadow:0 14px 30px rgba(0,0,0,.32)}
body[data-theme="dark"] .card{background:linear-gradient(180deg,rgba(50,35,28,.96),rgba(31,25,22,.98));border-color:rgba(207,109,60,.24);box-shadow:0 18px 34px rgba(0,0,0,.34)}
body[data-theme="dark"] .logEvent{background:linear-gradient(180deg,rgba(18,31,45,.96),rgba(14,24,36,.98));border-color:rgba(73,144,204,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)}
body[data-theme="dark"] .workspaceTabs{background:rgba(148,163,184,.12)}
body[data-theme="dark"] .workspaceTab,
body[data-theme="dark"] .tab{color:#aab8c8}
body[data-theme="dark"] .workspaceTab.active,
body[data-theme="dark"] .tab.active,
body[data-theme="dark"] .ghost,
body[data-theme="dark"] .mini,
body[data-theme="dark"] .chip,
body[data-theme="dark"] .status,
body[data-theme="dark"] .search{background:rgba(18,25,35,.88);border-color:rgba(148,163,184,.18);color:#edf2f7}
body[data-theme="dark"] .chip select,
body[data-theme="dark"] .search::placeholder{color:#9fb0c2}
body[data-theme="dark"] .ghost,
body[data-theme="dark"] .mini,
body[data-theme="dark"] .workspaceTab.active,
body[data-theme="dark"] .tab.active{border-color:rgba(148,163,184,.18)}
body[data-theme="dark"] .summary{background:linear-gradient(180deg,rgba(58,40,31,.88),rgba(45,33,28,.96))}
body[data-theme="dark"] .body{background:#211a17;border-top-color:rgba(207,109,60,.18)}
body[data-theme="dark"] .logSummary{background:linear-gradient(180deg,rgba(21,34,47,.94),rgba(16,27,39,.98))}
body[data-theme="dark"] .logBody{background:#13212d;border-top-color:rgba(73,144,204,.18)}
body[data-theme="dark"] .metaCard{background:rgba(17,25,35,.64);border-color:rgba(148,163,184,.14)}
body[data-theme="dark"] .attachment{background:rgba(50,35,28,.9);border-color:rgba(207,109,60,.18)}
body[data-theme="dark"] .attachmentLink{color:#f6c4a9}
body[data-theme="dark"] .attachmentLink:hover{background:rgba(75,52,39,.96);border-color:rgba(246,196,169,.26)}
body[data-theme="dark"] .panel,
body[data-theme="dark"] pre,
body[data-theme="dark"] .logBody pre{background:linear-gradient(180deg,rgba(73,144,204,.06),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)}
body[data-theme="dark"] .panel{border-color:rgba(148,163,184,.14)}
body[data-theme="dark"] .banner,
body[data-theme="dark"] .empty{background:rgba(15,21,30,.82);border-color:rgba(148,163,184,.16)}
body[data-theme="dark"] .time{background:rgba(148,163,184,.12);color:#e8edf3}
body[data-theme="dark"] .tag{background:rgba(207,109,60,.14);color:#f0c2aa}
body[data-theme="dark"] .logTag{background:rgba(73,144,204,.16);color:#93cfff}
body[data-theme="dark"] .preview,
body[data-theme="dark"] .logPreview,
body[data-theme="dark"] .metaCard dd,
body[data-theme="dark"] .head h2,
body[data-theme="dark"] .stat strong,
body[data-theme="dark"] h1{color:#edf2f7}
body[data-theme="dark"] .meta,
body[data-theme="dark"] .helper,
body[data-theme="dark"] .lede,
body[data-theme="dark"] .stat small,
body[data-theme="dark"] .stat span,
body[data-theme="dark"] .chip,
body[data-theme="dark"] .workspaceTab,
body[data-theme="dark"] .tab{color:#aab8c8}
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}}
`; `;
} }
@@ -821,8 +892,10 @@ function escapeHtml(value) {
function clientApp(config) { function clientApp(config) {
const appState = { const appState = {
panel: "emails", panel: "emails",
logsReady: false logsReady: false,
theme: getInitialTheme()
}; };
const THEME_STORAGE_KEY = "localstack-inspector-theme";
const state = { const state = {
messages: [], messages: [],
@@ -871,6 +944,7 @@ function clientApp(config) {
const el = { const el = {
workspaceTabs: document.getElementById("workspaceTabs"), workspaceTabs: document.getElementById("workspaceTabs"),
themeToggle: document.getElementById("themeToggle"),
emailsPanel: document.getElementById("emailsPanel"), emailsPanel: document.getElementById("emailsPanel"),
logsPanel: document.getElementById("logsPanel"), logsPanel: document.getElementById("logsPanel"),
refreshButton: document.getElementById("refreshButton"), refreshButton: document.getElementById("refreshButton"),
@@ -880,6 +954,7 @@ function clientApp(config) {
clearSearchButton: document.getElementById("clearSearchButton"), clearSearchButton: document.getElementById("clearSearchButton"),
expandAllButton: document.getElementById("expandAllButton"), expandAllButton: document.getElementById("expandAllButton"),
collapseAllButton: document.getElementById("collapseAllButton"), collapseAllButton: document.getElementById("collapseAllButton"),
scrollToTopButton: document.getElementById("scrollToTopButton"),
statusChip: document.getElementById("statusChip"), statusChip: document.getElementById("statusChip"),
totalStat: document.getElementById("totalStat"), totalStat: document.getElementById("totalStat"),
visibleStat: document.getElementById("visibleStat"), visibleStat: document.getElementById("visibleStat"),
@@ -891,6 +966,7 @@ function clientApp(config) {
banner: document.getElementById("banner"), banner: document.getElementById("banner"),
empty: document.getElementById("empty"), empty: document.getElementById("empty"),
list: document.getElementById("list"), list: document.getElementById("list"),
emailsContentPane: document.getElementById("emailsContentPane"),
logsRefreshButton: document.getElementById("logsRefreshButton"), logsRefreshButton: document.getElementById("logsRefreshButton"),
logsAutoToggle: document.getElementById("logsAutoToggle"), logsAutoToggle: document.getElementById("logsAutoToggle"),
logsIntervalSelect: document.getElementById("logsIntervalSelect"), logsIntervalSelect: document.getElementById("logsIntervalSelect"),
@@ -900,6 +976,7 @@ function clientApp(config) {
logsLimitSelect: document.getElementById("logsLimitSelect"), logsLimitSelect: document.getElementById("logsLimitSelect"),
logsSearchInput: document.getElementById("logsSearchInput"), logsSearchInput: document.getElementById("logsSearchInput"),
logsClearSearchButton: document.getElementById("logsClearSearchButton"), logsClearSearchButton: document.getElementById("logsClearSearchButton"),
logsScrollToTopButton: document.getElementById("logsScrollToTopButton"),
logsStatusChip: document.getElementById("logsStatusChip"), logsStatusChip: document.getElementById("logsStatusChip"),
logsTotalStat: document.getElementById("logsTotalStat"), logsTotalStat: document.getElementById("logsTotalStat"),
logsVisibleStat: document.getElementById("logsVisibleStat"), logsVisibleStat: document.getElementById("logsVisibleStat"),
@@ -910,13 +987,15 @@ function clientApp(config) {
logsFetchDetail: document.getElementById("logsFetchDetail"), logsFetchDetail: document.getElementById("logsFetchDetail"),
logsBanner: document.getElementById("logsBanner"), logsBanner: document.getElementById("logsBanner"),
logsEmpty: document.getElementById("logsEmpty"), logsEmpty: document.getElementById("logsEmpty"),
logsList: document.getElementById("logsList") logsList: document.getElementById("logsList"),
logsContentPane: document.getElementById("logsContentPane")
}; };
el.intervalSelect.value = String(config.defaultRefreshMs); el.intervalSelect.value = String(config.defaultRefreshMs);
el.logsIntervalSelect.value = String(config.defaultRefreshMs); el.logsIntervalSelect.value = String(config.defaultRefreshMs);
el.logsWindowSelect.value = String(config.defaultLogWindowMs); el.logsWindowSelect.value = String(config.defaultLogWindowMs);
el.logsLimitSelect.value = String(config.defaultLogLimit); el.logsLimitSelect.value = String(config.defaultLogLimit);
applyTheme(appState.theme);
wire(); wire();
renderWorkspace(); renderWorkspace();
renderAll(); renderAll();
@@ -938,6 +1017,10 @@ function clientApp(config) {
await setPanel(button.dataset.panel); await setPanel(button.dataset.panel);
}); });
el.themeToggle.addEventListener("click", () => {
applyTheme(appState.theme === "dark" ? "light" : "dark");
});
el.refreshButton.addEventListener("click", () => refreshMessages("manual")); el.refreshButton.addEventListener("click", () => refreshMessages("manual"));
el.autoToggle.addEventListener("change", () => { el.autoToggle.addEventListener("change", () => {
@@ -973,6 +1056,14 @@ function clientApp(config) {
renderList(); renderList();
}); });
el.scrollToTopButton.addEventListener("click", () => {
scrollPaneToTop(el.emailsContentPane);
});
el.emailsContentPane.addEventListener("scroll", () => {
updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton);
});
el.list.addEventListener("click", async (event) => { el.list.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-action]"); const button = event.target.closest("button[data-action]");
@@ -1044,6 +1135,14 @@ function clientApp(config) {
applyLogsFilter(); applyLogsFilter();
}); });
el.logsScrollToTopButton.addEventListener("click", () => {
scrollPaneToTop(el.logsContentPane);
});
el.logsContentPane.addEventListener("scroll", () => {
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
});
el.logsGroupSelect.addEventListener("change", async () => { el.logsGroupSelect.addEventListener("change", async () => {
logsState.group = el.logsGroupSelect.value; logsState.group = el.logsGroupSelect.value;
logsState.stream = ""; logsState.stream = "";
@@ -1113,6 +1212,9 @@ function clientApp(config) {
refreshMessages("keyboard"); refreshMessages("keyboard");
} }
}); });
updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton);
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
} }
async function setPanel(panel) { async function setPanel(panel) {
@@ -1655,6 +1757,7 @@ function clientApp(config) {
el.empty.textContent = state.messages.length el.empty.textContent = state.messages.length
? "No messages match the current search." ? "No messages match the current search."
: "No emails yet. Send one through LocalStack SES and refresh."; : "No emails yet. Send one through LocalStack SES and refresh.";
updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton);
return; return;
} }
@@ -1662,6 +1765,7 @@ function clientApp(config) {
el.list.innerHTML = state.filtered.map(renderCard).join(""); el.list.innerHTML = state.filtered.map(renderCard).join("");
bindCardToggles(); bindCardToggles();
el.list.querySelectorAll(".card[open]").forEach((details) => hydrate(details, getMessage(details.dataset.id))); el.list.querySelectorAll(".card[open]").forEach((details) => hydrate(details, getMessage(details.dataset.id)));
updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton);
} }
function renderLogsList() { function renderLogsList() {
@@ -1672,6 +1776,7 @@ function clientApp(config) {
el.logsList.innerHTML = ""; el.logsList.innerHTML = "";
el.logsEmpty.hidden = false; el.logsEmpty.hidden = false;
el.logsEmpty.textContent = "No CloudWatch log groups found in LocalStack yet."; el.logsEmpty.textContent = "No CloudWatch log groups found in LocalStack yet.";
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
return; return;
} }
@@ -1681,12 +1786,14 @@ function clientApp(config) {
el.logsEmpty.textContent = logsState.events.length el.logsEmpty.textContent = logsState.events.length
? "No log events match the current search." ? "No log events match the current search."
: "No log events found for the selected group, stream, and time window."; : "No log events found for the selected group, stream, and time window.";
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
return; return;
} }
el.logsEmpty.hidden = true; el.logsEmpty.hidden = true;
el.logsList.innerHTML = logsState.filtered.map((event, index) => renderLogEvent(event, index)).join(""); el.logsList.innerHTML = logsState.filtered.map((event, index) => renderLogEvent(event, index)).join("");
bindLogToggles(); bindLogToggles();
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
} }
function renderLogEvent(event, index) { function renderLogEvent(event, index) {
@@ -1705,10 +1812,10 @@ function clientApp(config) {
<p class="logPreview">${escapeHtml(event.preview || "No preview available.")}</p> <p class="logPreview">${escapeHtml(event.preview || "No preview available.")}</p>
</summary> </summary>
<div class="logBody"> <div class="logBody">
<div class="logActions"> <div class="logCodeWrap">
<button class="mini" type="button" data-log-action="copy" data-id="${escapeHtml(event.id)}">Copy JSON</button> <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> </div>
<pre>${escapeHtml(formatLogMessage(event.message))}</pre>
</div> </div>
</details> </details>
`; `;
@@ -1958,6 +2065,58 @@ function clientApp(config) {
el.logsStatusChip.textContent = message; el.logsStatusChip.textContent = message;
} }
function getInitialTheme() {
try {
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === "dark" || storedTheme === "light") {
return storedTheme;
}
} catch {}
return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light";
}
function applyTheme(theme) {
const nextTheme = theme === "dark" ? "dark" : "light";
appState.theme = nextTheme;
document.body.dataset.theme = nextTheme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
} catch {}
renderThemeToggle();
}
function renderThemeToggle() {
if (!el.themeToggle) {
return;
}
const isDark = appState.theme === "dark";
el.themeToggle.textContent = isDark ? "Theme: Dark" : "Theme: Light";
el.themeToggle.setAttribute("aria-pressed", isDark ? "true" : "false");
el.themeToggle.setAttribute("aria-label", isDark ? "Switch to light theme" : "Switch to dark theme");
el.themeToggle.title = isDark ? "Switch to light theme" : "Switch to dark theme";
}
function scrollPaneToTop(element) {
if (!element) {
return;
}
element.scrollTo({ top: 0, behavior: "smooth" });
}
function updatePaneTopButtonVisibility(pane, button) {
if (!pane || !button) {
return;
}
button.classList.toggle("visible", pane.scrollTop > 140);
}
function formatDateTime(value) { function formatDateTime(value) {
if (!value) { if (!value) {
return "Unknown time"; return "Unknown time";