From 75631338b1942225d4e6ffe487a5af2f10ac6925 Mon Sep 17 00:00:00 2001 From: Wasabules <39313803+Wasabules@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:48:54 +0200 Subject: [PATCH 01/13] feat(log): resizable columns, with a double-click that fits the content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which column deserves room depends entirely on what is being read. A file of Windows events has application names three times longer than anything else, a firewall stream has none at all, and an IPv6 source needs twice the width of an IPv4 one. No default is right for both, so the answer is to let the reader set it and then remember it. - drag a column's right edge; the handle straddles the edge and is nine pixels wide, because a five-pixel target is a target you miss - double-click that edge to fit the column to the widest value on screen, the heading included — fitting Hostname on a file with no hostnames in it must not hide the word "Hostname" - right-click any heading for the same thing, for every column at once, or to put the widths back - widths persist, and a stored value that is not a number leaves the default standing rather than collapsing a column to nothing Measured with the font the column actually renders in, read from a cell on screen: a monospace 11px source and a 12px hostname are far enough apart that one font for both would leave one of them clipped. Sampled over the first five thousand rows, which is wide enough for anything and does not stall on a full buffer. The seven headings were seven copies of the same markup, which is seven places to forget when one of them gains a handle; they are a loop now. That also fixed an old misalignment: the header row had no gap between cells while the rows had four pixels, so every heading sat slightly left of its own column. --- README.md | 2 + frontend/src/components/LogViewer.svelte | 211 +++++++++++++++++++---- frontend/src/lib/columns.ts | 104 +++++++++++ frontend/src/lib/i18n/de.json | 6 +- frontend/src/lib/i18n/en.json | 6 +- frontend/src/lib/i18n/es.json | 6 +- frontend/src/lib/i18n/fr.json | 6 +- frontend/src/lib/i18n/it.json | 6 +- frontend/src/lib/i18n/ja.json | 6 +- frontend/src/lib/i18n/pt.json | 6 +- frontend/src/lib/i18n/zh.json | 6 +- 11 files changed, 327 insertions(+), 38 deletions(-) create mode 100644 frontend/src/lib/columns.ts diff --git a/README.md b/README.md index 83b022b..9b3af4a 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ in a ticket without going through a redaction tool first. - **Filter, sort and group** by severity, facility, host, application, source IP or time range - **Explicit timezones** — follow the machine, pin to UTC, or name a zone; the column header says which one it is showing +- **Resizable columns** — drag an edge, double-click it to fit the widest value, or use the + header's right-click menu to fit everything or start over. Widths are remembered - **Export** as CSV or plain text - **Import a log file** already on disk — `.log`, `.txt` or a rotated `.gz`. A captured syslog file is parsed exactly as it would be off the wire; a plain application log has its diff --git a/frontend/src/components/LogViewer.svelte b/frontend/src/components/LogViewer.svelte index 279754a..95bd02f 100644 --- a/frontend/src/components/LogViewer.svelte +++ b/frontend/src/components/LogViewer.svelte @@ -8,6 +8,11 @@ import { activeZone, zoneAbbreviation, formatInZone } from '../lib/timezone'; import { queryMessages, getStorageStats, queryMessageGroups } from '../lib/api'; import { _ } from 'svelte-i18n'; + import { + columnWidths, widthVars, setColumnWidth, resetColumnWidths, + measureLongest, clampWidth, RESIZABLE, + } from '../lib/columns'; + import type { ColumnKey } from '../lib/columns'; const ROW_HEIGHT = 28; const GROUP_ROW_HEIGHT = 32; @@ -370,6 +375,104 @@ $: historyTotalPages = Math.max(1, Math.ceil(historyTotal / historyPageSize)); // --- Sort --- + // The table, as data. Seven columns written out by hand is seven places to + // forget when one of them gains a resizer. + const COLUMNS: { key: ColumnKey | 'message'; sort: SortCol; label: string }[] = [ + { key: 'severity', sort: 'severity', label: 'log.severity' }, + { key: 'timestamp', sort: 'timestamp', label: 'log.timestamp' }, + { key: 'protocol', sort: 'protocol', label: 'log.proto' }, + { key: 'source', sort: 'sourceIP', label: 'log.source' }, + { key: 'hostname', sort: 'hostname', label: 'log.hostname' }, + { key: 'app', sort: 'appName', label: 'log.app' }, + { key: 'message', sort: 'message', label: 'log.message' }, + ]; + + let header: HTMLDivElement; + let menu: { x: number; y: number; key: ColumnKey } | null = null; + + // --- dragging a column edge ---------------------------------------------- + // + // Pointer events rather than mouse events, and a capture on the handle: the + // pointer leaves the 5px strip on the first frame of any real drag, and + // without the capture the resize stops the moment it does. + let drag: { key: ColumnKey; startX: number; startWidth: number } | null = null; + + function startDrag(e: PointerEvent, key: ColumnKey) { + e.preventDefault(); + e.stopPropagation(); + drag = { key, startX: e.clientX, startWidth: $columnWidths[key] }; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + } + + function onDrag(e: PointerEvent) { + if (!drag) return; + setColumnWidth(drag.key, drag.startWidth + (e.clientX - drag.startX)); + } + + function endDrag(e: PointerEvent) { + if (!drag) return; + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + drag = null; + } + + // --- fitting a column to its content ------------------------------------- + + /** What this column shows for a message, as the row renders it. */ + function cellText(key: ColumnKey, msg: SyslogMessage): string { + switch (key) { + case 'severity': return msg.severityLabel; + case 'timestamp': return formatInZone(msg.timestamp, $activeZone); + case 'protocol': return msg.protocol; + case 'source': return redactIP(msg.sourceIP, $anonymous); + case 'hostname': return redactHost(msg.hostname, $anonymous); + case 'app': return msg.appName; + } + } + + // Enough rows to answer the question without stalling on a full buffer. + // A column wide enough for the widest of five thousand lines is wide enough. + const FIT_SAMPLE = 5000; + + /** The font a column renders in, read from the table rather than assumed. */ + function columnFont(key: ColumnKey): string { + const cell = header?.parentElement?.querySelector(`.log-row .col-${key}`); + const el = cell ?? header?.querySelector(`.col-${key}`); + if (!el) return '12px sans-serif'; + const style = getComputedStyle(el); + return `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + } + + function fitColumn(key: ColumnKey) { + const rows = $filteredMessages.slice(0, FIT_SAMPLE); + const values = rows.map(m => cellText(key, m)); + + // The heading has to fit too, or fitting a column to a file with no + // hostnames in it would hide the word "Hostname". + const headEl = header?.querySelector(`.col-${key}`); + const headStyle = headEl ? getComputedStyle(headEl) : null; + const headWidth = headEl && headStyle + ? measureLongest([headEl.innerText.trim()], + `${headStyle.fontStyle} ${headStyle.fontWeight} ${headStyle.fontSize} ${headStyle.fontFamily}`) + : 0; + + // The padding a cell already spends, plus room for a sort arrow. + const CHROME = 26; + setColumnWidth(key, clampWidth(Math.max(measureLongest(values, columnFont(key)), headWidth) + CHROME)); + } + + function fitAllColumns() { + for (const key of RESIZABLE) fitColumn(key); + } + + function openMenu(e: MouseEvent, key: ColumnKey) { + e.preventDefault(); + menu = { x: e.clientX, y: e.clientY, key }; + } + + function closeMenu() { + menu = null; + } + function toggleSort(col: SortCol) { if ($sortColumn === col) { if ($sortDirection === 'desc') $sortDirection = 'asc'; @@ -384,31 +487,43 @@ -
-
- - - - - - - +
+ +
+ {#each COLUMNS as col (col.key)} +
+ + {#if col.key !== 'message'} + {@const key = col.key} + +
startDrag(e, key)} + on:pointermove={onDrag} + on:pointerup={endDrag} + on:pointercancel={endDrag} + on:dblclick={() => fitColumn(key)}>
+ {/if} +
+ {/each}
+ {#if menu} + {@const target = menu} + + + +
+ + + +
+ {/if} +
@@ -553,14 +668,47 @@ .log-header { display: flex; align-items: center; padding: 0 8px; background: var(--bg-tertiary); border-bottom: 1px solid var(--border-color); - font-size: 11px; font-weight: 600; color: var(--text-secondary); flex-shrink: 0; gap: 0; + font-size: 11px; font-weight: 600; color: var(--text-secondary); flex-shrink: 0; + /* The same gap the rows use, so a heading sits over its own column. */ + gap: 4px; } + /* The width lives on the wrapper now: the button fills it, and the resize + handle sits at its edge without taking a share of it. */ + .col-wrap { position: relative; display: flex; align-items: center; min-width: 0; } .col-header { + flex: 1; min-width: 0; background: transparent; color: var(--text-secondary); border: none; border-right: 1px solid var(--border-subtle); font-size: 11px; font-weight: 600; padding: 6px 6px; cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + + /* Wider than it looks: a 5px target is a target you miss. The strip + straddles the edge so the cursor changes slightly before it. */ + .col-resizer { + position: absolute; top: 0; bottom: 0; right: -4px; width: 9px; + cursor: col-resize; z-index: 2; + touch-action: none; + } + .col-resizer::after { + content: ''; position: absolute; top: 3px; bottom: 3px; left: 4px; width: 1px; + background: transparent; transition: background 0.1s; + } + .col-resizer:hover::after, .col-resizer.dragging::after { background: var(--accent); } + + .menu-backdrop { position: fixed; inset: 0; z-index: 900; } + .col-menu { + position: fixed; z-index: 901; + display: flex; flex-direction: column; min-width: 160px; + background: var(--bg-secondary); border: 1px solid var(--border-color); + border-radius: 4px; box-shadow: 0 6px 18px rgba(0, 0, 0, 0.3); + padding: 4px; + } + .col-menu button { + background: none; border: none; text-align: left; cursor: pointer; + padding: 6px 10px; font-size: 12px; color: var(--text-primary); border-radius: 3px; + } + .col-menu button:hover { background: var(--bg-hover); } .col-header:hover { background: var(--bg-hover); color: var(--text-primary); } .col-header.sorted { color: var(--accent); } .col-header:last-child { border-right: none; } @@ -596,13 +744,16 @@ .group-count { color: var(--text-muted); font-weight: 400; font-size: 11px; flex-shrink: 0; } .group-bar { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 0; z-index: -1; transition: width 0.3s; } - .col-severity { width: 80px; flex-shrink: 0; } - .col-timestamp { width: 140px; flex-shrink: 0; font-family: monospace; font-size: 11px; color: var(--text-secondary); } + .col-severity { width: var(--w-severity, 80px); flex-shrink: 0; } + .col-timestamp { width: var(--w-timestamp, 140px); flex-shrink: 0; font-family: monospace; font-size: 11px; color: var(--text-secondary); } .col-zone { font-weight: 400; font-size: 10px; opacity: 0.7; } - .col-protocol { width: 40px; flex-shrink: 0; font-size: 11px; color: var(--text-muted); } - .col-source { width: 110px; flex-shrink: 0; font-family: monospace; font-size: 11px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .col-hostname { width: 110px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .col-app { width: 100px; flex-shrink: 0; color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .col-protocol { width: var(--w-protocol, 40px); flex-shrink: 0; font-size: 11px; color: var(--text-muted); } + .col-source { width: var(--w-source, 110px); flex-shrink: 0; font-family: monospace; font-size: 11px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .col-hostname { width: var(--w-hostname, 110px); flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .col-app { width: var(--w-app, 100px); flex-shrink: 0; color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + /* A row cell keeps its own font and colour; the heading above it only needs + the width, which the wrapper carries. */ + .col-wrap.col-message { flex: 1; min-width: 0; } .col-message { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .severity-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; color: #10161d; text-align: center; min-width: 60px; } diff --git a/frontend/src/lib/columns.ts b/frontend/src/lib/columns.ts new file mode 100644 index 0000000..80432c4 --- /dev/null +++ b/frontend/src/lib/columns.ts @@ -0,0 +1,104 @@ +import { writable } from 'svelte/store'; + +/** + * The width of each column in the log table, kept across restarts. + * + * A log line is mostly message, but which of the other columns deserves room + * depends entirely on what is being read: a file of Windows events has + * application names three times longer than anything else, a firewall stream + * has none at all, and an IPv6 source needs twice the width of an IPv4 one. + * There is no default that is right for both, so the answer is to let the + * reader set it and then remember it. + * + * Widths live here rather than in the component so the stored value and the + * CSS fallback cannot drift: the component publishes them as custom properties + * and the stylesheet reads them. + */ + +export type ColumnKey = 'severity' | 'timestamp' | 'protocol' | 'source' | 'hostname' | 'app'; + +/** The message column is last and takes whatever is left, so it has no width. */ +export const RESIZABLE: ColumnKey[] = ['severity', 'timestamp', 'protocol', 'source', 'hostname', 'app']; + +export const DEFAULT_WIDTHS: Record = { + severity: 80, + timestamp: 140, + protocol: 40, + source: 110, + hostname: 110, + app: 100, +}; + +// A column narrower than this cannot show even an ellipsis usefully; one wider +// than this has pushed the message off the screen, which is the one column +// nobody wants to lose. +export const MIN_WIDTH = 36; +export const MAX_WIDTH = 900; + +const STORAGE_KEY = 'syslogstudio-columns'; + +export const clampWidth = (px: number): number => + Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.round(px))); + +function getInitial(): Record { + const widths = { ...DEFAULT_WIDTHS }; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return widths; + const stored = JSON.parse(raw) as Partial>; + for (const key of RESIZABLE) { + const value = stored[key]; + // A stored file is not a promise: a hand-edited or truncated value + // must leave the default standing rather than collapse a column. + if (typeof value === 'number' && Number.isFinite(value)) { + widths[key] = clampWidth(value); + } + } + } catch { /* private browsing, or nothing stored yet */ } + return widths; +} + +export const columnWidths = writable>(getInitial()); + +columnWidths.subscribe(value => { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); } catch {} +}); + +export function setColumnWidth(key: ColumnKey, px: number) { + columnWidths.update(w => ({ ...w, [key]: clampWidth(px) })); +} + +export function resetColumnWidths() { + columnWidths.set({ ...DEFAULT_WIDTHS }); +} + +/** The custom properties the stylesheet reads, as one style attribute. */ +export function widthVars(widths: Record): string { + return RESIZABLE.map(key => `--w-${key}:${widths[key]}px`).join(';'); +} + +// One canvas for every measurement. Creating one per call is what turns +// "fit the column" into a visible pause on a long list. +let canvas: HTMLCanvasElement | null = null; + +/** + * The width the longest of these strings needs, in pixels. + * + * Measured with the font the column actually renders in, taken from a cell on + * screen — a monospace 11px source address and a 12px hostname are far enough + * apart that measuring both with one font would leave one of them clipped. + */ +export function measureLongest(values: string[], font: string): number { + if (!canvas) canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return 0; + ctx.font = font; + + let widest = 0; + for (const value of values) { + if (!value) continue; + const w = ctx.measureText(value).width; + if (w > widest) widest = w; + } + return widest; +} diff --git a/frontend/src/lib/i18n/de.json b/frontend/src/lib/i18n/de.json index df8b9f0..01c35fe 100644 --- a/frontend/src/lib/i18n/de.json +++ b/frontend/src/lib/i18n/de.json @@ -148,7 +148,11 @@ "copy": "Kopieren", "copiedToClipboard": "In die Zwischenablage kopiert", "failedToCopy": "Kopieren in die Zwischenablage fehlgeschlagen", - "matchesFound": "Treffer gefunden" + "matchesFound": "Treffer gefunden", + "resizeHint": "Ziehen zum Ändern der Breite, Doppelklick für den breitesten Wert", + "fitColumn": "Diese Spalte anpassen", + "fitAll": "Alle Spalten anpassen", + "resetWidths": "Spaltenbreiten zurücksetzen" }, "dashboard": { "totalMessages": "Nachrichten gesamt", diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index d2fca86..8e48571 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -148,7 +148,11 @@ "rawMessage": "Raw Message", "copy": "Copy", "copiedToClipboard": "Copied to clipboard", - "failedToCopy": "Failed to copy to clipboard" + "failedToCopy": "Failed to copy to clipboard", + "resizeHint": "Drag to resize, double-click to fit the widest value", + "fitColumn": "Fit this column", + "fitAll": "Fit all columns", + "resetWidths": "Reset column widths" }, "dashboard": { "totalMessages": "Total Messages", diff --git a/frontend/src/lib/i18n/es.json b/frontend/src/lib/i18n/es.json index c629dfa..7b60b45 100644 --- a/frontend/src/lib/i18n/es.json +++ b/frontend/src/lib/i18n/es.json @@ -148,7 +148,11 @@ "copy": "Copiar", "copiedToClipboard": "Copiado al portapapeles", "failedToCopy": "Error al copiar al portapapeles", - "matchesFound": "coincidencias encontradas" + "matchesFound": "coincidencias encontradas", + "resizeHint": "Arrastra para cambiar el ancho, doble clic para ajustar al valor más largo", + "fitColumn": "Ajustar esta columna", + "fitAll": "Ajustar todas las columnas", + "resetWidths": "Restablecer los anchos" }, "dashboard": { "totalMessages": "Total de mensajes", diff --git a/frontend/src/lib/i18n/fr.json b/frontend/src/lib/i18n/fr.json index b233ec6..40f9da8 100644 --- a/frontend/src/lib/i18n/fr.json +++ b/frontend/src/lib/i18n/fr.json @@ -148,7 +148,11 @@ "rawMessage": "Message brut", "copy": "Copier", "copiedToClipboard": "Copié dans le presse-papiers", - "failedToCopy": "Échec de la copie dans le presse-papiers" + "failedToCopy": "Échec de la copie dans le presse-papiers", + "resizeHint": "Glisser pour redimensionner, double-cliquer pour ajuster à la valeur la plus large", + "fitColumn": "Ajuster cette colonne", + "fitAll": "Ajuster toutes les colonnes", + "resetWidths": "Réinitialiser les largeurs" }, "dashboard": { "totalMessages": "Total des messages", diff --git a/frontend/src/lib/i18n/it.json b/frontend/src/lib/i18n/it.json index 181509e..2add9a4 100644 --- a/frontend/src/lib/i18n/it.json +++ b/frontend/src/lib/i18n/it.json @@ -148,7 +148,11 @@ "copy": "Copia", "copiedToClipboard": "Copiato negli appunti", "failedToCopy": "Impossibile copiare negli appunti", - "matchesFound": "corrispondenze trovate" + "matchesFound": "corrispondenze trovate", + "resizeHint": "Trascina per ridimensionare, doppio clic per adattare al valore più lungo", + "fitColumn": "Adatta questa colonna", + "fitAll": "Adatta tutte le colonne", + "resetWidths": "Reimposta le larghezze" }, "dashboard": { "totalMessages": "Totale messaggi", diff --git a/frontend/src/lib/i18n/ja.json b/frontend/src/lib/i18n/ja.json index c46729e..372362a 100644 --- a/frontend/src/lib/i18n/ja.json +++ b/frontend/src/lib/i18n/ja.json @@ -148,7 +148,11 @@ "copy": "コピー", "copiedToClipboard": "クリップボードにコピーしました", "failedToCopy": "クリップボードへのコピーに失敗しました", - "matchesFound": "件の一致" + "matchesFound": "件の一致", + "resizeHint": "ドラッグで幅変更、ダブルクリックで最長の値に合わせる", + "fitColumn": "この列を自動調整", + "fitAll": "すべての列を自動調整", + "resetWidths": "列幅をリセット" }, "dashboard": { "totalMessages": "メッセージ合計", diff --git a/frontend/src/lib/i18n/pt.json b/frontend/src/lib/i18n/pt.json index ca0687d..213a1b0 100644 --- a/frontend/src/lib/i18n/pt.json +++ b/frontend/src/lib/i18n/pt.json @@ -148,7 +148,11 @@ "copy": "Copiar", "copiedToClipboard": "Copiado para a área de transferência", "failedToCopy": "Falha ao copiar para a área de transferência", - "matchesFound": "correspondências encontradas" + "matchesFound": "correspondências encontradas", + "resizeHint": "Arraste para redimensionar, duplo clique para ajustar ao valor mais longo", + "fitColumn": "Ajustar esta coluna", + "fitAll": "Ajustar todas as colunas", + "resetWidths": "Repor as larguras" }, "dashboard": { "totalMessages": "Total de mensagens", diff --git a/frontend/src/lib/i18n/zh.json b/frontend/src/lib/i18n/zh.json index 2772519..19228c7 100644 --- a/frontend/src/lib/i18n/zh.json +++ b/frontend/src/lib/i18n/zh.json @@ -148,7 +148,11 @@ "copy": "复制", "copiedToClipboard": "已复制到剪贴板", "failedToCopy": "复制到剪贴板失败", - "matchesFound": "个匹配项" + "matchesFound": "个匹配项", + "resizeHint": "拖动调整宽度,双击适应最长内容", + "fitColumn": "适应此列", + "fitAll": "适应所有列", + "resetWidths": "重置列宽" }, "dashboard": { "totalMessages": "消息总数", From 68356592a8367b4f1b8ec7f983f0057864b04ad0 Mon Sep 17 00:00:00 2001 From: Wasabules <39313803+Wasabules@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:58:09 +0200 Subject: [PATCH 02/13] feat(log): carry a column to another place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where a column belongs is the same kind of question as how wide it should be, with the same answer: it depends on the file, so the reader decides and the application remembers. Someone reading one host's log wants the message first; someone watching twenty devices wants the host first. Press a heading and move it. Pointer events rather than the HTML drag-and-drop API, which insists on its own ghost image and its own drop semantics and fights a table whose columns are a flex row. Nothing happens until the pointer has travelled five pixels, because every one of these presses is also a click on the sort button, and a heading that reordered itself on an imprecise click would be unusable. The carried column dims and a line shows where it would land. The rows read the same stored order as the header, so they cannot disagree. An order stored by an older version is kept and then completed with whatever column has been added since — dropping a column from the table would be a strange way to learn it exists. One defect the test caught: after a drag the browser does not always follow the release with a click, so the suppression meant to swallow that click was left standing and swallowed the NEXT heading clicked instead — which reads as sorting having stopped working. A press now decides for itself. --- README.md | 5 +- frontend/src/components/LogViewer.svelte | 170 ++++++++++++++++++----- frontend/src/lib/columns.ts | 57 ++++++++ frontend/src/lib/i18n/de.json | 3 +- frontend/src/lib/i18n/en.json | 3 +- frontend/src/lib/i18n/es.json | 3 +- frontend/src/lib/i18n/fr.json | 3 +- frontend/src/lib/i18n/it.json | 3 +- frontend/src/lib/i18n/ja.json | 3 +- frontend/src/lib/i18n/pt.json | 3 +- frontend/src/lib/i18n/zh.json | 3 +- 11 files changed, 209 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 9b3af4a..9c11fea 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,9 @@ in a ticket without going through a redaction tool first. - **Filter, sort and group** by severity, facility, host, application, source IP or time range - **Explicit timezones** — follow the machine, pin to UTC, or name a zone; the column header says which one it is showing -- **Resizable columns** — drag an edge, double-click it to fit the widest value, or use the - header's right-click menu to fit everything or start over. Widths are remembered +- **Columns you arrange** — drag an edge to resize, double-click it to fit the widest value, + drag a heading to move the column, or use the header's right-click menu to fit everything or + start over. Widths and order are remembered - **Export** as CSV or plain text - **Import a log file** already on disk — `.log`, `.txt` or a rotated `.gz`. A captured syslog file is parsed exactly as it would be off the wire; a plain application log has its diff --git a/frontend/src/components/LogViewer.svelte b/frontend/src/components/LogViewer.svelte index 95bd02f..e6ca4f3 100644 --- a/frontend/src/components/LogViewer.svelte +++ b/frontend/src/components/LogViewer.svelte @@ -9,10 +9,10 @@ import { queryMessages, getStorageStats, queryMessageGroups } from '../lib/api'; import { _ } from 'svelte-i18n'; import { - columnWidths, widthVars, setColumnWidth, resetColumnWidths, - measureLongest, clampWidth, RESIZABLE, + columnWidths, columnOrder, widthVars, setColumnWidth, resetColumns, + moveColumn, measureLongest, clampWidth, RESIZABLE, } from '../lib/columns'; - import type { ColumnKey } from '../lib/columns'; + import type { ColumnKey, AnyColumn } from '../lib/columns'; const ROW_HEIGHT = 28; const GROUP_ROW_HEIGHT = 32; @@ -377,18 +377,18 @@ // --- Sort --- // The table, as data. Seven columns written out by hand is seven places to // forget when one of them gains a resizer. - const COLUMNS: { key: ColumnKey | 'message'; sort: SortCol; label: string }[] = [ - { key: 'severity', sort: 'severity', label: 'log.severity' }, - { key: 'timestamp', sort: 'timestamp', label: 'log.timestamp' }, - { key: 'protocol', sort: 'protocol', label: 'log.proto' }, - { key: 'source', sort: 'sourceIP', label: 'log.source' }, - { key: 'hostname', sort: 'hostname', label: 'log.hostname' }, - { key: 'app', sort: 'appName', label: 'log.app' }, - { key: 'message', sort: 'message', label: 'log.message' }, - ]; + const COLUMN: Record = { + severity: { sort: 'severity', label: 'log.severity' }, + timestamp: { sort: 'timestamp', label: 'log.timestamp' }, + protocol: { sort: 'protocol', label: 'log.proto' }, + source: { sort: 'sourceIP', label: 'log.source' }, + hostname: { sort: 'hostname', label: 'log.hostname' }, + app: { sort: 'appName', label: 'log.app' }, + message: { sort: 'message', label: 'log.message' }, + }; let header: HTMLDivElement; - let menu: { x: number; y: number; key: ColumnKey } | null = null; + let menu: { x: number; y: number; key: AnyColumn } | null = null; // --- dragging a column edge ---------------------------------------------- // @@ -415,6 +415,69 @@ drag = null; } + // --- carrying a column to another place ---------------------------------- + // + // Pointer events rather than the HTML drag-and-drop API: that API insists + // on its own ghost image and its own drop semantics, and both fight a + // table whose columns are a flex row. + // + // Nothing happens until the pointer has travelled a few pixels, because + // every one of these presses is also a click on the sort button, and a + // heading that reordered itself on an imprecise click would be unusable. + let reorder: { key: AnyColumn; startX: number; active: boolean; over: number } | null = null; + let suppressClick = false; + const DRAG_THRESHOLD = 5; + + function headerDown(e: PointerEvent, key: AnyColumn) { + if (e.button !== 0) return; + // A fresh press decides for itself. After a drag the browser does not + // always follow the release with a click, and a suppression left + // standing would then swallow the NEXT heading someone clicks — which + // reads as sorting having stopped working. + suppressClick = false; + reorder = { key, startX: e.clientX, active: false, over: $columnOrder.indexOf(key) }; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + } + + function headerMove(e: PointerEvent) { + if (!reorder || drag) return; + if (!reorder.active) { + if (Math.abs(e.clientX - reorder.startX) < DRAG_THRESHOLD) return; + reorder.active = true; + } + reorder = { ...reorder, over: dropIndexAt(e.clientX) }; + } + + /** Where the column would land if it were let go here. */ + function dropIndexAt(x: number): number { + const wraps = Array.from(header?.querySelectorAll('.col-wrap') ?? []); + for (let i = 0; i < wraps.length; i++) { + const box = wraps[i].getBoundingClientRect(); + if (x < box.left + box.width / 2) return i; + } + return wraps.length; + } + + function headerUp(e: PointerEvent) { + if (!reorder) return; + const done = reorder; + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + reorder = null; + if (!done.active) return; + // The press was a drag, so the click that follows it is not a request + // to sort. + suppressClick = true; + moveColumn(done.key, done.over); + } + + function headerClick(sort: SortCol) { + if (suppressClick) { + suppressClick = false; + return; + } + toggleSort(sort); + } + // --- fitting a column to its content ------------------------------------- /** What this column shows for a message, as the row renders it. */ @@ -464,7 +527,7 @@ for (const key of RESIZABLE) fitColumn(key); } - function openMenu(e: MouseEvent, key: ColumnKey) { + function openMenu(e: MouseEvent, key: AnyColumn) { e.preventDefault(); menu = { x: e.clientX, y: e.clientY, key }; } @@ -490,23 +553,33 @@
- {#each COLUMNS as col (col.key)} -
+ {#each $columnOrder as key, i (key)} + {@const col = COLUMN[key]} +
- {#if col.key !== 'message'} - {@const key = col.key} + {#if key !== 'message'} + {@const resizable = key} -
startDrag(e, key)} + on:pointerdown={e => startDrag(e, resizable)} on:pointermove={onDrag} on:pointerup={endDrag} on:pointercancel={endDrag} - on:dblclick={() => fitColumn(key)}>
+ on:dblclick={() => fitColumn(resizable)}>
{/if}
{/each} @@ -518,9 +591,11 @@
- + {#if target.key !== 'message'} + + {/if} - +
{/if} @@ -550,17 +625,27 @@ role="row" tabindex="0" on:click={() => selectMessage(msg)} on:keydown={e => e.key === 'Enter' && selectMessage(msg)}> - - - {msg.severityLabel} - - - {formatInZone(msg.timestamp, $activeZone)} - {msg.protocol} - {redactIP(msg.sourceIP, $anonymous)} - {redactHost(msg.hostname, $anonymous)} - {msg.appName} - {redactText(msg.message, $anonymous)} + {#each $columnOrder as key (key)} + {#if key === 'severity'} + + + {msg.severityLabel} + + + {:else if key === 'timestamp'} + {formatInZone(msg.timestamp, $activeZone)} + {:else if key === 'protocol'} + {msg.protocol} + {:else if key === 'source'} + {redactIP(msg.sourceIP, $anonymous)} + {:else if key === 'hostname'} + {redactHost(msg.hostname, $anonymous)} + {:else if key === 'app'} + {msg.appName} + {:else} + {redactText(msg.message, $anonymous)} + {/if} + {/each}
{/if} {/each} @@ -696,6 +781,17 @@ } .col-resizer:hover::after, .col-resizer.dragging::after { background: var(--accent); } + /* Carried, and where it would land. The line is on the wrapper rather than + a floating element so it cannot drift out of the header on a fast drag. */ + .col-wrap.dragged { opacity: 0.4; } + .col-wrap.drop-before, .col-wrap.drop-after { position: relative; } + .col-wrap.drop-before::before, .col-wrap.drop-after::before { + content: ''; position: absolute; top: 2px; bottom: 2px; width: 2px; + background: var(--accent); z-index: 3; + } + .col-wrap.drop-before::before { left: -3px; } + .col-wrap.drop-after::before { right: -3px; } + .menu-backdrop { position: fixed; inset: 0; z-index: 900; } .col-menu { position: fixed; z-index: 901; diff --git a/frontend/src/lib/columns.ts b/frontend/src/lib/columns.ts index 80432c4..3aa87e5 100644 --- a/frontend/src/lib/columns.ts +++ b/frontend/src/lib/columns.ts @@ -64,6 +64,63 @@ columnWidths.subscribe(value => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); } catch {} }); +// --- order ------------------------------------------------------------------- +// +// Which column belongs where is the same kind of question as how wide it +// should be, and it has the same answer: it depends on the file, so the reader +// decides and the application remembers. Someone reading one host's log wants +// the message first; someone watching twenty devices wants the host first. + +export type AnyColumn = ColumnKey | 'message'; + +export const DEFAULT_ORDER: AnyColumn[] = [ + 'severity', 'timestamp', 'protocol', 'source', 'hostname', 'app', 'message', +]; + +const ORDER_KEY = 'syslogstudio-column-order'; + +function initialOrder(): AnyColumn[] { + try { + const raw = localStorage.getItem(ORDER_KEY); + if (!raw) return [...DEFAULT_ORDER]; + const stored = JSON.parse(raw); + if (!Array.isArray(stored)) return [...DEFAULT_ORDER]; + // Kept, then completed: a stored order from an older version is missing + // whatever column has been added since, and dropping that column from + // the table would be a strange way to learn it exists. + const known = stored.filter((k): k is AnyColumn => DEFAULT_ORDER.includes(k)); + const seen = new Set(known); + return [...known, ...DEFAULT_ORDER.filter(k => !seen.has(k))]; + } catch { + return [...DEFAULT_ORDER]; + } +} + +export const columnOrder = writable(initialOrder()); + +columnOrder.subscribe(value => { + try { localStorage.setItem(ORDER_KEY, JSON.stringify(value)); } catch {} +}); + +/** Moves a column so that it lands at `index` in the order. */ +export function moveColumn(key: AnyColumn, index: number) { + columnOrder.update(order => { + const from = order.indexOf(key); + if (from < 0) return order; + const next = order.filter(k => k !== key); + // The index was read against the order WITH the column still in it, so + // a move to the right has to account for the hole it leaves behind. + const adjusted = index > from ? index - 1 : index; + next.splice(Math.max(0, Math.min(next.length, adjusted)), 0, key); + return next; + }); +} + +export function resetColumns() { + columnOrder.set([...DEFAULT_ORDER]); + resetColumnWidths(); +} + export function setColumnWidth(key: ColumnKey, px: number) { columnWidths.update(w => ({ ...w, [key]: clampWidth(px) })); } diff --git a/frontend/src/lib/i18n/de.json b/frontend/src/lib/i18n/de.json index 01c35fe..47ad6c5 100644 --- a/frontend/src/lib/i18n/de.json +++ b/frontend/src/lib/i18n/de.json @@ -152,7 +152,8 @@ "resizeHint": "Ziehen zum Ändern der Breite, Doppelklick für den breitesten Wert", "fitColumn": "Diese Spalte anpassen", "fitAll": "Alle Spalten anpassen", - "resetWidths": "Spaltenbreiten zurücksetzen" + "resetWidths": "Spalten zurücksetzen", + "moveHint": "Klicken zum Sortieren, ziehen zum Verschieben der Spalte" }, "dashboard": { "totalMessages": "Nachrichten gesamt", diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 8e48571..9666c46 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -152,7 +152,8 @@ "resizeHint": "Drag to resize, double-click to fit the widest value", "fitColumn": "Fit this column", "fitAll": "Fit all columns", - "resetWidths": "Reset column widths" + "resetWidths": "Reset columns", + "moveHint": "Click to sort, drag to move the column" }, "dashboard": { "totalMessages": "Total Messages", diff --git a/frontend/src/lib/i18n/es.json b/frontend/src/lib/i18n/es.json index 7b60b45..11920d4 100644 --- a/frontend/src/lib/i18n/es.json +++ b/frontend/src/lib/i18n/es.json @@ -152,7 +152,8 @@ "resizeHint": "Arrastra para cambiar el ancho, doble clic para ajustar al valor más largo", "fitColumn": "Ajustar esta columna", "fitAll": "Ajustar todas las columnas", - "resetWidths": "Restablecer los anchos" + "resetWidths": "Restablecer las columnas", + "moveHint": "Haz clic para ordenar, arrastra para mover la columna" }, "dashboard": { "totalMessages": "Total de mensajes", diff --git a/frontend/src/lib/i18n/fr.json b/frontend/src/lib/i18n/fr.json index 40f9da8..8e67b5d 100644 --- a/frontend/src/lib/i18n/fr.json +++ b/frontend/src/lib/i18n/fr.json @@ -152,7 +152,8 @@ "resizeHint": "Glisser pour redimensionner, double-cliquer pour ajuster à la valeur la plus large", "fitColumn": "Ajuster cette colonne", "fitAll": "Ajuster toutes les colonnes", - "resetWidths": "Réinitialiser les largeurs" + "resetWidths": "Réinitialiser les colonnes", + "moveHint": "Cliquer pour trier, glisser pour déplacer la colonne" }, "dashboard": { "totalMessages": "Total des messages", diff --git a/frontend/src/lib/i18n/it.json b/frontend/src/lib/i18n/it.json index 2add9a4..13bcefd 100644 --- a/frontend/src/lib/i18n/it.json +++ b/frontend/src/lib/i18n/it.json @@ -152,7 +152,8 @@ "resizeHint": "Trascina per ridimensionare, doppio clic per adattare al valore più lungo", "fitColumn": "Adatta questa colonna", "fitAll": "Adatta tutte le colonne", - "resetWidths": "Reimposta le larghezze" + "resetWidths": "Reimposta le colonne", + "moveHint": "Clic per ordinare, trascina per spostare la colonna" }, "dashboard": { "totalMessages": "Totale messaggi", diff --git a/frontend/src/lib/i18n/ja.json b/frontend/src/lib/i18n/ja.json index 372362a..75189b7 100644 --- a/frontend/src/lib/i18n/ja.json +++ b/frontend/src/lib/i18n/ja.json @@ -152,7 +152,8 @@ "resizeHint": "ドラッグで幅変更、ダブルクリックで最長の値に合わせる", "fitColumn": "この列を自動調整", "fitAll": "すべての列を自動調整", - "resetWidths": "列幅をリセット" + "resetWidths": "列をリセット", + "moveHint": "クリックで並べ替え、ドラッグで列を移動" }, "dashboard": { "totalMessages": "メッセージ合計", diff --git a/frontend/src/lib/i18n/pt.json b/frontend/src/lib/i18n/pt.json index 213a1b0..3ec2c1d 100644 --- a/frontend/src/lib/i18n/pt.json +++ b/frontend/src/lib/i18n/pt.json @@ -152,7 +152,8 @@ "resizeHint": "Arraste para redimensionar, duplo clique para ajustar ao valor mais longo", "fitColumn": "Ajustar esta coluna", "fitAll": "Ajustar todas as colunas", - "resetWidths": "Repor as larguras" + "resetWidths": "Repor as colunas", + "moveHint": "Clique para ordenar, arraste para mover a coluna" }, "dashboard": { "totalMessages": "Total de mensagens", diff --git a/frontend/src/lib/i18n/zh.json b/frontend/src/lib/i18n/zh.json index 19228c7..6118a2c 100644 --- a/frontend/src/lib/i18n/zh.json +++ b/frontend/src/lib/i18n/zh.json @@ -152,7 +152,8 @@ "resizeHint": "拖动调整宽度,双击适应最长内容", "fitColumn": "适应此列", "fitAll": "适应所有列", - "resetWidths": "重置列宽" + "resetWidths": "重置列", + "moveHint": "单击排序,拖动移动列" }, "dashboard": { "totalMessages": "消息总数", From 1dc9674445fd77aa68df3cdf22b01c817abbef30 Mon Sep 17 00:00:00 2001 From: Wasabules <39313803+Wasabules@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:05:16 +0200 Subject: [PATCH 03/13] feat(log): a context menu on a log line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three groups, for the three things someone does with a line they have just spotted: carry it somewhere else, narrow the view around it, or make it wake someone up next time. - copy the message, the raw line, the whole record as JSON, or the value of the cell that was aimed at - filter by that host, that application or that severity, or ask for the five minutes around the line — the gesture of an incident, where you hold one line and want its neighbourhood - draft an alert rule from it, prefilled with the host, the application, the severity and the first few words of the message, since the whole text carries its own counters and would never match twice Copying takes what is DISPLAYED. In anonymous mode that is a stand-in, which is the point — the mode exists so a line can go into a ticket, and copying the real host behind the reader's back would defeat it exactly when it matters. The real value stays one click away, in an entry that says so. Filtering does the opposite and uses the real value, because a filter on "host-01" matches nothing. One thing the test found on the way: the filter boxes only ever PUSHED into the store, so a filter set from anywhere else left them empty while the list was narrowed. A list hiding rows with nothing in the bar to say why is the same trap as a mode with no indicator. They follow the store now — except the search box, which is debounced and would lose keystrokes. --- README.md | 4 + frontend/src/components/AlertConfig.svelte | 19 ++- frontend/src/components/FilterBar.svelte | 17 +++ frontend/src/components/LogRowMenu.svelte | 144 +++++++++++++++++++++ frontend/src/components/LogViewer.svelte | 38 ++++-- frontend/src/lib/cells.ts | 79 +++++++++++ frontend/src/lib/clipboard.ts | 32 +++++ frontend/src/lib/i18n/de.json | 14 +- frontend/src/lib/i18n/en.json | 14 +- frontend/src/lib/i18n/es.json | 14 +- frontend/src/lib/i18n/fr.json | 14 +- frontend/src/lib/i18n/it.json | 14 +- frontend/src/lib/i18n/ja.json | 14 +- frontend/src/lib/i18n/pt.json | 14 +- frontend/src/lib/i18n/zh.json | 14 +- frontend/src/lib/stores.ts | 5 + 16 files changed, 427 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/LogRowMenu.svelte create mode 100644 frontend/src/lib/cells.ts create mode 100644 frontend/src/lib/clipboard.ts diff --git a/README.md b/README.md index 9c11fea..83e5566 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,10 @@ in a ticket without going through a redaction tool first. - **Filter, sort and group** by severity, facility, host, application, source IP or time range - **Explicit timezones** — follow the machine, pin to UTC, or name a zone; the column header says which one it is showing +- **Right-click a line** to copy it (message, raw line, JSON, or the cell you aimed at), to + narrow the view to that host, application, severity or the five minutes around it, or to turn + it into an alert rule. In anonymous mode copying yields what is on screen, and the real value + is a separate entry — you cannot paste a real address believing it was masked - **Columns you arrange** — drag an edge to resize, double-click it to fit the widest value, drag a heading to move the column, or use the header's right-click menu to fit everything or start over. Widths and order are remembered diff --git a/frontend/src/components/AlertConfig.svelte b/frontend/src/components/AlertConfig.svelte index 51e8d4d..31e6754 100644 --- a/frontend/src/components/AlertConfig.svelte +++ b/frontend/src/components/AlertConfig.svelte @@ -3,7 +3,7 @@ import { anonymous, redactText } from '../lib/anonymize'; import { onMount } from 'svelte'; import { _ } from 'svelte-i18n'; - import { alertRules, alertHistory } from '../lib/stores'; + import { alertRules, alertHistory, draftAlertRule } from '../lib/stores'; import type { AlertRule } from '../lib/stores'; import { getAlertRules, addAlertRule, updateAlertRule, deleteAlertRule, getAlertHistory, clearAlertHistory } from '../lib/api'; import { SEVERITY_LABELS } from '../lib/constants'; @@ -32,6 +32,23 @@ } }); + // A line from the log view can arrive with a rule already sketched out. + // Taken once and cleared, so the form does not reopen on every later visit. + $: if ($draftAlertRule) applyDraft($draftAlertRule); + + function applyDraft(draft: Partial) { + draftAlertRule.set(null); + editingRule = null; + name = draft.name ?? ''; + pattern = draft.pattern ?? ''; + useRegex = draft.useRegex ?? false; + minSeverity = draft.minSeverity ?? -1; + hostname = draft.hostname ?? ''; + appName = draft.appName ?? ''; + cooldown = draft.cooldown ?? 60; + showForm = true; + } + function resetForm() { editingRule = null; name = ''; diff --git a/frontend/src/components/FilterBar.svelte b/frontend/src/components/FilterBar.svelte index d629b94..5393f6f 100644 --- a/frontend/src/components/FilterBar.svelte +++ b/frontend/src/components/FilterBar.svelte @@ -31,6 +31,23 @@ }, 200); } + // The filter can be set from outside this bar — the row context menu does + // it — and until now these boxes only ever PUSHED into the store. A list + // narrowed by a filter whose box looks empty is the same trap as a mode + // with no indicator: something is being hidden and nothing says what. + // + // The search box is deliberately left out: it is debounced, so the store + // lags what is being typed, and syncing it back would delete keystrokes. + $: followStore($filter); + + function followStore(f: typeof $filter) { + if (f.hostname !== hostnameText) hostnameText = f.hostname; + if (f.appName !== appNameText) appNameText = f.appName; + if (f.sourceIP !== sourceIPText) sourceIPText = f.sourceIP; + if (f.dateFrom !== dateFrom) dateFrom = f.dateFrom; + if (f.dateTo !== dateTo) dateTo = f.dateTo; + } + function setHostname() { filter.update(f => ({ ...f, hostname: hostnameText })); } diff --git a/frontend/src/components/LogRowMenu.svelte b/frontend/src/components/LogRowMenu.svelte new file mode 100644 index 0000000..83254b0 --- /dev/null +++ b/frontend/src/components/LogRowMenu.svelte @@ -0,0 +1,144 @@ + + + + +
+ + + + diff --git a/frontend/src/components/LogViewer.svelte b/frontend/src/components/LogViewer.svelte index e6ca4f3..719c34c 100644 --- a/frontend/src/components/LogViewer.svelte +++ b/frontend/src/components/LogViewer.svelte @@ -13,6 +13,8 @@ moveColumn, measureLongest, clampWidth, RESIZABLE, } from '../lib/columns'; import type { ColumnKey, AnyColumn } from '../lib/columns'; + import { shownValue } from '../lib/cells'; + import LogRowMenu from './LogRowMenu.svelte'; const ROW_HEIGHT = 28; const GROUP_ROW_HEIGHT = 32; @@ -480,18 +482,6 @@ // --- fitting a column to its content ------------------------------------- - /** What this column shows for a message, as the row renders it. */ - function cellText(key: ColumnKey, msg: SyslogMessage): string { - switch (key) { - case 'severity': return msg.severityLabel; - case 'timestamp': return formatInZone(msg.timestamp, $activeZone); - case 'protocol': return msg.protocol; - case 'source': return redactIP(msg.sourceIP, $anonymous); - case 'hostname': return redactHost(msg.hostname, $anonymous); - case 'app': return msg.appName; - } - } - // Enough rows to answer the question without stalling on a full buffer. // A column wide enough for the widest of five thousand lines is wide enough. const FIT_SAMPLE = 5000; @@ -507,7 +497,7 @@ function fitColumn(key: ColumnKey) { const rows = $filteredMessages.slice(0, FIT_SAMPLE); - const values = rows.map(m => cellText(key, m)); + const values = rows.map(m => shownValue(key, m, $anonymous, $activeZone)); // The heading has to fit too, or fitting a column to a file with no // hostnames in it would hide the word "Hostname". @@ -527,6 +517,20 @@ for (const key of RESIZABLE) fitColumn(key); } + // --- what there is to do with one line ----------------------------------- + let rowMenu: { msg: SyslogMessage; column: AnyColumn | null; x: number; y: number } | null = null; + + function openRowMenu(e: MouseEvent, msg: SyslogMessage) { + e.preventDefault(); + // Which cell was aimed at, so the menu can offer that value by name. + const cell = (e.target as HTMLElement)?.closest?.('[class*="col-"]'); + const key = cell + ? Array.from(cell.classList) + .find(c => c.startsWith('col-') && c !== 'col-wrap')?.slice(4) + : undefined; + rowMenu = { msg, column: (key as AnyColumn) ?? null, x: e.clientX, y: e.clientY }; + } + function openMenu(e: MouseEvent, key: AnyColumn) { e.preventDefault(); menu = { x: e.clientX, y: e.clientY, key }; @@ -599,6 +603,11 @@
{/if} + {#if rowMenu} + (rowMenu = null)} /> + {/if} +
@@ -624,7 +633,8 @@ style="top: {rowPositions[visibleStart + i]}px; height: {ROW_HEIGHT}px;" role="row" tabindex="0" on:click={() => selectMessage(msg)} - on:keydown={e => e.key === 'Enter' && selectMessage(msg)}> + on:keydown={e => e.key === 'Enter' && selectMessage(msg)} + on:contextmenu={e => openRowMenu(e, msg)}> {#each $columnOrder as key (key)} {#if key === 'severity'} diff --git a/frontend/src/lib/cells.ts b/frontend/src/lib/cells.ts new file mode 100644 index 0000000..3df6a38 --- /dev/null +++ b/frontend/src/lib/cells.ts @@ -0,0 +1,79 @@ +import type { SyslogMessage } from './stores'; +import type { AnyColumn } from './columns'; +import { redactHost, redactIP, redactText } from './anonymize'; +import { formatInZone } from './timezone'; + +/** + * What a column shows for a message, and what it holds underneath. + * + * Two functions rather than one because the difference between them matters: + * `shown` is what is on screen, which in anonymous mode is a stand-in, and + * `real` is what was received. Copying uses the first — someone who has turned + * the mode on is about to paste into a ticket — and filtering uses the second, + * since a filter on "host-01" would match nothing. + * + * Shared by the table and its context menu so the two cannot disagree about + * what a column contains. + */ + +export function shownValue( + key: AnyColumn, + msg: SyslogMessage, + anonymous: boolean, + zone: string, +): string { + switch (key) { + case 'severity': return msg.severityLabel; + case 'timestamp': return formatInZone(msg.timestamp, zone); + case 'protocol': return msg.protocol; + case 'source': return redactIP(msg.sourceIP, anonymous); + case 'hostname': return redactHost(msg.hostname, anonymous); + case 'app': return msg.appName; + case 'message': return redactText(msg.message, anonymous); + } +} + +export function realValue(key: AnyColumn, msg: SyslogMessage, zone: string): string { + switch (key) { + case 'severity': return msg.severityLabel; + case 'timestamp': return formatInZone(msg.timestamp, zone); + case 'protocol': return msg.protocol; + case 'source': return msg.sourceIP; + case 'hostname': return msg.hostname; + case 'app': return msg.appName; + case 'message': return msg.message; + } +} + +/** The whole record, as it is displayed. */ +export function messageAsJSON(msg: SyslogMessage, anonymous: boolean): string { + return JSON.stringify({ + timestamp: msg.timestamp, + receivedAt: msg.receivedAt, + severity: msg.severity, + severityLabel: msg.severityLabel, + facility: msg.facility, + facilityLabel: msg.facilityLabel, + hostname: redactHost(msg.hostname, anonymous), + appName: msg.appName, + procID: msg.procID, + msgID: msg.msgID, + sourceIP: redactIP(msg.sourceIP, anonymous), + protocol: msg.protocol, + structuredData: redactText(msg.structuredData, anonymous), + message: redactText(msg.message, anonymous), + }, null, 2); +} + +/** + * The value a `datetime-local` input takes, in the machine's own zone. + * + * Not UTC and not the display zone: this string goes into the filter, which + * reads a zoneless date in the machine's zone — the same rule the wire parser + * follows for an RFC 3164 timestamp (#24). + */ +export function toLocalInput(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + `T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts new file mode 100644 index 0000000..90b1b14 --- /dev/null +++ b/frontend/src/lib/clipboard.ts @@ -0,0 +1,32 @@ +/** + * Puts text on the clipboard, or says it could not. + * + * The async clipboard API needs a secure context and a permission that a + * webview does not always grant. The old selection-based path still works + * everywhere, so it stands behind the new one rather than the copy simply + * doing nothing — a menu item that silently fails is worse than no menu item. + */ +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { /* fall through */ } + + try { + const area = document.createElement('textarea'); + area.value = text; + // Off-screen but focusable: a hidden element cannot be selected, and + // scrolling the page to a visible one would move the list underneath. + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.top = '0'; + area.style.left = '-9999px'; + document.body.appendChild(area); + area.select(); + const ok = document.execCommand('copy'); + area.remove(); + return ok; + } catch { + return false; + } +} diff --git a/frontend/src/lib/i18n/de.json b/frontend/src/lib/i18n/de.json index 47ad6c5..13a33a8 100644 --- a/frontend/src/lib/i18n/de.json +++ b/frontend/src/lib/i18n/de.json @@ -153,7 +153,19 @@ "fitColumn": "Diese Spalte anpassen", "fitAll": "Alle Spalten anpassen", "resetWidths": "Spalten zurücksetzen", - "moveHint": "Klicken zum Sortieren, ziehen zum Verschieben der Spalte" + "moveHint": "Klicken zum Sortieren, ziehen zum Verschieben der Spalte", + "copyMessage": "Nachricht kopieren", + "copyRaw": "Rohzeile kopieren", + "copyJson": "Als JSON kopieren", + "copyValue": "„{value}“ kopieren", + "copyReal": "Echten Wert kopieren", + "copied": "Kopiert", + "copyFailed": "Kopieren nicht möglich", + "filterHost": "Nach diesem Host filtern", + "filterApp": "Nach dieser Anwendung filtern", + "filterSeverity": "Nach diesem Schweregrad filtern", + "around": "{minutes} Minuten um diese Zeile anzeigen", + "createAlert": "Alarmregel aus dieser Zeile erstellen" }, "dashboard": { "totalMessages": "Nachrichten gesamt", diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 9666c46..ba89ea7 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -153,7 +153,19 @@ "fitColumn": "Fit this column", "fitAll": "Fit all columns", "resetWidths": "Reset columns", - "moveHint": "Click to sort, drag to move the column" + "moveHint": "Click to sort, drag to move the column", + "copyMessage": "Copy message", + "copyRaw": "Copy raw line", + "copyJson": "Copy as JSON", + "copyValue": "Copy “{value}”", + "copyReal": "Copy the real value", + "copied": "Copied", + "copyFailed": "Could not copy", + "filterHost": "Filter by this host", + "filterApp": "Filter by this application", + "filterSeverity": "Filter by this severity", + "around": "Show {minutes} minutes around this line", + "createAlert": "Create an alert rule from this line" }, "dashboard": { "totalMessages": "Total Messages", diff --git a/frontend/src/lib/i18n/es.json b/frontend/src/lib/i18n/es.json index 11920d4..81cef9b 100644 --- a/frontend/src/lib/i18n/es.json +++ b/frontend/src/lib/i18n/es.json @@ -153,7 +153,19 @@ "fitColumn": "Ajustar esta columna", "fitAll": "Ajustar todas las columnas", "resetWidths": "Restablecer las columnas", - "moveHint": "Haz clic para ordenar, arrastra para mover la columna" + "moveHint": "Haz clic para ordenar, arrastra para mover la columna", + "copyMessage": "Copiar el mensaje", + "copyRaw": "Copiar la línea original", + "copyJson": "Copiar como JSON", + "copyValue": "Copiar «{value}»", + "copyReal": "Copiar el valor real", + "copied": "Copiado", + "copyFailed": "No se pudo copiar", + "filterHost": "Filtrar por este host", + "filterApp": "Filtrar por esta aplicación", + "filterSeverity": "Filtrar por esta severidad", + "around": "Mostrar {minutes} minutos alrededor de esta línea", + "createAlert": "Crear una regla de alerta desde esta línea" }, "dashboard": { "totalMessages": "Total de mensajes", diff --git a/frontend/src/lib/i18n/fr.json b/frontend/src/lib/i18n/fr.json index 8e67b5d..47427be 100644 --- a/frontend/src/lib/i18n/fr.json +++ b/frontend/src/lib/i18n/fr.json @@ -153,7 +153,19 @@ "fitColumn": "Ajuster cette colonne", "fitAll": "Ajuster toutes les colonnes", "resetWidths": "Réinitialiser les colonnes", - "moveHint": "Cliquer pour trier, glisser pour déplacer la colonne" + "moveHint": "Cliquer pour trier, glisser pour déplacer la colonne", + "copyMessage": "Copier le message", + "copyRaw": "Copier la ligne brute", + "copyJson": "Copier en JSON", + "copyValue": "Copier « {value} »", + "copyReal": "Copier la vraie valeur", + "copied": "Copié", + "copyFailed": "Copie impossible", + "filterHost": "Filtrer sur cet hôte", + "filterApp": "Filtrer sur cette application", + "filterSeverity": "Filtrer sur cette sévérité", + "around": "Afficher {minutes} minutes autour de cette ligne", + "createAlert": "Créer une règle d’alerte depuis cette ligne" }, "dashboard": { "totalMessages": "Total des messages", diff --git a/frontend/src/lib/i18n/it.json b/frontend/src/lib/i18n/it.json index 13bcefd..c683427 100644 --- a/frontend/src/lib/i18n/it.json +++ b/frontend/src/lib/i18n/it.json @@ -153,7 +153,19 @@ "fitColumn": "Adatta questa colonna", "fitAll": "Adatta tutte le colonne", "resetWidths": "Reimposta le colonne", - "moveHint": "Clic per ordinare, trascina per spostare la colonna" + "moveHint": "Clic per ordinare, trascina per spostare la colonna", + "copyMessage": "Copia il messaggio", + "copyRaw": "Copia la riga originale", + "copyJson": "Copia come JSON", + "copyValue": "Copia «{value}»", + "copyReal": "Copia il valore reale", + "copied": "Copiato", + "copyFailed": "Copia non riuscita", + "filterHost": "Filtra per questo host", + "filterApp": "Filtra per questa applicazione", + "filterSeverity": "Filtra per questa severità", + "around": "Mostra {minutes} minuti attorno a questa riga", + "createAlert": "Crea una regola di avviso da questa riga" }, "dashboard": { "totalMessages": "Totale messaggi", diff --git a/frontend/src/lib/i18n/ja.json b/frontend/src/lib/i18n/ja.json index 75189b7..6004188 100644 --- a/frontend/src/lib/i18n/ja.json +++ b/frontend/src/lib/i18n/ja.json @@ -153,7 +153,19 @@ "fitColumn": "この列を自動調整", "fitAll": "すべての列を自動調整", "resetWidths": "列をリセット", - "moveHint": "クリックで並べ替え、ドラッグで列を移動" + "moveHint": "クリックで並べ替え、ドラッグで列を移動", + "copyMessage": "メッセージをコピー", + "copyRaw": "生の行をコピー", + "copyJson": "JSON としてコピー", + "copyValue": "「{value}」をコピー", + "copyReal": "実際の値をコピー", + "copied": "コピーしました", + "copyFailed": "コピーできませんでした", + "filterHost": "このホストで絞り込む", + "filterApp": "このアプリケーションで絞り込む", + "filterSeverity": "この重大度で絞り込む", + "around": "この行の前後 {minutes} 分を表示", + "createAlert": "この行からアラートルールを作成" }, "dashboard": { "totalMessages": "メッセージ合計", diff --git a/frontend/src/lib/i18n/pt.json b/frontend/src/lib/i18n/pt.json index 3ec2c1d..818f405 100644 --- a/frontend/src/lib/i18n/pt.json +++ b/frontend/src/lib/i18n/pt.json @@ -153,7 +153,19 @@ "fitColumn": "Ajustar esta coluna", "fitAll": "Ajustar todas as colunas", "resetWidths": "Repor as colunas", - "moveHint": "Clique para ordenar, arraste para mover a coluna" + "moveHint": "Clique para ordenar, arraste para mover a coluna", + "copyMessage": "Copiar a mensagem", + "copyRaw": "Copiar a linha original", + "copyJson": "Copiar como JSON", + "copyValue": "Copiar «{value}»", + "copyReal": "Copiar o valor real", + "copied": "Copiado", + "copyFailed": "Não foi possível copiar", + "filterHost": "Filtrar por este anfitrião", + "filterApp": "Filtrar por esta aplicação", + "filterSeverity": "Filtrar por esta severidade", + "around": "Mostrar {minutes} minutos em torno desta linha", + "createAlert": "Criar uma regra de alerta a partir desta linha" }, "dashboard": { "totalMessages": "Total de mensagens", diff --git a/frontend/src/lib/i18n/zh.json b/frontend/src/lib/i18n/zh.json index 6118a2c..719c9ef 100644 --- a/frontend/src/lib/i18n/zh.json +++ b/frontend/src/lib/i18n/zh.json @@ -153,7 +153,19 @@ "fitColumn": "适应此列", "fitAll": "适应所有列", "resetWidths": "重置列", - "moveHint": "单击排序,拖动移动列" + "moveHint": "单击排序,拖动移动列", + "copyMessage": "复制消息", + "copyRaw": "复制原始行", + "copyJson": "复制为 JSON", + "copyValue": "复制“{value}”", + "copyReal": "复制真实值", + "copied": "已复制", + "copyFailed": "无法复制", + "filterHost": "按此主机筛选", + "filterApp": "按此应用筛选", + "filterSeverity": "按此严重程度筛选", + "around": "显示此行前后 {minutes} 分钟", + "createAlert": "从此行创建告警规则" }, "dashboard": { "totalMessages": "消息总数", diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index ebc593b..1481754 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -182,6 +182,11 @@ export const filter = writable({ }); export const selectedMessage = writable(null); + +// A rule the alert view should open with, handed over by the log line it came +// from. Cleared by the view once it has taken it, so returning to Alerts later +// does not reopen a form nobody asked for. +export const draftAlertRule = writable | null>(null); export const autoScroll = writable(true); export const activeView = writable<'logs' | 'dashboard' | 'alerts' | 'simulator' | 'notify'>('logs'); export const logViewMode = writable<'live' | 'history'>('live'); From b64a3dfe591b7036cd199df24030ee9d11248bda Mon Sep 17 00:00:00 2001 From: Wasabules <39313803+Wasabules@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:37:46 +0200 Subject: [PATCH 04/13] feat(log): one Export button instead of two CSV and TXT were two buttons for the same act with a different extension, in the scarcest space in the window. They are one button and a short menu now, which leaves room in a toolbar that has been gaining buttons. The menu opens leftwards: this button sits at the right edge, and a menu anchored the usual way would hang off the window. It uses the same dropdown and the same click-anywhere backdrop as the severity list, so there is one way these behave rather than two. --- frontend/src/components/FilterBar.svelte | 43 ++++++++++++++++++++++-- frontend/src/lib/i18n/de.json | 2 ++ frontend/src/lib/i18n/en.json | 2 ++ frontend/src/lib/i18n/es.json | 2 ++ frontend/src/lib/i18n/fr.json | 2 ++ frontend/src/lib/i18n/it.json | 2 ++ frontend/src/lib/i18n/ja.json | 2 ++ frontend/src/lib/i18n/pt.json | 2 ++ frontend/src/lib/i18n/zh.json | 2 ++ 9 files changed, 56 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/FilterBar.svelte b/frontend/src/components/FilterBar.svelte index 5393f6f..3c98e94 100644 --- a/frontend/src/components/FilterBar.svelte +++ b/frontend/src/components/FilterBar.svelte @@ -121,6 +121,9 @@ } let showSeverityDropdown = false; + // CSV and text are the same act with a different extension, and the + // toolbar is the scarcest space in the window. + let showExport = false; @@ -132,7 +135,12 @@ {$filter.severities.length > 0 ? $_('filter.severityCount', { values: { count: $filter.severities.length } }) : $_('filter.severity')} ▾ - {#if showSeverityDropdown} + {#if showExport} + + +{/if} + +{#if showSeverityDropdown}
@@ -335,6 +359,19 @@ background: var(--bg-hover); } + .export-wrap { position: relative; } + /* Opens leftwards: this button sits at the right edge of the toolbar, and + a menu anchored left would hang off the window. */ + .export-menu { left: auto; right: 0; min-width: 140px; } + .dropdown-item.as-button { + width: 100%; + background: none; + border: none; + text-align: left; + font-size: 11px; + color: var(--text-primary); + } + .action-btn { background: var(--bg-tertiary); color: var(--text-secondary); diff --git a/frontend/src/lib/i18n/de.json b/frontend/src/lib/i18n/de.json index 13a33a8..dbe7e4e 100644 --- a/frontend/src/lib/i18n/de.json +++ b/frontend/src/lib/i18n/de.json @@ -51,6 +51,8 @@ "clear": "Leeren", "csv": "CSV", "txt": "TXT", + "export": "Exportieren", + "exportHint": "Die gefilterten Nachrichten exportieren", "clearAllLogs": "Alle Protokolle löschen", "exportAsCSV": "Als CSV exportieren", "exportAsText": "Als Text exportieren", diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index ba89ea7..ad67edc 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -51,6 +51,8 @@ "clear": "Clear", "csv": "CSV", "txt": "TXT", + "export": "Export", + "exportHint": "Export the filtered messages", "clearAllLogs": "Clear all logs", "exportAsCSV": "Export as CSV", "exportAsText": "Export as Text", diff --git a/frontend/src/lib/i18n/es.json b/frontend/src/lib/i18n/es.json index 81cef9b..f4c4ac6 100644 --- a/frontend/src/lib/i18n/es.json +++ b/frontend/src/lib/i18n/es.json @@ -51,6 +51,8 @@ "clear": "Limpiar", "csv": "CSV", "txt": "TXT", + "export": "Exportar", + "exportHint": "Exportar los mensajes filtrados", "clearAllLogs": "Borrar todos los registros", "exportAsCSV": "Exportar como CSV", "exportAsText": "Exportar como texto", diff --git a/frontend/src/lib/i18n/fr.json b/frontend/src/lib/i18n/fr.json index 47427be..8f94663 100644 --- a/frontend/src/lib/i18n/fr.json +++ b/frontend/src/lib/i18n/fr.json @@ -51,6 +51,8 @@ "clear": "Effacer", "csv": "CSV", "txt": "TXT", + "export": "Exporter", + "exportHint": "Exporter les messages filtrés", "clearAllLogs": "Effacer tous les journaux", "exportAsCSV": "Exporter en CSV", "exportAsText": "Exporter en texte", diff --git a/frontend/src/lib/i18n/it.json b/frontend/src/lib/i18n/it.json index c683427..5543671 100644 --- a/frontend/src/lib/i18n/it.json +++ b/frontend/src/lib/i18n/it.json @@ -51,6 +51,8 @@ "clear": "Cancella", "csv": "CSV", "txt": "TXT", + "export": "Esporta", + "exportHint": "Esporta i messaggi filtrati", "clearAllLogs": "Cancella tutti i registri", "exportAsCSV": "Esporta come CSV", "exportAsText": "Esporta come testo", diff --git a/frontend/src/lib/i18n/ja.json b/frontend/src/lib/i18n/ja.json index 6004188..d0645db 100644 --- a/frontend/src/lib/i18n/ja.json +++ b/frontend/src/lib/i18n/ja.json @@ -51,6 +51,8 @@ "clear": "クリア", "csv": "CSV", "txt": "TXT", + "export": "エクスポート", + "exportHint": "絞り込んだメッセージをエクスポート", "clearAllLogs": "すべてのログを削除", "exportAsCSV": "CSV としてエクスポート", "exportAsText": "テキストとしてエクスポート", diff --git a/frontend/src/lib/i18n/pt.json b/frontend/src/lib/i18n/pt.json index 818f405..1dfc662 100644 --- a/frontend/src/lib/i18n/pt.json +++ b/frontend/src/lib/i18n/pt.json @@ -51,6 +51,8 @@ "clear": "Limpar", "csv": "CSV", "txt": "TXT", + "export": "Exportar", + "exportHint": "Exportar as mensagens filtradas", "clearAllLogs": "Apagar todos os registos", "exportAsCSV": "Exportar como CSV", "exportAsText": "Exportar como texto", diff --git a/frontend/src/lib/i18n/zh.json b/frontend/src/lib/i18n/zh.json index 719c9ef..30d2660 100644 --- a/frontend/src/lib/i18n/zh.json +++ b/frontend/src/lib/i18n/zh.json @@ -51,6 +51,8 @@ "clear": "清除", "csv": "CSV", "txt": "TXT", + "export": "导出", + "exportHint": "导出筛选后的消息", "clearAllLogs": "清除所有日志", "exportAsCSV": "导出为 CSV", "exportAsText": "导出为文本", From 18d86b83107a48eb382573ddbba1e0d73a8162e6 Mon Sep 17 00:00:00 2001 From: Wasabules <39313803+Wasabules@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:51:56 +0200 Subject: [PATCH 05/13] feat(log): a detail panel you can widen, and columns you can add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sizes the application was deciding for the reader. The detail panel was 350 pixels, which is right for a severity and a host and wrong for a Windows event whose message runs to several hundred characters of tab-separated fields. The edge between the list and the panel is draggable now — nine pixels wide and straddling the border, because a one-pixel border is not something anyone can hit on purpose — and double-clicking it goes back to the default. The width is clamped against the window as well as its own bounds: a panel wider than the window leaves no list to select from, and a width stored on a large screen must not do that on a small one. And every message carries a facility, a process id, a message id, an RFC version and a received time that nothing on screen would show. A syslog stream from one appliance needs none of them; a reader chasing a structured field or a process id needs exactly one, and opening each line to see it is the difference between reading a file and interrogating it. All five are columns now, hidden by default — a table that opens with eleven columns is a table nobody reads — and the heading's right-click menu turns any of them on. The last visible column cannot be hidden: a table with no columns shows nothing and offers no way back, since the menu that would restore them hangs off a heading that is no longer there. Moving a column now names the column it lands in front of rather than a position, because a position read off the header is an index into the VISIBLE columns, and with some hidden that is not an index into the order at all. Process and message ids sort as numbers when they are numbers, so 9 does not come after 10. --- README.md | 7 +- frontend/src/components/LogDetail.svelte | 51 ++++++++++++- frontend/src/components/LogViewer.svelte | 79 +++++++++++++++---- frontend/src/lib/cells.ts | 10 +++ frontend/src/lib/columns.ts | 97 +++++++++++++++++++++--- frontend/src/lib/i18n/de.json | 4 +- frontend/src/lib/i18n/en.json | 4 +- frontend/src/lib/i18n/es.json | 4 +- frontend/src/lib/i18n/fr.json | 4 +- frontend/src/lib/i18n/it.json | 4 +- frontend/src/lib/i18n/ja.json | 4 +- frontend/src/lib/i18n/pt.json | 4 +- frontend/src/lib/i18n/zh.json | 4 +- frontend/src/lib/layout.ts | 48 ++++++++++++ frontend/src/lib/stores.ts | 18 ++++- 15 files changed, 302 insertions(+), 40 deletions(-) create mode 100644 frontend/src/lib/layout.ts diff --git a/README.md b/README.md index 83e5566..a816f7a 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,11 @@ in a ticket without going through a redaction tool first. it into an alert rule. In anonymous mode copying yields what is on screen, and the real value is a separate entry — you cannot paste a real address believing it was masked - **Columns you arrange** — drag an edge to resize, double-click it to fit the widest value, - drag a heading to move the column, or use the header's right-click menu to fit everything or - start over. Widths and order are remembered + drag a heading to move the column, and right-click any heading to add or remove one: facility, + process id, message id, RFC version and received time are all there, hidden until wanted. + Widths, order and choice are remembered +- **A detail panel you can widen** — drag the edge between the list and the message, double-click + it to go back to the default - **Export** as CSV or plain text - **Import a log file** already on disk — `.log`, `.txt` or a rotated `.gz`. A captured syslog file is parsed exactly as it would be off the wire; a plain application log has its diff --git a/frontend/src/components/LogDetail.svelte b/frontend/src/components/LogDetail.svelte index 63370b8..3cbbd45 100644 --- a/frontend/src/components/LogDetail.svelte +++ b/frontend/src/components/LogDetail.svelte @@ -5,6 +5,7 @@ import { activeZone, zoneAbbreviation, formatInZone } from '../lib/timezone'; import { toastSuccess, toastError } from '../lib/toast'; import { _ } from 'svelte-i18n'; + import { detailWidth, setDetailWidth, DEFAULT_DETAIL_WIDTH } from '../lib/layout'; function close() { $selectedMessage = null; @@ -20,11 +21,47 @@ } } } + // Dragging the edge. Leftwards widens the panel, which is why the delta is + // subtracted: the panel grows into the space the list gives up. + let dragging = false; + let startX = 0; + let startWidth = 0; + + function startResize(e: PointerEvent) { + if (e.button !== 0) return; + e.preventDefault(); + dragging = true; + startX = e.clientX; + startWidth = $detailWidth; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + } + + function onResize(e: PointerEvent) { + if (!dragging) return; + setDetailWidth(startWidth - (e.clientX - startX)); + } + + function endResize(e: PointerEvent) { + if (!dragging) return; + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + dragging = false; + } {#if $selectedMessage} {@const msg = $selectedMessage} -
+
+ + +
setDetailWidth(DEFAULT_DETAIL_WIDTH)}>
{$_('log.messageDetail')} @@ -117,8 +154,18 @@ +

Syslog export

+
%d messages · written %s
+
%s
+ + + +`, + html.EscapeString(time.Now().In(loc).Format("2006-01-02")), + len(messages), + html.EscapeString(time.Now().In(loc).Format(exportTimeLayout)), + summary.String()) + + shown := messages + if len(shown) > maxHTMLPreview { + shown = shown[:maxHTMLPreview] + } + for _, msg := range shown { + colour := htmlSeverityColours[7] + if int(msg.Severity) >= 0 && int(msg.Severity) < len(htmlSeverityColours) { + colour = htmlSeverityColours[msg.Severity] + } + fmt.Fprintf(w, + ``+ + ``+"\n", + colour, + html.EscapeString(msg.SeverityLabel), + html.EscapeString(msg.Timestamp.In(loc).Format(exportTimeLayout)), + html.EscapeString(msg.SourceIP), + html.EscapeString(msg.Hostname), + html.EscapeString(msg.AppName), + html.EscapeString(msg.Message)) + } + + fmt.Fprint(w, "
SeverityTimestampSourceHostAppMessage
%s%s%s%s%s%s
\n") + if len(messages) > len(shown) { + // Said in the file rather than only in a toast at export time: whoever + // opens this may not be whoever wrote it. + fmt.Fprintf(w, + `
Showing the first %d of %d messages. A browser stops being usable long before the rest would fit; export as CSV or NDJSON for the whole set.
`+"\n", + len(shown), len(messages)) + } + fmt.Fprint(w, "\n") + return w.Flush() +} diff --git a/export_formats_test.go b/export_formats_test.go new file mode 100644 index 0000000..34db92d --- /dev/null +++ b/export_formats_test.go @@ -0,0 +1,231 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "SyslogStudio/internal/importer" + "SyslogStudio/internal/models" +) + +func exportSample() []models.SyslogMessage { + at := time.Date(2026, 3, 17, 21, 42, 10, 0, time.UTC) + return []models.SyslogMessage{ + { + ID: "1", Timestamp: at, ReceivedAt: at, + Severity: models.SevError, SeverityLabel: "Error", + Facility: models.FacLocal0, FacilityLabel: "local0", + Hostname: "vpn-gw-01", AppName: "ipsec", ProcID: "4242", + Message: "IKE_SA rekey failed", SourceIP: "10.0.0.7", Protocol: "TCP", + }, + { + ID: "2", Timestamp: at.Add(time.Second), ReceivedAt: at.Add(time.Second), + Severity: models.SevInformational, SeverityLabel: "Info", + Facility: models.FacUser, FacilityLabel: "user", + Hostname: "web-1", AppName: "sshd", + // A message that would break a line-based format if it were let + // through as it stands. + Message: "Accepted publickey\nfor deploy", SourceIP: "10.0.0.9", Protocol: "UDP", + }, + } +} + +func writeTo(t *testing.T, name, format string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := writeExport(path, format, exportSample(), time.UTC); err != nil { + t.Fatalf("writeExport(%s): %v", format, err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// --- NDJSON ------------------------------------------------------------------ + +func TestExport_NDJSONIsOneObjectPerLine(t *testing.T) { + out := writeTo(t, "out.ndjson", formatNDJSON) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2 — a message with a newline in it must not become two records", len(lines)) + } + + var first exportRecord + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("line 1 is not JSON: %v", err) + } + if first.SeverityLabel != "Error" || first.Hostname != "vpn-gw-01" || first.ProcID != "4242" { + t.Errorf("fields lost: %+v", first) + } + if first.Timestamp != "2026-03-17T21:42:10Z" { + t.Errorf("timestamp = %q, want RFC 3339", first.Timestamp) + } + + var second exportRecord + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("line 2 is not JSON: %v", err) + } + // The newline survives inside the value, which is the whole reason to use + // JSON rather than another line-based format. + if second.Message != "Accepted publickey\nfor deploy" { + t.Errorf("message = %q, want the newline kept inside the field", second.Message) + } +} + +// --- syslog ------------------------------------------------------------------ + +// The point of writing syslog is that it can be read back. Ours is the parser +// nearest to hand, and if it cannot read what we wrote, nothing else will. +func TestExport_RFC5424RoundTripsThroughTheImporter(t *testing.T) { + path := filepath.Join(t.TempDir(), "replay.log") + if err := writeExport(path, formatRFC5424, exportSample(), time.UTC); err != nil { + t.Fatal(err) + } + + var back []models.SyslogMessage + res, err := importer.Read( + importer.Options{Path: path, Year: 2026, Location: time.UTC, + Format: models.ImportFormat{Mode: models.ImportSyslog}}, + func(m models.SyslogMessage) bool { back = append(back, m); return true }) + if err != nil { + t.Fatalf("reading back: %v", err) + } + + if res.Imported != 2 || res.Syslog != 2 { + t.Fatalf("read back %d messages (%d with a priority), want 2 and 2", res.Imported, res.Syslog) + } + if back[0].SeverityLabel != "Error" || back[0].FacilityLabel != "local0" { + t.Errorf("priority lost: %s / %s", back[0].SeverityLabel, back[0].FacilityLabel) + } + if back[0].Hostname != "vpn-gw-01" || back[0].AppName != "ipsec" || back[0].ProcID != "4242" { + t.Errorf("origin lost: %q / %q / %q", back[0].Hostname, back[0].AppName, back[0].ProcID) + } + if back[0].Message != "IKE_SA rekey failed" { + t.Errorf("message = %q", back[0].Message) + } + if got := back[0].Timestamp.UTC().Format(time.RFC3339); got != "2026-03-17T21:42:10Z" { + t.Errorf("timestamp = %s", got) + } + // The embedded newline had to go somewhere, and a space is the only place + // it can go in a format that ends a record at one. + if strings.Contains(back[1].Message, "\n") || !strings.Contains(back[1].Message, "for deploy") { + t.Errorf("second message = %q", back[1].Message) + } +} + +func TestExport_RFC3164RoundTripsThroughTheImporter(t *testing.T) { + path := filepath.Join(t.TempDir(), "replay-bsd.log") + if err := writeExport(path, formatRFC3164, exportSample(), time.UTC); err != nil { + t.Fatal(err) + } + + var back []models.SyslogMessage + if _, err := importer.Read( + importer.Options{Path: path, Year: 2026, Location: time.UTC, + Format: models.ImportFormat{Mode: models.ImportSyslog}}, + func(m models.SyslogMessage) bool { back = append(back, m); return true }); err != nil { + t.Fatal(err) + } + + if len(back) != 2 { + t.Fatalf("read back %d messages, want 2", len(back)) + } + if back[0].SeverityLabel != "Error" || back[0].Hostname != "vpn-gw-01" { + t.Errorf("got %q / %q", back[0].SeverityLabel, back[0].Hostname) + } + if back[0].AppName != "ipsec" || back[0].ProcID != "4242" { + t.Errorf("tag lost: %q[%q]", back[0].AppName, back[0].ProcID) + } +} + +func TestExport_SyslogFillsEmptyFieldsWithADash(t *testing.T) { + msgs := []models.SyslogMessage{{ + Timestamp: time.Date(2026, 3, 17, 21, 42, 10, 0, time.UTC), + Severity: models.SevNotice, SeverityLabel: "Notice", + Facility: models.FacUser, Message: "bare", + }} + path := filepath.Join(t.TempDir(), "bare.log") + if err := writeExport(path, formatRFC5424, msgs, time.UTC); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + line := strings.TrimSpace(string(data)) + + // <13>1