Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add lith function stats dashboard to silo

Instruments lith with per-function call/error/latency tracking and
exposes /lith/stats, /lith/errors, /lith/requests APIs. Adds a "lith"
tab to the silo dashboard with live function bar chart, error log,
and request log — our own Netlify functions dashboard replacement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+260 -1
+69 -1
lith/server.mjs
··· 57 57 const HAS_SSL = existsSync(SSL_CERT) && existsSync(SSL_KEY); 58 58 59 59 const app = express(); 60 + const BOOT_TIME = Date.now(); 61 + 62 + // --- Function stats & error log --- 63 + const fnStats = {}; // { fnName: { calls, errors, totalMs, lastCall, lastError } } 64 + const errorLog = []; // [{ time, fn, status, error, path, method }] 65 + const requestLog = []; // [{ time, fn, ms, status, path, method }] 66 + const MAX_ERROR_LOG = 500; 67 + const MAX_REQUEST_LOG = 1000; 68 + 69 + function recordCall(name, ms, status, path, method, error) { 70 + if (!fnStats[name]) fnStats[name] = { calls: 0, errors: 0, totalMs: 0, lastCall: null, lastError: null }; 71 + const s = fnStats[name]; 72 + s.calls++; 73 + s.totalMs += ms; 74 + s.lastCall = new Date().toISOString(); 75 + 76 + requestLog.unshift({ time: s.lastCall, fn: name, ms: Math.round(ms), status, path, method }); 77 + if (requestLog.length > MAX_REQUEST_LOG) requestLog.length = MAX_REQUEST_LOG; 78 + 79 + if (error || status >= 500) { 80 + s.errors++; 81 + s.lastError = new Date().toISOString(); 82 + errorLog.unshift({ time: s.lastError, fn: name, status, error: error || `HTTP ${status}`, path, method }); 83 + if (errorLog.length > MAX_ERROR_LOG) errorLog.length = MAX_ERROR_LOG; 84 + } 85 + } 60 86 61 87 // --- Body parsing --- 62 88 app.use(express.json({ limit: "50mb" })); ··· 147 173 async function handleFunction(req, res) { 148 174 const name = req.params.fn; 149 175 const handler = functions[name]; 150 - if (!handler) return res.status(404).send("Function not found: " + name); 176 + if (!handler) { 177 + recordCall(name || "unknown", 0, 404, req.path, req.method, "Function not found"); 178 + return res.status(404).send("Function not found: " + name); 179 + } 151 180 181 + const t0 = Date.now(); 152 182 try { 153 183 const event = toEvent(req); 154 184 const context = { clientContext: {} }; 155 185 const result = await handler(event, context); 156 186 157 187 const statusCode = result.statusCode || 200; 188 + const ms = Date.now() - t0; 189 + recordCall(name, ms, statusCode, req.path, req.method, statusCode >= 500 ? result.body : null); 190 + 158 191 if (result.headers) res.set(result.headers); 159 192 if (result.multiValueHeaders) { 160 193 for (const [k, vals] of Object.entries(result.multiValueHeaders)) { ··· 185 218 res.status(statusCode).send(result.body); 186 219 } 187 220 } catch (err) { 221 + const ms = Date.now() - t0; 222 + recordCall(name, ms, 500, req.path, req.method, err.message); 188 223 console.error(`fn/${name} error:`, err); 189 224 res.status(500).send("Internal Server Error"); 190 225 } ··· 242 277 } 243 278 244 279 // --- Routes --- 280 + 281 + // --- Lith stats API (consumed by silo dashboard) --- 282 + app.get("/lith/stats", (req, res) => { 283 + const uptime = Math.floor((Date.now() - BOOT_TIME) / 1000); 284 + const mem = process.memoryUsage(); 285 + const sorted = Object.entries(fnStats) 286 + .map(([name, s]) => ({ name, ...s, avgMs: s.calls ? Math.round(s.totalMs / s.calls) : 0 })) 287 + .sort((a, b) => b.calls - a.calls); 288 + 289 + res.json({ 290 + uptime, 291 + boot: new Date(BOOT_TIME).toISOString(), 292 + functionsLoaded: Object.keys(functions).length, 293 + memory: { rss: Math.round(mem.rss / 1048576), heap: Math.round(mem.heapUsed / 1048576) }, 294 + totals: { 295 + calls: sorted.reduce((s, f) => s + f.calls, 0), 296 + errors: sorted.reduce((s, f) => s + f.errors, 0), 297 + }, 298 + functions: sorted, 299 + }); 300 + }); 301 + 302 + app.get("/lith/errors", (req, res) => { 303 + const limit = Math.min(parseInt(req.query.limit) || 100, MAX_ERROR_LOG); 304 + res.json({ errors: errorLog.slice(0, limit), total: errorLog.length }); 305 + }); 306 + 307 + app.get("/lith/requests", (req, res) => { 308 + const limit = Math.min(parseInt(req.query.limit) || 100, MAX_REQUEST_LOG); 309 + const fn = req.query.fn; 310 + const filtered = fn ? requestLog.filter((r) => r.fn === fn) : requestLog; 311 + res.json({ requests: filtered.slice(0, limit), total: filtered.length }); 312 + }); 245 313 246 314 // --- /media/* handler (ports Netlify edge function media.js) --- 247 315 app.all("/media/*rest", async (req, res) => {
+167
silo/dashboard.html
··· 314 314 <button class="tab-btn" data-tab="5">firehose</button> 315 315 <button class="tab-btn" data-tab="6">feed</button> 316 316 <button class="tab-btn" data-tab="7">telemetry</button> 317 + <button class="tab-btn" data-tab="8">lith</button> 317 318 </div> 318 319 319 320 <div class="panels"> ··· 556 557 <div id="tl-recent" style="font-size:11px;color:var(--fg2);max-height:400px;overflow-y:auto">loading...</div> 557 558 </div> 558 559 </div> 560 + 561 + <!-- lith panel --> 562 + <div class="panel" data-panel="8"> 563 + <div class="overview-grid" id="lith-overview"> 564 + <div class="card"> 565 + <div class="card-hd">lith status</div> 566 + <div class="kv"><span class="k">uptime</span><span class="v" id="lith-uptime">-</span></div> 567 + <div class="kv"><span class="k">boot</span><span class="v" id="lith-boot">-</span></div> 568 + <div class="kv"><span class="k">functions loaded</span><span class="v" id="lith-fn-count">-</span></div> 569 + <div class="kv"><span class="k">memory (RSS / heap)</span><span class="v" id="lith-mem">-</span></div> 570 + <div class="kv"><span class="k">total calls</span><span class="v" id="lith-total-calls">-</span></div> 571 + <div class="kv"><span class="k">total errors</span><span class="v" id="lith-total-errors">-</span></div> 572 + </div> 573 + <div class="card"> 574 + <div class="card-hd">top functions (by calls)</div> 575 + <div id="lith-top-fns" style="font-size:11px;max-height:300px;overflow-y:auto">loading...</div> 576 + </div> 577 + </div> 578 + 579 + <div style="display:flex;gap:4px;padding:4px 6px;border-bottom:1px solid var(--border)"> 580 + <button class="sub-tab-btn active" onclick="lithSubTab(this,'lith-errors-panel')">errors</button> 581 + <button class="sub-tab-btn" onclick="lithSubTab(this,'lith-requests-panel')">recent requests</button> 582 + </div> 583 + 584 + <div id="lith-errors-panel" class="sub-panel active" style="display:block;padding:6px;overflow-y:auto;max-height:calc(100vh - 360px)"> 585 + <table style="width:100%;font-size:11px;border-collapse:collapse" id="lith-errors-table"> 586 + <thead><tr style="color:var(--fg2);text-align:left"> 587 + <th style="padding:2px 6px">time</th> 588 + <th style="padding:2px 6px">function</th> 589 + <th style="padding:2px 6px">status</th> 590 + <th style="padding:2px 6px">path</th> 591 + <th style="padding:2px 6px">error</th> 592 + </tr></thead> 593 + <tbody id="lith-errors-body"></tbody> 594 + </table> 595 + </div> 596 + 597 + <div id="lith-requests-panel" class="sub-panel" style="display:none;padding:6px;overflow-y:auto;max-height:calc(100vh - 360px)"> 598 + <table style="width:100%;font-size:11px;border-collapse:collapse" id="lith-requests-table"> 599 + <thead><tr style="color:var(--fg2);text-align:left"> 600 + <th style="padding:2px 6px">time</th> 601 + <th style="padding:2px 6px">function</th> 602 + <th style="padding:2px 6px">ms</th> 603 + <th style="padding:2px 6px">status</th> 604 + <th style="padding:2px 6px">path</th> 605 + </tr></thead> 606 + <tbody id="lith-requests-body"></tbody> 607 + </table> 608 + </div> 609 + </div> 610 + 559 611 </div> 560 612 </div> 561 613 ··· 1875 1927 setInterval(loadInstaStatus, 60000); 1876 1928 setInterval(loadTiktokStatus, 60000); 1877 1929 } 1930 + 1931 + // --- Lith dashboard --- 1932 + const LITH_URL = 'https://aesthetic.computer'; 1933 + let lithTimer = null; 1934 + 1935 + function lithSubTab(btn, panelId) { 1936 + btn.parentElement.querySelectorAll('.sub-tab-btn').forEach(b => b.classList.remove('active')); 1937 + btn.classList.add('active'); 1938 + document.getElementById('lith-errors-panel').style.display = 'none'; 1939 + document.getElementById('lith-requests-panel').style.display = 'none'; 1940 + document.getElementById(panelId).style.display = 'block'; 1941 + } 1942 + 1943 + function fmtUptime(s) { 1944 + if (s < 60) return s + 's'; 1945 + if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's'; 1946 + const h = Math.floor(s/3600); 1947 + return h + 'h ' + Math.floor((s%3600)/60) + 'm'; 1948 + } 1949 + 1950 + function fmtTime(iso) { 1951 + if (!iso) return '-'; 1952 + const d = new Date(iso); 1953 + return d.toLocaleTimeString('en-US', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3,'0'); 1954 + } 1955 + 1956 + async function loadLith() { 1957 + try { 1958 + const [statsRes, errorsRes, reqsRes] = await Promise.all([ 1959 + fetch(LITH_URL + '/lith/stats'), 1960 + fetch(LITH_URL + '/lith/errors?limit=100'), 1961 + fetch(LITH_URL + '/lith/requests?limit=100'), 1962 + ]); 1963 + const stats = await statsRes.json(); 1964 + const errors = await errorsRes.json(); 1965 + const reqs = await reqsRes.json(); 1966 + 1967 + // Overview 1968 + document.getElementById('lith-uptime').textContent = fmtUptime(stats.uptime); 1969 + document.getElementById('lith-boot').textContent = fmtTime(stats.boot); 1970 + document.getElementById('lith-fn-count').textContent = stats.functionsLoaded; 1971 + document.getElementById('lith-mem').textContent = stats.memory.rss + ' MB / ' + stats.memory.heap + ' MB'; 1972 + document.getElementById('lith-total-calls').textContent = fmt(stats.totals.calls); 1973 + document.getElementById('lith-total-errors').textContent = stats.totals.errors; 1974 + document.getElementById('lith-total-errors').style.color = stats.totals.errors > 0 ? 'var(--err)' : 'var(--ok)'; 1975 + 1976 + // Top functions 1977 + const topEl = document.getElementById('lith-top-fns'); 1978 + if (stats.functions.length === 0) { 1979 + topEl.textContent = 'no calls yet'; 1980 + } else { 1981 + const maxCalls = stats.functions[0]?.calls || 1; 1982 + topEl.innerHTML = stats.functions.slice(0, 30).map(f => { 1983 + const pct = Math.max(2, Math.round(f.calls / maxCalls * 100)); 1984 + const errStyle = f.errors > 0 ? 'color:var(--err)' : 'color:var(--fg2)'; 1985 + return '<div style="display:flex;align-items:center;gap:6px;padding:1px 0">' + 1986 + '<span style="width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(f.name) + '</span>' + 1987 + '<div style="flex:1;height:8px;background:var(--bg);border-radius:2px;overflow:hidden">' + 1988 + '<div style="width:' + pct + '%;height:100%;background:var(--accent);border-radius:2px"></div>' + 1989 + '</div>' + 1990 + '<span style="width:50px;text-align:right">' + fmt(f.calls) + '</span>' + 1991 + '<span style="width:40px;text-align:right;' + errStyle + '">' + f.errors + 'e</span>' + 1992 + '<span style="width:45px;text-align:right;color:var(--fg2)">' + f.avgMs + 'ms</span>' + 1993 + '</div>'; 1994 + }).join(''); 1995 + } 1996 + 1997 + // Errors table 1998 + const errBody = document.getElementById('lith-errors-body'); 1999 + if (errors.errors.length === 0) { 2000 + errBody.innerHTML = '<tr><td colspan="5" style="padding:8px;color:var(--fg2)">no errors</td></tr>'; 2001 + } else { 2002 + errBody.innerHTML = errors.errors.map(e => { 2003 + const errMsg = (e.error || '').substring(0, 120); 2004 + return '<tr style="border-bottom:1px solid var(--border)">' + 2005 + '<td style="padding:2px 6px;white-space:nowrap;color:var(--fg2)">' + fmtTime(e.time) + '</td>' + 2006 + '<td style="padding:2px 6px;color:var(--accent)">' + esc(e.fn) + '</td>' + 2007 + '<td style="padding:2px 6px;color:var(--err)">' + e.status + '</td>' + 2008 + '<td style="padding:2px 6px;color:var(--fg2)">' + esc(e.path || '') + '</td>' + 2009 + '<td style="padding:2px 6px;color:var(--fg2);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(errMsg) + '</td>' + 2010 + '</tr>'; 2011 + }).join(''); 2012 + } 2013 + 2014 + // Requests table 2015 + const reqBody = document.getElementById('lith-requests-body'); 2016 + if (reqs.requests.length === 0) { 2017 + reqBody.innerHTML = '<tr><td colspan="5" style="padding:8px;color:var(--fg2)">no requests yet</td></tr>'; 2018 + } else { 2019 + reqBody.innerHTML = reqs.requests.map(r => { 2020 + const statusColor = r.status >= 500 ? 'var(--err)' : r.status >= 400 ? 'var(--warn)' : 'var(--fg)'; 2021 + const msColor = r.ms > 1000 ? 'var(--warn)' : r.ms > 3000 ? 'var(--err)' : 'var(--fg2)'; 2022 + return '<tr style="border-bottom:1px solid var(--border)">' + 2023 + '<td style="padding:2px 6px;white-space:nowrap;color:var(--fg2)">' + fmtTime(r.time) + '</td>' + 2024 + '<td style="padding:2px 6px;color:var(--accent)">' + esc(r.fn) + '</td>' + 2025 + '<td style="padding:2px 6px;color:' + msColor + '">' + r.ms + '</td>' + 2026 + '<td style="padding:2px 6px;color:' + statusColor + '">' + r.status + '</td>' + 2027 + '<td style="padding:2px 6px;color:var(--fg2)">' + esc(r.path || '') + '</td>' + 2028 + '</tr>'; 2029 + }).join(''); 2030 + } 2031 + } catch (err) { 2032 + document.getElementById('lith-uptime').textContent = 'offline'; 2033 + document.getElementById('lith-uptime').style.color = 'var(--err)'; 2034 + console.error('lith fetch error:', err); 2035 + } 2036 + } 2037 + 2038 + // Start polling lith when tab is selected 2039 + document.addEventListener('click', e => { 2040 + if (e.target.matches('[data-tab="8"]')) { 2041 + loadLith(); 2042 + if (!lithTimer) lithTimer = setInterval(loadLith, 10000); 2043 + } 2044 + }); 1878 2045 1879 2046 initAuth(); 1880 2047 </script>
+24
silo/server.mjs
··· 1262 1262 } catch (err) { res.json({ status: "unreachable", error: err.message }); } 1263 1263 }); 1264 1264 1265 + // --- Lith stats proxy --- 1266 + const LITH_URL = process.env.LITH_URL || "https://aesthetic.computer"; 1267 + 1268 + app.get("/api/services/lith/stats", async (req, res) => { 1269 + try { 1270 + const resp = await fetch(`${LITH_URL}/lith/stats`, { signal: AbortSignal.timeout(5000) }); 1271 + res.json(await resp.json()); 1272 + } catch (err) { res.json({ status: "unreachable", error: err.message }); } 1273 + }); 1274 + 1275 + app.get("/api/services/lith/errors", async (req, res) => { 1276 + try { 1277 + const resp = await fetch(`${LITH_URL}/lith/errors?limit=${req.query.limit || 100}`, { signal: AbortSignal.timeout(5000) }); 1278 + res.json(await resp.json()); 1279 + } catch (err) { res.json({ errors: [], error: err.message }); } 1280 + }); 1281 + 1282 + app.get("/api/services/lith/requests", async (req, res) => { 1283 + try { 1284 + const resp = await fetch(`${LITH_URL}/lith/requests?limit=${req.query.limit || 100}`, { signal: AbortSignal.timeout(5000) }); 1285 + res.json(await resp.json()); 1286 + } catch (err) { res.json({ requests: [], error: err.message }); } 1287 + }); 1288 + 1265 1289 app.get("/api/services/billing", async (req, res) => { 1266 1290 const billingUrl = process.env.BILLING_URL || "https://aesthetic.computer/api/billing"; 1267 1291 try {