// logs.js — Crank log viewer document.addEventListener('DOMContentLoaded', function () { // ── Helpers ──────────────────────────────────────────────────────────────── function minsAgo(mins) { return new Date(Date.now() - mins * 60 * 1000); } function fmtTs(date) { var d = (date instanceof Date) ? date : new Date(date); var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; return ( d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3) ); } // ── Seed log data ────────────────────────────────────────────────────────── // Timestamps are computed relative to Date.now() so time-range filtering works. var LOGS = [ { id: 1, ts: minsAgo(2), level: 'info', op: 'create_crm_lead', msg: 'Tool invoked — POST /v1/leads', duration: '142ms', status: 201, detail: { req: '{"first_name":"Alice","last_name":"Smith","email":"alice@acme.com"}', res: '{"lead_id":"ld_9f3a","status":"new","created_at":"2026-03-27T14:32:11Z"}' } }, { id: 2, ts: minsAgo(5), level: 'info', op: 'get_user_profile', msg: 'Tool invoked — GET /v1/users/me', duration: '38ms', status: 200, detail: { req: '{"user_id":"usr_0042"}', res: '{"id":"usr_0042","name":"Alice Smith","email":"alice@acme.com","role":"admin"}' } }, { id: 3, ts: minsAgo(12), level: 'warn', op: 'send_slack_message', msg: 'Rate limited by Slack API — retrying after back-off', duration: '1842ms', status: 429, detail: { req: '{"channel":"#ops","text":"Deploy finished"}', res: '{"error":"ratelimited","retry_after":30}' } }, { id: 4, ts: minsAgo(18), level: 'info', op: 'fetch_invoice', msg: 'Tool invoked — GET /invoices/inv_003', duration: '95ms', status: 200, detail: { req: '{"invoice_id":"inv_003"}', res: '{"id":"inv_003","amount":2400,"currency":"USD","status":"paid"}' } }, { id: 5, ts: minsAgo(26), level: 'error', op: 'stream_telemetry', msg: 'Upstream unavailable', duration: '0ms', status: 503, detail: { req: '{"stream_id":"st_881","since":"2026-03-27T14:00:00Z"}', res: '503 Service Unavailable\nRetry-After: 60' } }, { id: 6, ts: minsAgo(38), level: 'info', op: 'create_crm_lead', msg: 'Tool invoked — POST /v1/leads', duration: '167ms', status: 201, detail: { req: '{"first_name":"Bob","last_name":"Jones","email":"bob@example.com"}', res: '{"lead_id":"ld_9f3b","status":"new"}' } }, { id: 7, ts: minsAgo(52), level: 'debug', op: 'get_user_profile', msg: 'Schema validation passed — 4 fields mapped', duration: '41ms', status: 200, detail: { req: '{"user_id":"usr_0041"}', res: '{"id":"usr_0041","name":"Bob Jones","email":"bob@example.com","role":"viewer"}' } }, { id: 8, ts: minsAgo(75), level: 'info', op: 'list_products', msg: 'Tool invoked — GET /api/products', duration: '210ms', status: 200, detail: { req: '{"limit":20,"page":1}', res: '{"total":142,"items":[{"id":"p01","name":"Mechanical Keyboard","price":149.99},...]}' } }, { id: 9, ts: minsAgo(110), level: 'info', op: 'create_support_ticket', msg: 'Tool invoked — POST /v1/tickets', duration: '178ms', status: 201, detail: { req: '{"subject":"Login issue","priority":"high","user_id":"usr_0042"}', res: '{"ticket_id":"tkt_5521","status":"open","created_at":"2026-03-27T12:42:11Z"}' } }, { id: 10, ts: minsAgo(190), level: 'error', op: 'send_slack_message', msg: 'Upstream returned 503 Service Unavailable — retried 3×', duration: '0ms', status: 503, detail: { req: '{"channel":"#alerts","text":"DB failover triggered"}', res: '503 Service Unavailable\nRetry-After: 30' } }, { id: 11, ts: minsAgo(280), level: 'info', op: 'fetch_invoice', msg: 'Tool invoked — GET /invoices/inv_002', duration: '88ms', status: 200, detail: { req: '{"invoice_id":"inv_002"}', res: '{"id":"inv_002","amount":1250,"currency":"USD","status":"open"}' } }, { id: 12, ts: minsAgo(420), level: 'warn', op: 'get_order_status', msg: 'Validation error: missing field order_id', duration: '55ms', status: 422, detail: { req: '{"customer_id":"cus_009"}', res: '{"error":"validation_failed","fields":["order_id"]}' } }, { id: 13, ts: minsAgo(720), level: 'info', op: 'create_crm_lead', msg: 'Tool invoked — POST /v1/leads', duration: '155ms', status: 200, detail: { req: '{"first_name":"Carol","last_name":"Davis","email":"carol@co.io"}', res: '{"lead_id":"ld_9f3c","status":"new"}' } }, { id: 14, ts: minsAgo(2200), level: 'info', op: 'sync_contacts', msg: 'Tool invoked — POST /v1/contacts/sync', duration: '340ms', status: 200, detail: { req: '{"source":"hubspot","since":"2026-03-25T00:00:00Z"}', res: '{"synced":48,"skipped":2,"errors":0}' } }, { id: 15, ts: minsAgo(7200), level: 'debug', op: 'list_products', msg: 'Cache miss — fetching from origin', duration: '198ms', status: 200, detail: { req: '{"limit":10,"page":1}', res: '{"total":142,"items":[...]}' } } ]; // ── State ────────────────────────────────────────────────────────────────── var levelFilter = 'all'; var searchText = ''; var timeRange = '7d'; var openId = null; var liveMode = true; var liveTimer = null; var nextId = 100; // ── DOM refs ─────────────────────────────────────────────────────────────── var logList = document.getElementById('log-list'); var logSearch = document.getElementById('log-search'); var refreshBtn = document.getElementById('refresh-btn'); var timeRangeSel = document.getElementById('time-range'); var liveDot = document.querySelector('.live-dot'); var liveLabel = document.querySelector('.live-label'); // ── Debug chip injection ─────────────────────────────────────────────────── // The HTML has All/Info/Warn/Error chips. We add Debug after Error. var chipError = document.getElementById('chip-error'); if (chipError && !document.getElementById('chip-debug')) { var debugChip = document.createElement('button'); debugChip.className = 'filter-chip'; debugChip.setAttribute('data-level', 'debug'); debugChip.id = 'chip-debug'; var debugSpan = document.createElement('span'); debugSpan.className = 'log-level debug'; debugSpan.style.cssText = 'padding:0 4px;font-size:10px;'; debugSpan.textContent = 'DEBUG'; debugChip.appendChild(debugSpan); chipError.parentNode.insertBefore(debugChip, chipError.nextSibling); } // ── Time-range helpers ───────────────────────────────────────────────────── function rangeMs(range) { var map = { '30m': 30, '1h': 60, '6h': 360, '24h': 1440, '7d': 10080 }; var mins = map[range] || 10080; return mins * 60 * 1000; } // ── Render ───────────────────────────────────────────────────────────────── function statusClass(s) { if (!s) return ''; if (s < 300) return 'ok'; if (s < 500) return 'warn'; return 'err'; } function escText(str) { // Safely return string; all user-facing dynamic strings go through textContent, // but for pre blocks in detail we use textContent assignment too (see below). return str; } function renderLogs() { var q = searchText.toLowerCase(); var cutoff = Date.now() - rangeMs(timeRange); var rows = LOGS.filter(function (l) { var ts = (l.ts instanceof Date) ? l.ts.getTime() : new Date(l.ts).getTime(); var matchTime = ts >= cutoff; var matchLevel = levelFilter === 'all' || l.level === levelFilter; var matchSearch = !q || l.msg.toLowerCase().indexOf(q) !== -1 || l.op.indexOf(q) !== -1; return matchTime && matchLevel && matchSearch; }); // Build DOM nodes instead of innerHTML for XSS safety where data is dynamic. // For static/controlled detail payloads we still use textContent on pre elements. var frag = document.createDocumentFragment(); if (!rows.length) { var empty = document.createElement('div'); empty.style.cssText = 'text-align:center;padding:40px;color:var(--text-muted);font-size:13.5px;'; empty.textContent = 'No log entries match the current filter'; frag.appendChild(empty); } rows.forEach(function (l) { var tsDate = (l.ts instanceof Date) ? l.ts : new Date(l.ts); var timeStr = fmtTs(tsDate).slice(11, 23); // HH:MM:SS.mmm // ── Row ── var row = document.createElement('div'); row.className = 'log-entry'; row.setAttribute('data-id', l.id); var timeEl = document.createElement('span'); timeEl.className = 'log-time'; timeEl.textContent = timeStr; row.appendChild(timeEl); var levelEl = document.createElement('span'); levelEl.className = 'log-level ' + l.level; levelEl.textContent = l.level.toUpperCase(); row.appendChild(levelEl); var msgDiv = document.createElement('div'); msgDiv.className = 'log-msg'; var opSpan = document.createElement('span'); opSpan.className = 'log-op-name'; opSpan.textContent = l.op; msgDiv.appendChild(opSpan); msgDiv.appendChild(document.createTextNode('\u00a0\u00a0')); // two nbsps if (l.status) { var statusEl = document.createElement('span'); statusEl.className = 'log-status ' + statusClass(l.status); statusEl.textContent = l.status; msgDiv.appendChild(statusEl); msgDiv.appendChild(document.createTextNode(' \u00b7 ')); // · separator } var msgText = document.createTextNode(l.msg); msgDiv.appendChild(msgText); row.appendChild(msgDiv); var durEl = document.createElement('span'); var durSlow = (l.duration.indexOf('s') !== -1 && parseFloat(l.duration) > 1); var durClass = durSlow ? 'slow' : (l.level === 'error' ? 'error' : ''); durEl.className = 'log-duration' + (durClass ? ' ' + durClass : ''); durEl.textContent = l.duration; row.appendChild(durEl); frag.appendChild(row); // ── Expanded detail ── if (l.detail) { var expDiv = document.createElement('div'); expDiv.className = 'log-entry-expanded' + (l.id === openId ? ' open' : ''); expDiv.setAttribute('data-exp', l.id); if (l.detail.req) { var reqLabel = document.createElement('div'); reqLabel.className = 'log-detail-label'; reqLabel.textContent = 'Request body'; expDiv.appendChild(reqLabel); var reqPre = document.createElement('pre'); reqPre.className = 'log-detail-block'; reqPre.textContent = l.detail.req; expDiv.appendChild(reqPre); } if (l.detail.res) { var resLabel = document.createElement('div'); resLabel.className = 'log-detail-label'; resLabel.textContent = 'Response'; expDiv.appendChild(resLabel); var resPre = document.createElement('pre'); resPre.className = 'log-detail-block'; resPre.textContent = l.detail.res; expDiv.appendChild(resPre); } frag.appendChild(expDiv); } }); logList.innerHTML = ''; logList.appendChild(frag); // Attach click handlers for expand/collapse logList.querySelectorAll('.log-entry').forEach(function (el) { el.addEventListener('click', function () { var id = parseInt(this.getAttribute('data-id'), 10); openId = (openId === id) ? null : id; logList.querySelectorAll('.log-entry-expanded').forEach(function (exp) { var expId = parseInt(exp.getAttribute('data-exp'), 10); exp.classList.toggle('open', expId === openId); }); }); }); } // ── Level chip wiring ────────────────────────────────────────────────────── function wireChips() { document.querySelectorAll('.filter-chip[data-level]').forEach(function (btn) { btn.addEventListener('click', function () { levelFilter = this.getAttribute('data-level'); document.querySelectorAll('.filter-chip[data-level]').forEach(function (b) { b.classList.remove('active'); }); this.classList.add('active'); renderLogs(); }); }); } wireChips(); // ── Search ───────────────────────────────────────────────────────────────── logSearch.addEventListener('input', function () { searchText = this.value; renderLogs(); }); // ── Refresh ──────────────────────────────────────────────────────────────── refreshBtn.addEventListener('click', renderLogs); // ── Time-range select ────────────────────────────────────────────────────── // The HTML uses a