991 lines
32 KiB
HTML
991 lines
32 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>AWS Lambda — Study notes & sandbox</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
background: #0a0e17;
|
||
color: #e8eaf0;
|
||
font-family: 'Inter', sans-serif;
|
||
line-height: 1.6;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
header {
|
||
padding: 16px 24px;
|
||
border-bottom: 1px solid #1e2a4a;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
header h1 {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
letter-spacing: 3px;
|
||
color: #0066ff;
|
||
}
|
||
|
||
header .subtitle {
|
||
font-size: 13px;
|
||
color: #4a5568;
|
||
letter-spacing: 1px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.layout {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
nav {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
width: 220px;
|
||
flex-shrink: 0;
|
||
background: #121829;
|
||
border-right: 1px solid #1e2a4a;
|
||
padding: 8px 0;
|
||
overflow-y: auto;
|
||
scrollbar-width: none;
|
||
}
|
||
nav::-webkit-scrollbar { display: none; }
|
||
|
||
nav a {
|
||
padding: 10px 20px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
color: #8892a8;
|
||
text-decoration: none;
|
||
border-left: 2px solid transparent;
|
||
transition: all 0.15s;
|
||
cursor: pointer;
|
||
}
|
||
|
||
nav a:hover { color: #e8eaf0; background: #1a2340; }
|
||
nav a.active { color: #0066ff; border-left-color: #0066ff; background: #0d1a33; }
|
||
|
||
nav .nav-group {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 10px;
|
||
color: #4a5568;
|
||
letter-spacing: 1.5px;
|
||
text-transform: uppercase;
|
||
padding: 14px 20px 6px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
|
||
main {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 32px 48px;
|
||
}
|
||
|
||
.graph-section {
|
||
display: none;
|
||
animation: fadeIn 0.2s ease;
|
||
}
|
||
|
||
.graph-section.active { display: block; }
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
.graph-section h2 {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
color: #8892a8;
|
||
margin-bottom: 8px;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.graph-section p.lead {
|
||
font-size: 13px;
|
||
color: #4a5568;
|
||
margin-bottom: 24px;
|
||
max-width: 800px;
|
||
}
|
||
|
||
.graph-container {
|
||
background: #0a0e17;
|
||
border: 1px solid #1e2a4a;
|
||
padding: 24px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.graph-container img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
|
||
.legend {
|
||
display: flex;
|
||
gap: 24px;
|
||
margin-top: 16px;
|
||
font-size: 11px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
color: #4a5568;
|
||
}
|
||
|
||
.legend span::before {
|
||
content: '';
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
margin-right: 6px;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.legend .live::before { background: #00c853; }
|
||
.legend .mock::before { background: #ffc107; }
|
||
.legend .mcp::before { background: #0066ff; }
|
||
.legend .ops::before { background: #ff3d00; }
|
||
|
||
.graph-container a { display: block; }
|
||
|
||
/* Tree (repo structure) */
|
||
.tree-container {
|
||
background: #0a0e17;
|
||
border: 1px solid #1e2a4a;
|
||
padding: 24px;
|
||
overflow: auto;
|
||
}
|
||
.repo-tree {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
color: #8892a8;
|
||
}
|
||
.t-root { color: #0066ff; font-weight: 600; font-size: 15px; }
|
||
.t-dir { color: #e8eaf0; font-weight: 500; }
|
||
.t-comment { color: #4a5568; }
|
||
|
||
/* Prose sections */
|
||
.graph-section h3 {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #e8eaf0;
|
||
letter-spacing: 1px;
|
||
margin: 32px 0 10px;
|
||
text-transform: uppercase;
|
||
}
|
||
.graph-section h3:first-child { margin-top: 0; }
|
||
.prose { max-width: 820px; }
|
||
.prose p {
|
||
font-size: 14px;
|
||
color: #b4bccf;
|
||
margin-bottom: 14px;
|
||
line-height: 1.7;
|
||
}
|
||
.prose p b { color: #e8eaf0; font-weight: 600; }
|
||
.prose code {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
color: #7ab0ff;
|
||
background: #121829;
|
||
padding: 1px 5px;
|
||
border-radius: 3px;
|
||
}
|
||
.prose a { color: #0066ff; text-decoration: none; }
|
||
.prose a:hover { text-decoration: underline; }
|
||
.prose ul, .prose ol {
|
||
margin: 8px 0 16px 22px;
|
||
font-size: 14px;
|
||
color: #b4bccf;
|
||
line-height: 1.7;
|
||
}
|
||
.prose ul li, .prose ol li { margin-bottom: 8px; }
|
||
.prose ul li b, .prose ol li b { color: #e8eaf0; font-weight: 600; }
|
||
|
||
/* Pre / code blocks */
|
||
.prose pre {
|
||
background: #121829;
|
||
border: 1px solid #1e2a4a;
|
||
padding: 14px 16px;
|
||
border-radius: 4px;
|
||
overflow-x: auto;
|
||
margin: 12px 0 18px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
color: #b4bccf;
|
||
line-height: 1.6;
|
||
}
|
||
.prose pre code { background: transparent; padding: 0; color: inherit; }
|
||
|
||
/* Tables */
|
||
.cmp-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
margin: 8px 0 20px;
|
||
border: 1px solid #1e2a4a;
|
||
}
|
||
.cmp-table th {
|
||
text-align: left;
|
||
background: #121829;
|
||
color: #8892a8;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 11px;
|
||
letter-spacing: 1px;
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid #1e2a4a;
|
||
}
|
||
.cmp-table td {
|
||
padding: 10px 14px;
|
||
color: #b4bccf;
|
||
border-bottom: 1px solid #1e2a4a;
|
||
vertical-align: top;
|
||
}
|
||
.cmp-table td.num {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
color: #7ab0ff;
|
||
white-space: nowrap;
|
||
}
|
||
.cmp-table td.warn { color: #ffc107; }
|
||
.cmp-table td.bad { color: #ff3d00; }
|
||
.cmp-table td.ok { color: #00c853; }
|
||
.cmp-table tr:last-child td { border-bottom: none; }
|
||
|
||
/* Callouts */
|
||
.callout {
|
||
border-left: 3px solid #0066ff;
|
||
background: #0d1a33;
|
||
padding: 12px 16px;
|
||
margin: 16px 0;
|
||
font-size: 13px;
|
||
color: #b4bccf;
|
||
border-radius: 0 4px 4px 0;
|
||
}
|
||
.callout.warn { border-left-color: #ffc107; background: #2a1f0a; }
|
||
.callout.bad { border-left-color: #ff3d00; background: #2a0f0a; }
|
||
.callout.ok { border-left-color: #00c853; background: #0a2410; }
|
||
.callout b { color: #e8eaf0; }
|
||
|
||
.placeholder {
|
||
color: #4a5568;
|
||
font-style: italic;
|
||
font-size: 13px;
|
||
border: 1px dashed #1e2a4a;
|
||
padding: 32px;
|
||
text-align: center;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* Mobile menu toggle */
|
||
.menu-toggle {
|
||
display: none;
|
||
background: transparent;
|
||
border: 1px solid #1e2a4a;
|
||
color: #e8eaf0;
|
||
padding: 6px 10px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
margin-left: auto;
|
||
}
|
||
.menu-toggle:hover { background: #1a2340; }
|
||
|
||
.nav-backdrop {
|
||
display: none;
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 10;
|
||
}
|
||
.layout.nav-open .nav-backdrop { display: block; }
|
||
|
||
@media (max-width: 720px) {
|
||
header { padding: 10px 12px; gap: 8px; }
|
||
header h1 { font-size: 16px; letter-spacing: 1px; }
|
||
header .subtitle { display: none; }
|
||
.menu-toggle { display: inline-block; }
|
||
|
||
.layout { position: relative; }
|
||
nav {
|
||
position: absolute;
|
||
left: 0; top: 0; bottom: 0;
|
||
width: 240px;
|
||
z-index: 20;
|
||
transform: translateX(-100%);
|
||
transition: transform 0.2s ease;
|
||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.5);
|
||
}
|
||
.layout.nav-open nav { transform: translateX(0); }
|
||
|
||
main { padding: 16px; }
|
||
.graph-section h2 { font-size: 13px; }
|
||
.prose p, .prose ul, .prose ol { font-size: 13px; }
|
||
.cmp-table { font-size: 12px; }
|
||
.cmp-table th, .cmp-table td { padding: 6px 8px; }
|
||
}
|
||
|
||
/* ── Lambda Tester ─────────────────────────────────────── */
|
||
.tester-form {
|
||
display: grid;
|
||
grid-template-columns: max-content 1fr;
|
||
column-gap: 14px;
|
||
row-gap: 10px;
|
||
align-items: start;
|
||
max-width: 820px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.tester-form label {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 11px;
|
||
color: #8892a8;
|
||
letter-spacing: 1px;
|
||
text-transform: uppercase;
|
||
padding-top: 8px;
|
||
}
|
||
.tester-form select,
|
||
.tester-form input,
|
||
.tester-form textarea {
|
||
background: #121829;
|
||
border: 1px solid #1e2a4a;
|
||
color: #e8eaf0;
|
||
padding: 6px 10px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 13px;
|
||
border-radius: 3px;
|
||
width: 100%;
|
||
}
|
||
.tester-form textarea { resize: vertical; min-height: 64px; }
|
||
.tester-form select:focus,
|
||
.tester-form input:focus,
|
||
.tester-form textarea:focus { outline: none; border-color: #0066ff; }
|
||
.tester-form .row-buttons {
|
||
display: flex;
|
||
gap: 8px;
|
||
grid-column: 2;
|
||
}
|
||
.tester-btn {
|
||
background: #1a2340;
|
||
border: 1px solid #1e2a4a;
|
||
color: #b4bccf;
|
||
padding: 7px 14px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.tester-btn:hover { background: #243054; color: #e8eaf0; }
|
||
.tester-btn.primary { background: #0066ff; color: #fff; border-color: #0066ff; }
|
||
.tester-btn.primary:hover { background: #0050cc; }
|
||
.tester-btn:disabled { opacity: 0.5; cursor: wait; }
|
||
|
||
/* CloudWatch REPORT-line block */
|
||
.tester-report {
|
||
background: #121829;
|
||
border: 1px solid #1e2a4a;
|
||
padding: 14px 16px;
|
||
border-radius: 4px;
|
||
margin: 16px 0;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
white-space: pre;
|
||
overflow-x: auto;
|
||
color: #b4bccf;
|
||
}
|
||
.tester-report .k { color: #8892a8; }
|
||
.tester-report .v { color: #7ab0ff; }
|
||
.tester-report .cold { color: #ffc107; font-weight: 600; }
|
||
.tester-report .warm { color: #00c853; font-weight: 600; }
|
||
.tester-report .err { color: #ff3d00; font-weight: 600; }
|
||
|
||
.tester-section details {
|
||
background: #0d1325;
|
||
border: 1px solid #1e2a4a;
|
||
border-radius: 4px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.tester-section details > summary {
|
||
cursor: pointer;
|
||
padding: 10px 14px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
color: #8892a8;
|
||
letter-spacing: 0.5px;
|
||
user-select: none;
|
||
}
|
||
.tester-section details[open] > summary { border-bottom: 1px solid #1e2a4a; color: #e8eaf0; }
|
||
.tester-section details pre {
|
||
padding: 12px 16px;
|
||
margin: 0;
|
||
overflow-x: auto;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
color: #b4bccf;
|
||
background: transparent;
|
||
border: none;
|
||
max-height: 360px;
|
||
}
|
||
.tester-section details .empty {
|
||
padding: 14px 16px;
|
||
color: #4a5568;
|
||
font-style: italic;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.tester-history { width: 100%; max-width: none; }
|
||
.tester-history th.num,
|
||
.tester-history td.num {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
color: #7ab0ff;
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
.tester-history tr { cursor: pointer; }
|
||
.tester-history tr:hover td { background: #0d1a33; }
|
||
.tester-history tr.selected td { background: #0d1a33; color: #e8eaf0; }
|
||
.tester-history .badge {
|
||
display: inline-block;
|
||
padding: 1px 8px;
|
||
border-radius: 3px;
|
||
font-size: 10px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.tester-history .badge.cold { background: #2a1f0a; color: #ffc107; }
|
||
.tester-history .badge.warm { background: #0a2410; color: #00c853; }
|
||
.tester-history .badge.err { background: #2a0f0a; color: #ff3d00; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<h1>AWS LAMBDA</h1>
|
||
<span class="subtitle">Study notes & sandbox — built from the interview exercise</span>
|
||
<button class="menu-toggle" onclick="toggleNav()" aria-label="Toggle navigation">☰</button>
|
||
</header>
|
||
|
||
<div class="layout">
|
||
<div class="nav-backdrop" onclick="toggleNav()"></div>
|
||
|
||
<nav>
|
||
<a class="active">TESTER</a>
|
||
<a href="http://docs.eth.local.ar">DOCS</a>
|
||
</nav>
|
||
|
||
<main>
|
||
|
||
<!-- ===================================================================== -->
|
||
<!-- LAMBDA TESTER -->
|
||
<!-- ===================================================================== -->
|
||
<section id="tester" class="graph-section tester-section active">
|
||
<h2>Lambda Tester</h2>
|
||
<p class="lead">Local invoker for any <code>handler(event, context)</code> in the project root. Captures the AWS-equivalent <code>REPORT</code> metrics, stdout, structured JSON log lines, and CloudWatch EMF metric records — the latter two will populate automatically once the corresponding function-side improvements land.</p>
|
||
|
||
<div class="tester-form">
|
||
<label>Function</label>
|
||
<select id="tester-function" aria-label="Function to invoke"></select>
|
||
|
||
<label>Memory (MB)</label>
|
||
<select id="tester-memory" aria-label="Configured memory size">
|
||
<option>128</option><option>256</option><option selected>512</option>
|
||
<option>1024</option><option>2048</option><option>3008</option><option>5120</option>
|
||
</select>
|
||
|
||
<label>Timeout (s)</label>
|
||
<input id="tester-timeout" type="number" value="30" min="1" max="900">
|
||
|
||
<label>Event JSON</label>
|
||
<div>
|
||
<select id="tester-event-sample" style="margin-bottom: 6px; max-width: 320px;"
|
||
aria-label="Load a saved sample event">
|
||
<option value="">— sample event —</option>
|
||
</select>
|
||
<textarea id="tester-event" rows="4" spellcheck="false">{}</textarea>
|
||
</div>
|
||
|
||
<span></span>
|
||
<div class="row-buttons">
|
||
<button class="tester-btn primary" id="tester-invoke">Invoke</button>
|
||
<button class="tester-btn" id="tester-reset" title="Clear module cache → next invocation cold-starts">Force cold start</button>
|
||
<button class="tester-btn" id="tester-refresh" title="Re-scan /app for handler files">Refresh functions</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="tester-report" class="tester-report">Hit <span class="v">Invoke</span> to run the selected function.</div>
|
||
|
||
<details open>
|
||
<summary>Result (JSON)</summary>
|
||
<pre id="tester-result"><span class="placeholder" style="border: none; padding: 0; display: block;">No invocation yet.</span></pre>
|
||
</details>
|
||
|
||
<details>
|
||
<summary>stdout / stderr</summary>
|
||
<pre id="tester-stdout"><span class="empty">No output yet.</span></pre>
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Structured logs <span style="color:#4a5568">(JSON-per-line on stdout — currently empty, will populate after improvement #2)</span></summary>
|
||
<pre id="tester-logs"><span class="empty">No structured logs detected.</span></pre>
|
||
</details>
|
||
|
||
<details>
|
||
<summary>EMF metrics <span style="color:#4a5568">(CloudWatch embedded-metric-format on stdout — populates when the function emits, e.g. <code>sign_pdfs_optimized</code>)</span></summary>
|
||
<pre id="tester-emf"><span class="empty">No EMF metrics detected.</span></pre>
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Packaging <span style="color:#4a5568">(deployment sizes from the live pod — function zips, shared layer, largest deps, vs AWS caps)</span></summary>
|
||
<pre id="tester-packaging"><span class="empty">Loading…</span></pre>
|
||
</details>
|
||
|
||
<h3>History</h3>
|
||
<p class="lead" style="margin-bottom:8px">Click a row to view its full record. Cleared on page reload (FastAPI keeps the last 200 invocations server-side regardless).</p>
|
||
<div id="tester-history-summary" style="font-family:'JetBrains Mono',monospace; font-size:12px; color:#b4bccf; margin-bottom:8px"></div>
|
||
<table class="cmp-table tester-history">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th><th>Time</th><th>Function</th><th>Start</th>
|
||
<th class="num">Init (ms)</th><th class="num">Duration (ms)</th>
|
||
<th class="num">Total (ms)</th><th>Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tester-history-body">
|
||
<tr><td colspan="8" style="color:#4a5568; text-align:center; font-style:italic;">No invocations yet.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h3>Scripts</h3>
|
||
<p class="lead" style="margin-bottom:8px">Support scripts bundled with the selected function — any <code>.py</code> besides <code>handler.py</code>.</p>
|
||
<div class="tester-form">
|
||
<label>Script</label>
|
||
<select id="script-name" aria-label="Support script to run">
|
||
<option disabled selected>(select a function first)</option>
|
||
</select>
|
||
<label>Args</label>
|
||
<input id="script-args" type="text" placeholder="space-separated e.g. /mnt/documents">
|
||
<span></span>
|
||
<div class="row-buttons">
|
||
<button class="tester-btn primary" id="script-run">Run</button>
|
||
</div>
|
||
</div>
|
||
<div id="script-output" class="tester-report" style="display:none"></div>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
function toggleNav() {
|
||
document.querySelector('.layout').classList.toggle('nav-open');
|
||
}
|
||
|
||
/* ── Lambda Tester ─────────────────────────────────────── */
|
||
|
||
const RUNNER_BASE = '/runner';
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
async function api(path, opts) {
|
||
const resp = await fetch(RUNNER_BASE + path, opts);
|
||
if (!resp.ok && resp.status !== 422) {
|
||
const body = await resp.text().catch(() => '');
|
||
throw new Error(`HTTP ${resp.status}: ${body || resp.statusText}`);
|
||
}
|
||
return resp.json();
|
||
}
|
||
|
||
let _functionMeta = {}; // name -> { events: [filename, ...] }
|
||
|
||
async function loadFunctions() {
|
||
const sel = $('tester-function');
|
||
sel.innerHTML = '<option disabled selected>loading…</option>';
|
||
try {
|
||
const { functions } = await api('/functions');
|
||
sel.innerHTML = '';
|
||
_functionMeta = {};
|
||
if (!functions.length) {
|
||
sel.innerHTML = '<option disabled selected>(no function folders found)</option>';
|
||
renderEvents();
|
||
return;
|
||
}
|
||
for (const fn of functions) {
|
||
// Backwards-compat: server may return either string names or {name, events} objects.
|
||
const name = typeof fn === 'string' ? fn : fn.name;
|
||
const events = (typeof fn === 'object' && fn.events) ? fn.events : [];
|
||
_functionMeta[name] = { events };
|
||
const opt = document.createElement('option');
|
||
opt.value = name; opt.textContent = name;
|
||
sel.appendChild(opt);
|
||
}
|
||
renderEvents();
|
||
loadScripts();
|
||
} catch (e) {
|
||
sel.innerHTML = `<option disabled selected>error: ${e.message}</option>`;
|
||
}
|
||
}
|
||
|
||
function renderEvents() {
|
||
const sel = $('tester-event-sample');
|
||
if (!sel) return;
|
||
const fn = $('tester-function').value;
|
||
const events = (_functionMeta[fn] && _functionMeta[fn].events) || [];
|
||
sel.innerHTML = '<option value="">— sample event —</option>';
|
||
for (const ev of events) {
|
||
const opt = document.createElement('option');
|
||
opt.value = ev; opt.textContent = ev;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
async function loadEvent(filename) {
|
||
if (!filename) return;
|
||
const fn = $('tester-function').value;
|
||
try {
|
||
const body = await api(`/functions/${encodeURIComponent(fn)}/events/${encodeURIComponent(filename)}`);
|
||
$('tester-event').value = JSON.stringify(body, null, 2);
|
||
} catch (e) {
|
||
$('tester-report').innerHTML = `<span class="err">[ERROR]</span> ${e.message}`;
|
||
}
|
||
}
|
||
|
||
function renderReport(rec) {
|
||
const m = rec.metrics;
|
||
const cold = m.cold_start;
|
||
const isError = !!rec.error;
|
||
const tag = isError
|
||
? '<span class="err">[ERROR]</span>'
|
||
: cold ? '<span class="cold">[COLD]</span>' : '<span class="warm">[WARM]</span>';
|
||
const initLine = m.init_duration_ms != null
|
||
? `<span class="k">Init Duration:</span> <span class="v">${m.init_duration_ms.toFixed(2)} ms</span>\n`
|
||
: '';
|
||
// GB-s and projected cost — AWS Lambda bills (memory_GB × duration_s).
|
||
// Prices: $0.0000166667 per GB-s on x86, $0.0000133334 on arm64. +$0.20/1M requests.
|
||
const gbS = (m.memory_size_mb / 1024) * (m.duration_ms / 1000);
|
||
const costX86 = gbS * 0.0000166667;
|
||
const costArm = gbS * 0.0000133334;
|
||
const per1M = (cost) => (cost * 1_000_000 + 0.20).toFixed(2);
|
||
$('tester-report').innerHTML = (
|
||
`${tag} <span class="k">REPORT RequestId:</span> <span class="v">${rec.invocation_id}</span>\n` +
|
||
`<span class="k">Function:</span> <span class="v">${rec.function}</span>\n` +
|
||
`<span class="k">Duration:</span> <span class="v">${m.duration_ms.toFixed(2)} ms</span>\n` +
|
||
`<span class="k">Billed Duration:</span> <span class="v">${m.billed_duration_ms} ms</span>\n` +
|
||
`<span class="k">Memory Size:</span> <span class="v">${m.memory_size_mb} MB</span> ` +
|
||
`<span class="k">Max Memory Used:</span> <span class="v">${m.max_memory_used_mb.toFixed(2)} MB</span>\n` +
|
||
initLine +
|
||
`<span class="k">GB-seconds:</span> <span class="v">${gbS.toFixed(6)} GB-s</span>\n` +
|
||
`<span class="k">Cost (x86):</span> <span class="v">$${costX86.toFixed(9)}</span> ` +
|
||
`<span class="k">×1M:</span> <span class="v">$${per1M(costX86)}</span>\n` +
|
||
`<span class="k">Cost (arm64):</span> <span class="v">$${costArm.toFixed(9)}</span> ` +
|
||
`<span class="k">×1M:</span> <span class="v">$${per1M(costArm)}</span>`
|
||
);
|
||
}
|
||
|
||
function renderResult(rec) {
|
||
const pre = $('tester-result');
|
||
if (rec.error) {
|
||
pre.textContent = `Error: ${rec.error.type}: ${rec.error.message}\n\n${rec.error.traceback || ''}`;
|
||
pre.style.color = '#ff8a65';
|
||
} else {
|
||
pre.style.color = '';
|
||
pre.textContent = JSON.stringify(rec.result, null, 2);
|
||
}
|
||
}
|
||
|
||
function renderStdout(rec) {
|
||
const pre = $('tester-stdout');
|
||
const text = (rec.stdout || '') + (rec.stderr ? '\n--- stderr ---\n' + rec.stderr : '');
|
||
if (!text.trim()) {
|
||
pre.innerHTML = '<span class="empty">No stdout/stderr captured.</span>';
|
||
} else {
|
||
pre.textContent = text;
|
||
}
|
||
}
|
||
|
||
function renderLogs(rec) {
|
||
const pre = $('tester-logs');
|
||
if (!rec.structured_logs?.length) {
|
||
pre.innerHTML = '<span class="empty">No structured (JSON-per-line) logs detected on stdout.</span>';
|
||
} else {
|
||
pre.textContent = rec.structured_logs.map(l => JSON.stringify(l, null, 2)).join('\n');
|
||
}
|
||
}
|
||
|
||
function renderEmf(rec) {
|
||
const pre = $('tester-emf');
|
||
if (!rec.emf_metrics?.length) {
|
||
pre.innerHTML = '<span class="empty">No EMF metric records detected on stdout.</span>';
|
||
} else {
|
||
pre.textContent = rec.emf_metrics.map(l => JSON.stringify(l, null, 2)).join('\n');
|
||
}
|
||
}
|
||
|
||
let _history = []; // newest first; kept in sync with /invocations
|
||
|
||
async function loadHistory() {
|
||
try {
|
||
const { invocations } = await api('/invocations');
|
||
_history = invocations;
|
||
renderHistory();
|
||
} catch (e) { /* ignore on first load if backend is still booting */ }
|
||
}
|
||
|
||
function _pctile(arr, p) {
|
||
if (!arr.length) return null;
|
||
const s = arr.slice().sort((a, b) => a - b);
|
||
const i = Math.min(s.length - 1, Math.floor((p / 100) * s.length));
|
||
return s[i];
|
||
}
|
||
|
||
function _renderHistorySummary() {
|
||
const el = $('tester-history-summary');
|
||
if (!el) return;
|
||
if (!_history.length) { el.textContent = ''; return; }
|
||
const cold = _history.filter(h => h.cold_start && h.init_duration_ms != null).map(h => h.init_duration_ms);
|
||
const warm = _history.filter(h => !h.cold_start).map(h => h.duration_ms);
|
||
const parts = [`<span class="k">${_history.length} invocations</span>`];
|
||
if (cold.length) parts.push(`<span class="cold">cold init p50</span> <span class="v">${_pctile(cold, 50).toFixed(0)} ms</span> p99 <span class="v">${_pctile(cold, 99).toFixed(0)} ms</span>`);
|
||
if (warm.length) parts.push(`<span class="warm">warm p50</span> <span class="v">${_pctile(warm, 50).toFixed(0)} ms</span> p99 <span class="v">${_pctile(warm, 99).toFixed(0)} ms</span>`);
|
||
el.innerHTML = parts.join(' | ');
|
||
}
|
||
|
||
function renderHistory() {
|
||
const tbody = $('tester-history-body');
|
||
_renderHistorySummary();
|
||
if (!_history.length) {
|
||
tbody.innerHTML = '<tr><td colspan="8" style="color:#4a5568; text-align:center; font-style:italic;">No invocations yet.</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = '';
|
||
_history.forEach((h, idx) => {
|
||
const tr = document.createElement('tr');
|
||
tr.dataset.id = h.invocation_id;
|
||
const ts = new Date(h.timestamp * 1000).toLocaleTimeString();
|
||
const startBadge = h.cold_start
|
||
? '<span class="badge cold">cold</span>'
|
||
: '<span class="badge warm">warm</span>';
|
||
const statusBadge = h.ok
|
||
? '<span class="badge warm">ok</span>'
|
||
: '<span class="badge err">err</span>';
|
||
tr.innerHTML = `
|
||
<td class="num">${_history.length - idx}</td>
|
||
<td>${ts}</td>
|
||
<td>${h.function}</td>
|
||
<td>${startBadge}</td>
|
||
<td class="num">${h.init_duration_ms != null ? h.init_duration_ms.toFixed(2) : '—'}</td>
|
||
<td class="num">${h.duration_ms.toFixed(2)}</td>
|
||
<td class="num">${((h.init_duration_ms || 0) + h.duration_ms).toFixed(2)}</td>
|
||
<td>${statusBadge}</td>`;
|
||
tr.addEventListener('click', () => loadHistoryDetail(h.invocation_id, tr));
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
async function loadHistoryDetail(id, row) {
|
||
try {
|
||
const rec = await api('/invocations/' + id);
|
||
document.querySelectorAll('.tester-history tr.selected').forEach(r => r.classList.remove('selected'));
|
||
if (row) row.classList.add('selected');
|
||
renderReport(rec);
|
||
renderResult(rec);
|
||
renderStdout(rec);
|
||
renderLogs(rec);
|
||
renderEmf(rec);
|
||
} catch (e) {
|
||
$('tester-report').innerHTML = `<span class="err">[ERROR]</span> ${e.message}`;
|
||
}
|
||
}
|
||
|
||
function _clearTesterPanels() {
|
||
// Wipe per-invocation panels (everything except the History table) so stale
|
||
// data from a previous run never leaks into the new one if it errors mid-flight.
|
||
$('tester-result').innerHTML = '<span class="placeholder" style="border:none; padding:0; display:block;">…running…</span>';
|
||
$('tester-stdout').innerHTML = '<span class="empty">…running…</span>';
|
||
$('tester-logs').innerHTML = '<span class="empty">…running…</span>';
|
||
$('tester-emf').innerHTML = '<span class="empty">…running…</span>';
|
||
}
|
||
|
||
async function invoke() {
|
||
const btn = $('tester-invoke');
|
||
btn.disabled = true;
|
||
$('tester-report').innerHTML = '<span class="k">…running…</span>';
|
||
_clearTesterPanels();
|
||
try {
|
||
const fn = $('tester-function').value;
|
||
if (!fn) throw new Error('No function selected.');
|
||
let event;
|
||
try { event = JSON.parse($('tester-event').value || '{}'); }
|
||
catch (e) { throw new Error('Event JSON invalid: ' + e.message); }
|
||
const body = {
|
||
event,
|
||
memory_mb: parseInt($('tester-memory').value, 10),
|
||
timeout_ms: parseInt($('tester-timeout').value, 10) * 1000,
|
||
};
|
||
const rec = await api('/invoke/' + encodeURIComponent(fn), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
renderReport(rec);
|
||
renderResult(rec);
|
||
renderStdout(rec);
|
||
renderLogs(rec);
|
||
renderEmf(rec);
|
||
loadHistory();
|
||
} catch (e) {
|
||
$('tester-report').innerHTML = `<span class="err">[ERROR]</span> ${e.message}`;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function resetCold() {
|
||
const btn = $('tester-reset');
|
||
btn.disabled = true;
|
||
try {
|
||
const { cleared } = await api('/reset', { method: 'POST' });
|
||
$('tester-report').innerHTML = `<span class="k">Module cache cleared. Next invocation will be COLD.</span>\n<span class="k">Was loaded:</span> <span class="v">${cleared.join(', ') || '(none)'}</span>`;
|
||
} catch (e) {
|
||
$('tester-report').innerHTML = `<span class="err">[ERROR]</span> ${e.message}`;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function _fmtBytes(n) {
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(2) + ' MB';
|
||
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||
}
|
||
|
||
function _bar(used, cap) {
|
||
const pct = Math.min(100, (used / cap) * 100);
|
||
const colour = pct > 80 ? '#ff3d00' : pct > 50 ? '#ffc107' : '#00c853';
|
||
return `<span style="color:${colour}">${pct.toFixed(2)}%</span>`;
|
||
}
|
||
|
||
async function loadPackaging() {
|
||
const pre = $('tester-packaging');
|
||
try {
|
||
const p = await api('/packaging');
|
||
const L = p.limits;
|
||
let out = '<span class="k">Functions (one deployment zip per folder):</span>\n';
|
||
for (const f of p.functions) {
|
||
out += ` ${f.name.padEnd(24)} `
|
||
+ `<span class="k">zip:</span> <span class="v">${_fmtBytes(f.folder_zip_bytes).padStart(10)}</span> `
|
||
+ `${_bar(f.folder_zip_bytes, L.zip_upload_max)} of 50 MB upload cap `
|
||
+ `<span class="k">unzipped:</span> <span class="v">${_fmtBytes(f.folder_bytes)}</span>\n`;
|
||
}
|
||
out += '\n<span class="k">Shared layer (would be a Lambda Layer in AWS):</span>\n';
|
||
out += ` shared/ `
|
||
+ `<span class="k">zip:</span> <span class="v">${_fmtBytes(p.shared_layer.zip_bytes).padStart(10)}</span> `
|
||
+ `<span class="k">unzipped:</span> <span class="v">${_fmtBytes(p.shared_layer.bytes)}</span>\n`;
|
||
const totalUnz = p.dependencies_total_bytes
|
||
+ p.shared_layer.bytes
|
||
+ p.functions.reduce((s, f) => s + f.folder_bytes, 0);
|
||
out += '\n<span class="k">Largest installed dependencies (top 25, ≥50 KB):</span>\n';
|
||
for (const d of p.dependencies) {
|
||
out += ` ${d.name.padEnd(24)} <span class="v">${_fmtBytes(d.bytes).padStart(10)}</span>\n`;
|
||
}
|
||
out += `\n<span class="k">Total deps:</span> <span class="v">${_fmtBytes(p.dependencies_total_bytes)}</span>\n`;
|
||
out += `<span class="k">Total unzipped (function + shared + deps):</span> <span class="v">${_fmtBytes(totalUnz)}</span> `
|
||
+ `${_bar(totalUnz, L.unzipped_max)} of 250 MB unzipped cap\n`;
|
||
out += `\n<span class="k">AWS caps for reference:</span>\n`;
|
||
out += ` <span class="k">zip upload</span> ${_fmtBytes(L.zip_upload_max)} `
|
||
+ `<span class="k">unzipped</span> ${_fmtBytes(L.unzipped_max)} `
|
||
+ `<span class="k">container image</span> ${_fmtBytes(L.container_image_max)}\n`;
|
||
out += ` <span class="k">/tmp default</span> ${_fmtBytes(L.tmp_default)} `
|
||
+ `<span class="k">/tmp max</span> ${_fmtBytes(L.tmp_max)} `
|
||
+ `<span class="k">sync response</span> ${_fmtBytes(L.response_max)}\n`;
|
||
pre.innerHTML = out;
|
||
} catch (e) {
|
||
pre.innerHTML = `<span class="err">[ERROR]</span> ${e.message}`;
|
||
}
|
||
}
|
||
|
||
async function loadScripts() {
|
||
const fn = $('tester-function').value;
|
||
const sel = $('script-name');
|
||
if (!sel) return;
|
||
sel.innerHTML = '<option disabled selected>loading…</option>';
|
||
if (!fn) { sel.innerHTML = '<option disabled selected>(select a function first)</option>'; return; }
|
||
try {
|
||
const { scripts } = await api(`/functions/${encodeURIComponent(fn)}/scripts`);
|
||
sel.innerHTML = '';
|
||
if (!scripts.length) {
|
||
sel.innerHTML = '<option disabled selected>(no support scripts)</option>';
|
||
} else {
|
||
for (const s of scripts) {
|
||
const opt = document.createElement('option');
|
||
opt.value = s; opt.textContent = s;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
sel.innerHTML = `<option disabled selected>error: ${e.message}</option>`;
|
||
}
|
||
}
|
||
|
||
async function runScript() {
|
||
const btn = $('script-run');
|
||
btn.disabled = true;
|
||
const out = $('script-output');
|
||
out.style.display = 'block';
|
||
out.innerHTML = '<span class="k">…running…</span>';
|
||
try {
|
||
const fn = $('tester-function').value;
|
||
if (!fn) throw new Error('No function selected.');
|
||
const script = $('script-name').value;
|
||
if (!script || script.startsWith('(')) throw new Error('No script selected.');
|
||
const argsStr = ($('script-args').value || '').trim();
|
||
const args = argsStr ? argsStr.split(/\s+/) : [];
|
||
const rec = await api(`/scripts/${encodeURIComponent(fn)}/${encodeURIComponent(script)}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ args }),
|
||
});
|
||
const ok = rec.returncode === 0;
|
||
const badge = ok ? '<span class="warm">[ok]</span>' : `<span class="err">[exit ${rec.returncode}]</span>`;
|
||
const dur = `<span class="k">duration:</span> <span class="v">${rec.duration_ms.toFixed(2)} ms</span>`;
|
||
let content = `${badge} ${dur}\n`;
|
||
if (rec.stdout) content += '\n' + rec.stdout;
|
||
if (rec.stderr) content += '\n<span class="k">--- stderr ---</span>\n' + rec.stderr;
|
||
out.innerHTML = content;
|
||
} catch (e) {
|
||
out.innerHTML = `<span class="err">[ERROR]</span> ${e.message}`;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
$('tester-invoke').addEventListener('click', invoke);
|
||
$('tester-reset').addEventListener('click', resetCold);
|
||
$('tester-refresh').addEventListener('click', loadFunctions);
|
||
$('tester-function').addEventListener('change', () => { renderEvents(); loadScripts(); });
|
||
$('tester-event-sample').addEventListener('change', (e) => loadEvent(e.target.value));
|
||
$('script-run').addEventListener('click', runScript);
|
||
loadFunctions();
|
||
loadHistory();
|
||
loadPackaging();
|
||
});
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|