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.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
// Package web embeds the UI so the server binary is self-contained.
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed index.html static
|
||||
var FS embed.FS
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>logviewer</title>
|
||||
<link rel="stylesheet" href="/static/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/logviewer.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-inverse navbar-static-top">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-header">
|
||||
<a class="navbar-brand" href="/">logviewer</a>
|
||||
</div>
|
||||
<p class="navbar-text navbar-right" id="status-text"></p>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid">
|
||||
<form id="search-form" onsubmit="return false;">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="btn-group" role="group" id="range-buttons">
|
||||
<button type="button" class="btn btn-default range-btn active" data-range="15m">15m</button>
|
||||
<button type="button" class="btn btn-default range-btn" data-range="1h">1h</button>
|
||||
<button type="button" class="btn btn-default range-btn" data-range="6h">6h</button>
|
||||
<button type="button" class="btn btn-default range-btn" data-range="1d">1d</button>
|
||||
<button type="button" class="btn btn-default range-btn" data-range="custom">custom</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="q" placeholder="fuzzy search (space-separated terms, all must match)">
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-primary" type="button" id="search-btn">Search</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 text-right">
|
||||
<button type="button" class="btn btn-default" id="sql-toggle">SQL</button>
|
||||
<button type="button" class="btn btn-default" id="tail-toggle">Tail</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" id="custom-range" style="display:none; margin-top:8px;">
|
||||
<div class="col-md-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon">since</span>
|
||||
<input type="datetime-local" class="form-control" id="since-input" step="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon">until</span>
|
||||
<input type="datetime-local" class="form-control" id="until-input" step="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" id="sql-row" style="display:none; margin-top:8px;">
|
||||
<div class="col-md-12">
|
||||
<textarea class="form-control mono" id="sql" rows="2"
|
||||
placeholder="raw WHERE fragment, e.g. severity = 'error' AND message LIKE '%timeout%' (runs as readonly user, time bounds and LIMIT always enforced)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top:8px;">
|
||||
<div class="col-md-2"><select class="form-control" id="f-namespace"><option value="">namespace</option></select></div>
|
||||
<div class="col-md-2"><select class="form-control" id="f-app"><option value="">app</option></select></div>
|
||||
<div class="col-md-2"><select class="form-control" id="f-host"><option value="">host</option></select></div>
|
||||
<div class="col-md-2"><input type="text" class="form-control" id="f-pod" placeholder="pod"></div>
|
||||
<div class="col-md-1"><input type="text" class="form-control" id="f-container" placeholder="container"></div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-control" id="f-severity">
|
||||
<option value="">severity</option>
|
||||
<option>fatal</option><option>error</option><option>warn</option>
|
||||
<option>info</option><option>debug</option><option>trace</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<select class="form-control" id="f-stream">
|
||||
<option value="">stream</option>
|
||||
<option>stdout</option><option>stderr</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="row" style="margin-top:10px;">
|
||||
<div class="col-md-6">
|
||||
<p id="result-info" class="text-muted"></p>
|
||||
</div>
|
||||
<div class="col-md-6 text-right" id="pager">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-default btn-sm" id="prev-btn" disabled>« newer</button>
|
||||
<button type="button" class="btn btn-default btn-sm" id="next-btn" disabled>older »</button>
|
||||
</div>
|
||||
<select id="page-size" class="btn btn-default btn-sm">
|
||||
<option>100</option><option>200</option><option>500</option><option>1000</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-condensed table-hover" id="log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ts">timestamp (UTC)</th>
|
||||
<th class="col-src">namespace / pod</th>
|
||||
<th class="col-sev">severity</th>
|
||||
<th>message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="log-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script src="/static/jquery.min.js"></script>
|
||||
<script src="/static/bootstrap.min.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,253 @@
|
||||
(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();
|
||||
})();
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Vendored
+6
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
.mono, #log-table td.msg, #log-table td.col-ts, .detail-table td {
|
||||
font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
#log-table td { vertical-align: top; word-break: break-all; }
|
||||
#log-table .col-ts { white-space: nowrap; width: 190px; }
|
||||
#log-table .col-src { width: 260px; }
|
||||
#log-table .col-sev { width: 80px; }
|
||||
#log-table tbody tr.log-row { cursor: pointer; }
|
||||
#log-table tr.detail-row td { background: #f7f7f9; }
|
||||
.detail-table { margin-bottom: 4px; }
|
||||
.detail-table th { width: 160px; }
|
||||
.sev-error, .sev-fatal, .sev-crit { }
|
||||
#tail-toggle.active, #sql-toggle.active { background: #337ab7; color: #fff; }
|
||||
Reference in New Issue
Block a user