From a12fac16c8d2a57391efef23696c6264375f786e Mon Sep 17 00:00:00 2001 From: Cedric Date: Thu, 1 Oct 2026 22:07:05 +0200 Subject: [PATCH] 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) --- README.md | 6 +++++ web/app.js | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++ web/index.html | 16 +++++++++++- web/style.css | 46 ++++++++++++++++++++++++++++++--- 4 files changed, 133 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 528af6d..0b6f4b2 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,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 diff --git a/web/app.js b/web/app.js index 2ae420c..ab92a30 100644 --- a/web/app.js +++ b/web/app.js @@ -126,6 +126,10 @@ 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', + 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 +254,10 @@ 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', + 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 +770,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 +787,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 +868,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.); 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. diff --git a/web/index.html b/web/index.html index 70f60c3..69a02d5 100644 --- a/web/index.html +++ b/web/index.html @@ -95,7 +95,17 @@ -
+

+
+ + +

Timeline

diff --git a/web/style.css b/web/style.css index 6056e67..2592aa7 100644 --- a/web/style.css +++ b/web/style.css @@ -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); @@ -593,8 +627,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;