Files
lambda_local_runner/docs/index.html
2026-05-13 17:23:25 -03:00

991 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &amp; 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 &amp; 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>