Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
392fc645ce | ||
|
|
81806a6991 | ||
|
|
6abf1f47fd | ||
|
|
b3b7041ee9 | ||
|
|
9a1b60fa2b | ||
|
|
a12fac16c8 |
No files matched your search
@@ -10,6 +10,22 @@ devices ──514 udp/tcp──▶ logstream (Go) ──HTTP batches──▶ Vi
|
||||
└──── API / UI ◀─────────┘
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
|
||||
- **Ingestion**: syslog (UDP/TCP) and Docker container logs both go through `sink()` (reverse DNS
|
||||
on IP hosts), then the `Store` queue, which sends them in batches to VictoriaLogs.
|
||||
- **Live view**: `sink()` also publishes each message to the `Hub`, which streams it to the
|
||||
browsers over SSE.
|
||||
- **Search**: the HTTP API turns the UI filters into LogsQL queries sent to VictoriaLogs.
|
||||
- **State**: tags and source settings live in `/data` (`logstream-data` volume); the logs
|
||||
themselves in the `vlogs-data` volume.
|
||||
|
||||
The editable source of the diagram is
|
||||
[`docs/logstream-schema-logique.excalidraw`](docs/logstream-schema-logique.excalidraw)
|
||||
(open it on [excalidraw.com](https://excalidraw.com)).
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
@@ -64,6 +80,12 @@ Severity badges `err`/`crit` and `warning` use the colors of the `error` and `wa
|
||||
|
||||
Shortcuts: `/` focuses the search box, `Esc` clears it. Clicking a row shows all its fields.
|
||||
|
||||
The column headers stay visible while scrolling. Drag the edge of a header (received, message
|
||||
time, severity, host, app) to resize the column, double-click it to go back to the automatic
|
||||
width; the message takes the remaining space. Widths are remembered by the browser
|
||||
(**Settings > Interface > Reset column widths** restores them all). On phones the list keeps its
|
||||
two-line layout without columns.
|
||||
|
||||
## Timeline
|
||||
|
||||
The timeline above the list shows the volume of logs per interval, counted by **reception
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 597 KiB |
+81
@@ -126,6 +126,11 @@ const I18N = {
|
||||
hDivision: (s) => `interval ${s}`, hCapped: 'enlarged',
|
||||
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
|
||||
hUnzoom: '× Reset zoom', unitDay: 'd',
|
||||
toTop: 'Back to top',
|
||||
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colMsg: 'Message',
|
||||
colGrip: 'Drag to resize, double-click for the automatic width',
|
||||
resetCols: 'Reset column widths', colsReset: 'Column widths reset',
|
||||
colsHelp: 'Drag the edge of a column header in the log list to resize it (remembered by this browser).',
|
||||
},
|
||||
fr: {
|
||||
locale: 'fr-FR',
|
||||
@@ -250,6 +255,11 @@ const I18N = {
|
||||
hDivision: (s) => `intervalle ${s}`, hCapped: 'élargi',
|
||||
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
|
||||
hUnzoom: '× Annuler le zoom', unitDay: 'j',
|
||||
toTop: 'Revenir en haut',
|
||||
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colMsg: 'Message',
|
||||
colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique',
|
||||
resetCols: 'Réinitialiser les colonnes', colsReset: 'Largeurs de colonnes réinitialisées',
|
||||
colsHelp: 'Glissez le bord d\'un en-tête de colonne de la liste pour la redimensionner (mémorisé par ce navigateur).',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -762,6 +772,7 @@ function renderList() {
|
||||
addFacetValues(state.rows);
|
||||
list.innerHTML = state.rows.map((r) => rowHTML(r, false)).join('');
|
||||
bindRows(list.firstElementChild, state.rows);
|
||||
$('#table').hidden = !state.rows.length;
|
||||
$('#empty').hidden = state.rows.length > 0 || !$('#error').hidden;
|
||||
}
|
||||
|
||||
@@ -778,6 +789,7 @@ function prependRows(rows) {
|
||||
list.insertAdjacentHTML('afterbegin', rows.map((r) => rowHTML(r, true)).join(''));
|
||||
bindRows(list.firstElementChild, rows);
|
||||
state.rows = rows.concat(state.rows);
|
||||
$('#table').hidden = false;
|
||||
while (state.rows.length > MAX_ROWS) {
|
||||
state.rows.pop();
|
||||
list.lastElementChild?.remove();
|
||||
@@ -858,6 +870,66 @@ list.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' && ev.target.classList.contains('row')) ev.target.click();
|
||||
});
|
||||
|
||||
/* ================= Column widths ================= */
|
||||
|
||||
// Resizable columns of the log list: width in px per column, remembered per
|
||||
// browser (logstream.cols.<name>); no stored width = automatic default (CSS).
|
||||
const COLS = { rcv: [60, 420], mt: [60, 420], sev: [36, 200], host: [50, 600], app: [40, 600] }; // [min, max]
|
||||
const clampCol = (k, w) => Math.min(COLS[k][1], Math.max(COLS[k][0], Math.round(w)));
|
||||
|
||||
function applyColWidths() {
|
||||
const table = $('#table');
|
||||
for (const k of Object.keys(COLS)) {
|
||||
const w = parseInt(store.get('cols.' + k, ''), 10);
|
||||
if (w > 0) table.style.setProperty('--col-' + k, clampCol(k, w) + 'px');
|
||||
else table.style.removeProperty('--col-' + k);
|
||||
}
|
||||
}
|
||||
|
||||
$('#listHead').addEventListener('pointerdown', (ev) => {
|
||||
const grip = ev.target.closest('.grip');
|
||||
if (!grip || ev.button !== 0) return;
|
||||
ev.preventDefault();
|
||||
const k = grip.parentElement.dataset.col;
|
||||
const x0 = ev.clientX;
|
||||
const w0 = grip.parentElement.getBoundingClientRect().width;
|
||||
const table = $('#table');
|
||||
grip.setPointerCapture(ev.pointerId);
|
||||
grip.classList.add('active');
|
||||
document.body.classList.add('col-resizing');
|
||||
const move = (e) => table.style.setProperty('--col-' + k, clampCol(k, w0 + e.clientX - x0) + 'px');
|
||||
const end = () => {
|
||||
grip.removeEventListener('pointermove', move);
|
||||
grip.removeEventListener('pointerup', end);
|
||||
grip.removeEventListener('pointercancel', end);
|
||||
grip.classList.remove('active');
|
||||
document.body.classList.remove('col-resizing');
|
||||
const w = parseInt(table.style.getPropertyValue('--col-' + k), 10);
|
||||
if (w > 0 && w !== Math.round(w0)) store.set('cols.' + k, String(w));
|
||||
};
|
||||
grip.addEventListener('pointermove', move);
|
||||
grip.addEventListener('pointerup', end);
|
||||
grip.addEventListener('pointercancel', end);
|
||||
});
|
||||
// Double-click on a handle: back to the automatic width.
|
||||
$('#listHead').addEventListener('dblclick', (ev) => {
|
||||
const grip = ev.target.closest('.grip');
|
||||
if (!grip) return;
|
||||
store.set('cols.' + grip.parentElement.dataset.col, '');
|
||||
applyColWidths();
|
||||
});
|
||||
$('#resetCols').addEventListener('click', () => {
|
||||
for (const k of Object.keys(COLS)) store.set('cols.' + k, '');
|
||||
applyColWidths();
|
||||
toast(t('colsReset'));
|
||||
});
|
||||
|
||||
// The header sticks right under the top bar, whose height changes with the layout.
|
||||
new ResizeObserver(([e]) => {
|
||||
document.documentElement.style.setProperty('--topbar-h', Math.ceil(e.target.getBoundingClientRect().height) + 'px');
|
||||
}).observe($('.topbar'));
|
||||
applyColWidths();
|
||||
|
||||
/* ================= Timeline (histogram) ================= */
|
||||
|
||||
// Settings > Interface > Timeline, remembered per browser; the first value is the default.
|
||||
@@ -1447,6 +1519,15 @@ $('#newPill').addEventListener('click', () => {
|
||||
});
|
||||
window.addEventListener('scroll', debounce(() => { if (window.scrollY <= 80) flushPending(); }, 150), { passive: true });
|
||||
|
||||
// "Back to top" button, shown once the page has scrolled a little.
|
||||
const syncToTop = () => { $('#toTop').hidden = window.scrollY < 400; };
|
||||
window.addEventListener('scroll', syncToTop, { passive: true });
|
||||
$('#toTop').addEventListener('click', () => {
|
||||
const smooth = !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
window.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
|
||||
});
|
||||
syncToTop();
|
||||
|
||||
$('#live').addEventListener('click', () => {
|
||||
state.live = !state.live;
|
||||
store.set('live', state.live ? '1' : '0');
|
||||
|
||||
+18
-1
@@ -95,7 +95,17 @@
|
||||
<div id="error" class="error-banner" hidden></div>
|
||||
<button id="newPill" class="pill" type="button" hidden></button>
|
||||
|
||||
<main id="list" class="list"></main>
|
||||
<div id="table" class="table" hidden>
|
||||
<div id="listHead" class="list-head" role="presentation">
|
||||
<span data-col="rcv"><span class="lbl" data-i18n="colRcv">Received</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||
<span data-col="mt"><span class="lbl" data-i18n="colMt">Message time</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||
<span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||
<span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||
<span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||
<span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span>
|
||||
</div>
|
||||
<main id="list" class="list"></main>
|
||||
</div>
|
||||
<div id="empty" class="empty" hidden>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7"/></svg>
|
||||
<p><strong data-i18n="emptyTitle">No matching logs.</strong></p>
|
||||
@@ -105,6 +115,9 @@
|
||||
<footer class="status">
|
||||
<span id="conn" class="conn"></span>
|
||||
<span id="stats" class="muted"></span>
|
||||
<button id="toTop" class="icon-btn to-top" type="button" data-i18n-title="toTop" data-i18n-aria="toTop" hidden>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<dialog id="settingsDlg" class="settings" aria-labelledby="settingsTitle">
|
||||
@@ -248,6 +261,10 @@
|
||||
</div>
|
||||
<div id="fontPreview" class="list preview-list" aria-hidden="true"></div>
|
||||
<p class="muted small" data-i18n="fontHelp"></p>
|
||||
<div class="cols-row">
|
||||
<button id="resetCols" class="btn ghost" type="button" data-i18n="resetCols">Reset column widths</button>
|
||||
<span class="muted small" data-i18n="colsHelp"></span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="set-section">
|
||||
<h3 data-i18n="histoTitle">Timeline</h3>
|
||||
|
||||
+46
-4
@@ -296,6 +296,39 @@ body.busy .progress::after {
|
||||
.list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||
.list:empty { display: none; }
|
||||
|
||||
/* Columns: received, message time, severity, host, app, message. Widths come from
|
||||
--col-* (set on #table when a column has been resized, see app.js), shared by
|
||||
the header and every row through subgrid so that the columns line up. */
|
||||
.table {
|
||||
display: grid;
|
||||
grid-template-columns: var(--col-rcv, max-content) var(--col-mt, max-content) var(--col-sev, 4.6rem)
|
||||
var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) minmax(0, 1fr);
|
||||
column-gap: 12px;
|
||||
margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
overflow: clip; /* rounded corners without breaking the sticky header (hidden would) */
|
||||
}
|
||||
.table[hidden] { display: none; }
|
||||
.table .list {
|
||||
display: grid; grid-template-columns: subgrid; grid-column: 1 / -1;
|
||||
margin: 0; border: 0; border-radius: 0; background: none; overflow: visible;
|
||||
}
|
||||
.table .row { grid-template-columns: subgrid; grid-column: 1 / -1; }
|
||||
.list-head {
|
||||
display: grid; grid-template-columns: subgrid; grid-column: 1 / -1;
|
||||
position: sticky; top: var(--topbar-h, 0px); z-index: 5;
|
||||
padding: 0 14px 0 11px; border-left: 3px solid transparent;
|
||||
background: var(--panel-2); border-bottom: 1px solid var(--border);
|
||||
font-size: 10.5px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--muted);
|
||||
}
|
||||
.list-head > span { position: relative; min-width: 0; padding: 4px 0; }
|
||||
/* Labels never widen a column (contain: inline-size), they are cut instead */
|
||||
.list-head .lbl { display: block; contain: inline-size; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: inherit; }
|
||||
.list-head .grip { position: absolute; top: 0; bottom: 0; right: -9px; width: 6px; z-index: 1; cursor: col-resize; touch-action: none; }
|
||||
.list-head .grip::after { content: ""; position: absolute; left: 2px; top: 25%; bottom: 25%; border-left: 1px solid var(--border); }
|
||||
.list-head .grip:hover::after, .list-head .grip.active::after { top: 0; bottom: 0; border-left: 2px solid var(--accent); left: 2px; }
|
||||
body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-select: none; -webkit-user-select: none; }
|
||||
.cols-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-top: 12px; }
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) 1fr;
|
||||
@@ -313,7 +346,8 @@ body.busy .progress::after {
|
||||
.row.new { animation: flash 1.2s ease-out; }
|
||||
@keyframes flash { from { background: var(--accent-soft); } to { background: transparent; } }
|
||||
|
||||
.row time { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.row > * { min-width: 0; }
|
||||
.row time { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; }
|
||||
.row time.rcv { color: var(--text); }
|
||||
.row time .ms { opacity: .6; }
|
||||
.row .host, .row .app { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -322,7 +356,7 @@ body.busy .progress::after {
|
||||
.row .msg { white-space: pre-wrap; word-break: break-word; min-width: 0; }
|
||||
|
||||
.sev {
|
||||
justify-self: start;
|
||||
justify-self: start; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
|
||||
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
|
||||
padding: 1px 6px; border-radius: 5px;
|
||||
color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent);
|
||||
@@ -391,6 +425,10 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
|
||||
.conn.ok::before { color: var(--sev-info); }
|
||||
.conn.ko::before { color: var(--sev-err); }
|
||||
#stats .bad { color: var(--sev-err); }
|
||||
/* "Back to top", at the right end of the status bar: never over a log row */
|
||||
.to-top { width: 24px; height: 24px; margin: -5px -6px -5px auto; flex: none; } /* no taller bar */
|
||||
.to-top svg { width: 16px; height: 16px; }
|
||||
.to-top[hidden] { display: none; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 48px; left: 50%; transform: translateX(-50%); z-index: 50;
|
||||
@@ -593,8 +631,12 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
||||
#count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.histo { padding: 0 16px 6px; }
|
||||
.h-leg { display: none; }
|
||||
.list, .error-banner { margin-left: 16px; margin-right: 16px; }
|
||||
.row {
|
||||
.list, .error-banner, .table { margin-left: 16px; margin-right: 16px; }
|
||||
/* Phones: no columns, two lines per log (layout below); the widths do not apply */
|
||||
.table { display: block; }
|
||||
.table .list { display: block; margin: 0; }
|
||||
.list-head { display: none; }
|
||||
.row, .table .row {
|
||||
grid-template-columns: auto auto 1fr;
|
||||
grid-template-areas: "rcv sev host" "msg msg msg";
|
||||
gap: 3px 8px; padding: 8px 12px 8px 10px;
|
||||
|
||||
Reference in new issue
Block a user