Files
crank/apps/ui/js/logs.js
T
2026-03-30 00:15:02 +03:00

488 lines
18 KiB
JavaScript
Raw 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.
// 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();
});