488 lines
18 KiB
JavaScript
488 lines
18 KiB
JavaScript
// 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 <select id="time-range"> with option values 30m/1h/6h/24h/7d.
|
||
// Default the select to "7d" on page load.
|
||
|
||
if (timeRangeSel) {
|
||
// Set initial value to 7d
|
||
timeRangeSel.value = '7d';
|
||
timeRange = '7d';
|
||
|
||
timeRangeSel.addEventListener('change', function () {
|
||
timeRange = this.value;
|
||
renderLogs();
|
||
});
|
||
}
|
||
|
||
// ── Live mode ──────────────────────────────────────────────────────────────
|
||
|
||
var LIVE_OPS = ['create_crm_lead', 'get_user_profile', 'fetch_invoice', 'list_products'];
|
||
var LIVE_MSGS = {
|
||
'create_crm_lead': 'Tool invoked — POST /v1/leads',
|
||
'get_user_profile': 'Tool invoked — GET /v1/users/me',
|
||
'fetch_invoice': 'Tool invoked — GET /invoices/inv_live',
|
||
'list_products': 'Tool invoked — GET /api/products'
|
||
};
|
||
var LIVE_STATUSES = { 'info': 200, 'warn': 429, 'error': 503, 'debug': 200 };
|
||
|
||
function pickLiveLevel() {
|
||
var r = Math.random();
|
||
if (r < 0.70) return 'info';
|
||
if (r < 0.85) return 'warn';
|
||
if (r < 0.95) return 'error';
|
||
return 'debug';
|
||
}
|
||
|
||
function makeLiveEntry() {
|
||
var op = LIVE_OPS[Math.floor(Math.random() * LIVE_OPS.length)];
|
||
var level = pickLiveLevel();
|
||
var status = LIVE_STATUSES[level];
|
||
var duration = (level === 'error') ? '0ms' : (Math.floor(Math.random() * 400) + 20) + 'ms';
|
||
nextId += 1;
|
||
return {
|
||
id: nextId,
|
||
ts: new Date(),
|
||
level: level,
|
||
op: op,
|
||
msg: LIVE_MSGS[op],
|
||
duration: duration,
|
||
status: status,
|
||
detail: {
|
||
req: '{"live":true}',
|
||
res: level === 'error' ? status + ' Service Unavailable' : '{"ok":true}'
|
||
}
|
||
};
|
||
}
|
||
|
||
function setLivePulsing(on) {
|
||
if (!liveDot) return;
|
||
if (on) {
|
||
liveDot.style.animationPlayState = 'running';
|
||
liveDot.style.opacity = '';
|
||
} else {
|
||
liveDot.style.animationPlayState = 'paused';
|
||
liveDot.style.opacity = '0.35';
|
||
}
|
||
}
|
||
|
||
function updateLiveLabel() {
|
||
if (!liveLabel) return;
|
||
liveLabel.textContent = liveMode ? 'Live' : 'Paused';
|
||
liveLabel.style.color = liveMode ? '' : 'var(--text-muted)';
|
||
}
|
||
|
||
function startLive() {
|
||
if (liveTimer) return;
|
||
liveTimer = setInterval(function () {
|
||
LOGS.unshift(makeLiveEntry());
|
||
if (LOGS.length > 100) {
|
||
LOGS.length = 100;
|
||
}
|
||
renderLogs();
|
||
}, 4000);
|
||
}
|
||
|
||
function stopLive() {
|
||
if (liveTimer) {
|
||
clearInterval(liveTimer);
|
||
liveTimer = null;
|
||
}
|
||
}
|
||
|
||
function toggleLive() {
|
||
liveMode = !liveMode;
|
||
setLivePulsing(liveMode);
|
||
updateLiveLabel();
|
||
if (liveMode) {
|
||
startLive();
|
||
} else {
|
||
stopLive();
|
||
}
|
||
}
|
||
|
||
// Wire live toggle to clicking the dot or the label
|
||
if (liveDot) {
|
||
liveDot.style.cursor = 'pointer';
|
||
liveDot.addEventListener('click', toggleLive);
|
||
}
|
||
if (liveLabel) {
|
||
liveLabel.style.cursor = 'pointer';
|
||
liveLabel.addEventListener('click', toggleLive);
|
||
}
|
||
|
||
// Also support a dedicated live-toggle button if one exists in the DOM
|
||
var liveToggleBtn = document.getElementById('live-toggle') || document.querySelector('.live-btn');
|
||
if (liveToggleBtn) {
|
||
liveToggleBtn.addEventListener('click', toggleLive);
|
||
}
|
||
|
||
// ── Initialise ─────────────────────────────────────────────────────────────
|
||
|
||
setLivePulsing(true);
|
||
updateLiveLabel();
|
||
startLive();
|
||
renderLogs();
|
||
|
||
});
|