Files
logviewer/web/static/app.js
T
unkin-agent abc81e60c8
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
Add logviewer: web UI for the ClickHouse log store
Single Go binary with embedded Bootstrap 3 + jQuery UI, querying logs.raw
over the ClickHouse HTTP interface as the readonly logreader user. Runs
behind oauth2-proxy; the app does no auth itself. Server-side enforced
time bounds (15m default, 72h max), parameterized queries, raw-SQL WHERE
fragment wrapped with enforced bounds and LIMIT, tail polling with a
clamped cursor, facets, healthz. Woodpecker build/test plus tag-driven
image push to artifactapi docker-internal.
2026-08-23 16:39:22 +10:00

254 lines
7.7 KiB
JavaScript

(function () {
'use strict';
var state = {
range: '15m',
offset: 0,
limit: 100,
tail: false,
tailTimer: null,
tailCursor: null,
tailPaused: false,
lastCount: 0
};
var SEV_CLASS = {
fatal: 'label-danger', crit: 'label-danger', critical: 'label-danger',
error: 'label-danger', err: 'label-danger',
warn: 'label-warning', warning: 'label-warning',
info: 'label-info',
debug: 'label-default', trace: 'label-default'
};
function sevLabel(sev) {
var cls = SEV_CLASS[(sev || '').toLowerCase()] || 'label-default';
return $('<span>').addClass('label ' + cls).text(sev || '-');
}
function timeParams() {
var p = {};
if (state.range === 'custom') {
var s = $('#since-input').val(), u = $('#until-input').val();
if (s) p.since = new Date(s).toISOString();
if (u) p.until = new Date(u).toISOString();
} else {
p.since = state.range;
}
return p;
}
function filterParams() {
var p = {};
var map = {
namespace: '#f-namespace', app: '#f-app', host: '#f-host',
pod: '#f-pod', container: '#f-container',
severity: '#f-severity', stream: '#f-stream'
};
Object.keys(map).forEach(function (k) {
var v = $(map[k]).val();
if (v) p[k] = v;
});
var q = $('#q').val().trim();
if (q) p.q = q;
if ($('#sql-row').is(':visible')) {
var sql = $('#sql').val().trim();
if (sql) p.sql = sql;
}
return p;
}
function setStatus(msg, isError) {
$('#status-text').text(msg || '').toggleClass('text-danger', !!isError);
}
function renderRow(row, tbody) {
var src = (row.namespace || '-') + ' / ' + (row.pod || '-');
var tr = $('<tr>').addClass('log-row')
.append($('<td>').addClass('col-ts').text(row.timestamp))
.append($('<td>').addClass('col-src').text(src))
.append($('<td>').addClass('col-sev').append(sevLabel(row.severity)))
.append($('<td>').addClass('msg').text(row.message));
tr.data('row', row);
tbody.append(tr);
}
function kvTable(title, obj) {
var keys = Object.keys(obj || {});
if (!keys.length) return null;
var t = $('<table>').addClass('table table-condensed detail-table');
t.append($('<caption>').text(title));
keys.sort().forEach(function (k) {
t.append($('<tr>').append($('<th>').text(k)).append($('<td>').text(obj[k])));
});
return t;
}
function toggleDetail(tr) {
var next = tr.next();
if (next.hasClass('detail-row')) { next.remove(); return; }
var row = tr.data('row');
var cell = $('<td>').attr('colspan', 4);
var meta = {
host: row.host, source: row.source, namespace: row.namespace,
pod: row.pod, container: row.container, stream: row.stream
};
[kvTable('meta', meta), kvTable('labels', row.labels), kvTable('fields', row.fields)]
.forEach(function (t) { if (t) cell.append(t); });
tr.after($('<tr>').addClass('detail-row').append(cell));
}
function renderRows(rows, append) {
var tbody = $('#log-body');
if (!append) tbody.empty();
rows.forEach(function (r) { renderRow(r, tbody); });
}
function runQuery() {
var params = $.extend({ limit: state.limit, offset: state.offset }, timeParams(), filterParams());
setStatus('loading...');
$.getJSON('/api/query', params)
.done(function (res) {
renderRows(res.rows, false);
state.lastCount = res.count;
setStatus('');
$('#result-info').text(
res.count + ' rows (' + res.since + ' → ' + res.until + '), offset ' + res.offset);
$('#prev-btn').prop('disabled', state.offset === 0);
$('#next-btn').prop('disabled', res.count < state.limit);
})
.fail(function (xhr) {
setStatus((xhr.responseJSON && xhr.responseJSON.error) || 'query failed', true);
});
}
function tailTick() {
if (state.tailPaused) return;
var params = $.extend({}, filterParams());
if (state.tailCursor !== null) params.cursor = state.tailCursor;
$.getJSON('/api/tail', params)
.done(function (res) {
state.tailCursor = res.cursor;
if (res.rows.length) {
renderRows(res.rows, true);
// keep the DOM bounded during long tails
var body = $('#log-body');
var extra = body.children('.log-row').length - 2000;
if (extra > 0) body.children().slice(0, extra * 2).remove();
if (!state.tailPaused) {
window.scrollTo(0, document.body.scrollHeight);
}
}
setStatus('tailing (cursor ' + res.cursor + ')');
})
.fail(function (xhr) {
setStatus((xhr.responseJSON && xhr.responseJSON.error) || 'tail failed', true);
});
}
function startTail() {
state.tail = true;
state.tailCursor = null;
state.tailPaused = false;
$('#log-body').empty();
$('#tail-toggle').addClass('active');
$('#pager button, #page-size').prop('disabled', true);
state.tailTimer = setInterval(tailTick, 1000);
tailTick();
}
function stopTail() {
state.tail = false;
clearInterval(state.tailTimer);
state.tailTimer = null;
$('#tail-toggle').removeClass('active');
$('#pager button, #page-size').prop('disabled', false);
setStatus('');
}
function refresh() {
if (state.tail) { stopTail(); startTail(); return; }
state.offset = 0;
runQuery();
}
function loadFacets() {
$.getJSON('/api/facets', timeParams()).done(function (res) {
var fill = function (sel, items, placeholder) {
var cur = $(sel).val();
var el = $(sel).empty().append($('<option>').val('').text(placeholder));
(items || []).forEach(function (it) {
el.append($('<option>').val(it.value).text(it.value + ' (' + it.count + ')'));
});
el.val(cur);
if (el.val() === null) el.val('');
};
fill('#f-namespace', res.namespaces, 'namespace');
fill('#f-app', res.apps, 'app');
fill('#f-host', res.hosts, 'host');
});
}
// --- events ---
$('#range-buttons').on('click', '.range-btn', function () {
$('.range-btn').removeClass('active');
$(this).addClass('active');
state.range = $(this).data('range');
$('#custom-range').toggle(state.range === 'custom');
if (state.range !== 'custom') { loadFacets(); refresh(); }
});
$('#custom-range input').on('change', function () {
loadFacets();
refresh();
});
$('#search-btn').on('click', refresh);
$('#search-form').on('change', 'select', refresh);
$('#search-form').on('keypress', 'input', function (e) {
if (e.which === 13) { e.preventDefault(); refresh(); }
});
var debounce = null;
$('#q').on('input', function () {
clearTimeout(debounce);
debounce = setTimeout(refresh, 300);
});
$('#sql-toggle').on('click', function () {
$(this).toggleClass('active');
$('#sql-row').toggle($(this).hasClass('active'));
});
$('#tail-toggle').on('click', function () {
if (state.tail) stopTail(); else startTail();
});
$('#log-body').on('click', 'tr.log-row', function () { toggleDetail($(this)); });
$('#prev-btn').on('click', function () {
state.offset = Math.max(0, state.offset - state.limit);
runQuery();
});
$('#next-btn').on('click', function () {
state.offset += state.limit;
runQuery();
});
$('#page-size').on('change', function () {
state.limit = parseInt($(this).val(), 10);
state.offset = 0;
runQuery();
});
// Pause tail autoscroll when the user scrolls up; resume at the bottom.
$(window).on('scroll', function () {
if (!state.tail) return;
var atBottom = window.innerHeight + window.scrollY >= document.body.scrollHeight - 40;
state.tailPaused = !atBottom;
if (state.tailPaused) setStatus('tail paused (scroll to bottom to resume)');
});
loadFacets();
runQuery();
})();