Author SHA1 Message Date
cedric 392fc645ce wip schema: arrows between containers 2026-10-02 09:17:30 +02:00
cedric 81806a6991 wip schema: straight polyline arrows 2026-10-02 09:16:59 +02:00
cedric 6abf1f47fd wip schema source 2026-10-02 09:16:02 +02:00
cedricandClaude Opus 5.5 b3b7041ee9 README: architecture diagram
Adds the logical diagram of the project (PNG and its editable Excalidraw
source in docs/) and an Architecture section after the introduction,
with a short description of the data flows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:09:08 +02:00
cedricandClaude Opus 5.5 9a1b60fa2b Back-to-top button in the status bar
A small arrow button at the right end of the status bar, shown once the
page has scrolled 400 px, scrolls smoothly back to the top (instantly
when the system asks for reduced motion). Living in the fixed status bar,
it never covers a log row or the details panel, on desktop and phones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:13:30 +02:00
cedricandClaude Opus 5.5 a12fac16c8 Log list: resizable columns with a sticky header
The list gets a thin sticky header (received, message time, severity,
host, app, message). Dragging the edge of a header resizes the column,
a double-click returns it to the automatic width; widths are clamped per
column and remembered per browser (logstream.cols.*), and Settings >
Interface has a "Reset column widths" button.

The columns are defined once on a #table wrapper from --col-* variables;
the header and every row use subgrid, so all rows line up (host and app
widths no longer vary from row to row). The wrapper clips with
overflow: clip so that the header can stick under the top bar. Phones
keep the two-line layout without header.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:07:05 +02:00
6 changed files with 3654 additions and 4 deletions

No files matched your search

+22
View File
@@ -10,6 +10,22 @@ devices ──514 udp/tcp──▶ logstream (Go) ──HTTP batches──▶ Vi
└──── API / UI ◀─────────┘
```
## Architecture
![Schéma logique de Logstream](docs/logstream-schema-logique.png)
- **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
View File
@@ -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');
+17
View File
@@ -95,7 +95,17 @@
<div id="error" class="error-banner" hidden></div>
<button id="newPill" class="pill" type="button" hidden></button>
<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
View File
@@ -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;