Skip to content

feat(log): a log view you can arrange — columns, panel, and a menu on a line - #53

Merged
Wasabules merged 13 commits into
mainfrom
feat-resizable-columns
Sep 29, 2026
Merged

Wasabules merged 13 commits into
mainfrom
feat-resizable-columns

Conversation

@Wasabules

@Wasabules Wasabules commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Four things the log view could not do: change a column's width, change its place, show a field it was hiding, or do anything with a line except open it. Plus a toolbar that had been gaining buttons.

Columns

Which column deserves room, where it belongs, and whether it belongs at all, 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. So the reader decides and the application remembers — width, order and choice.

  • Drag an edge to resize. The handle straddles the edge and is nine pixels wide, because a five-pixel target is a target you miss.
  • Double-click the edge to fit the column to the widest value on screen, the heading included: fitting Hostname on a file with no hostnames must not hide the word "Hostname".
  • Drag a heading to move the column. Nothing happens until the pointer has travelled five pixels, because every one of those presses is also a click on the sort button.
  • Right-click a heading to fit, reset, or add and remove columns: facility, process id, message id, RFC version and received time are carried by every message and were shown by nobody. They are hidden by default — a table that opens with eleven columns is a table nobody reads — and the last visible column cannot be hidden, since the menu that would restore it hangs off a heading that would no longer be there.

The detail panel

350 pixels is right for a severity and a host, 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, double-click resets it, and the width is clamped against the window as well as its own bounds — a width stored on a large screen must not leave a small one with no list to select from.

A context menu on a line

Copy message                     Copy the raw line, or the whole record as JSON
Copy raw line                    Copy “vpn-gw-01”  ← the cell that was aimed at
Copy as JSON
Copy “vpn-gw-01”
──────────────────
Filter by this host              The core loop of the tool, one click instead of
Filter by this application       retyping into the filter bar
Filter by this severity
Show 5 minutes around this line  The gesture of an incident: you hold one line
──────────────────               and you want its neighbourhood
Create an alert rule from this line

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 Export button

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, opening leftwards because the button sits at the right edge.

Three defects the tests found

  • After a drag the browser does not always follow the release with a click, so the suppression meant for that click was left standing and swallowed the next heading clicked — which reads as sorting having stopped working.
  • The filter boxes only ever pushed into the store, so a filter set from the row menu 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.
  • Moving a column used a position, which is an index into the visible columns — wrong as soon as one is hidden. It names the column it lands in front of now.

Verified

Driven for real against the components, not just type-checked:

header before: severity timestamp protocol source hostname app message
after dragging Hostname to the front:
header after : hostname severity timestamp protocol source app message
row    after : hostname severity timestamp protocol source app message
header and rows agree: true       carried column dimmed: opacity=0.4
drop indicator visible: true      click still sorts: true

resize drag   : hostname 110 -> 200      (exactly the +90px dragged)
double-click  : source  110 -> 86        source text no longer clipped: true

detail panel  : 350 -> 470 dragged, stored 470, list gave up exactly that space
                double-click -> 350

column menu   : Fit this column / Fit all columns / Reset columns
toggles       : Severity Timestamp Received Proto Source Hostname App
                ProcID Facility MsgID Version Message
after adding Facility:
headers       : severity timestamp protocol source hostname app facility message
row cells     : severity timestamp protocol source hostname app facility message
value         : "daemon"    hidden stored: ["received","procID","msgID","version"]

row menu      : Copy message / Copy raw line / Copy as JSON / Copy “sw-access-03”
clipboard     : "sw-access-03"  (matches the cell)
hostname filter box: "sw-core-01"
date bounds   : [ '2026-03-17T21:37', '2026-03-17T21:47' ]   (±5 min around 21:42)
alert draft   : name "snmpd on sw-core-01", pattern from the message
anonymous mode: cell shows "host-06"; menu adds Copy “host-06” then Copy the real value

toolbar       : Import | Clear | Export▾   (was Import | Clear | CSV | TXT)
export menu   : Export as CSV / Export as Text, reaching the binding

svelte-check --fail-on-warnings, the frontend build and go test ./... are clean. 23 strings across all 8 locales (482 keys × 8, parity checked).

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.
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.
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.
@Wasabules Wasabules changed the title feat(log): resizable columns, with a double-click that fits the content feat(log): columns you can resize and move, and a context menu on a line Sep 28, 2026
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.
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.
@Wasabules Wasabules changed the title feat(log): columns you can resize and move, and a context menu on a line feat(log): a log view you can arrange — columns, panel, and a menu on a line Sep 28, 2026
… can drop

Five things that were each a small friction and together made the log view
something to operate rather than something to read.

**The keyboard.** A list of ten thousand lines that can only be walked with a
mouse is a list nobody walks. Arrows move the selection, Page Up/Down and
Home/End move further, Escape closes the detail, Shift+arrows extend a
selection, Ctrl+A takes everything on screen, and "/" or Ctrl+F jumps to the
search box — by name, because "the first text box in the filter bar" is the
source address, and a shortcut that lands in the wrong field is worse than no
shortcut. Walking the list turns auto-scroll off: reading is not the same as
watching, and being dragged to the bottom mid-sentence is what makes the
difference.

**Picking lines.** Click, Ctrl-click and Shift-click, the gestures every list
in every operating system already uses. The context menu then offers to copy
or export exactly those, and the rows stop selecting their own text — shift
means "up to here" in a list, and the browser's highlight was covering the one
that says which rows are picked.

**Freezing.** Turning auto-scroll off stops the view chasing the bottom, but
messages keep arriving and the rows still move under the cursor. During the
flood after an incident — the moment someone most needs to read one line —
that is the difference between reading and trying to. Nothing is dropped: the
view stops being rebuilt, the banner says how many are waiting, and releasing
shows them all at once.

**Saved filters.** The same four criteria get retyped a dozen times a day.
Typing them again is not hard; typing them again correctly, under pressure, at
three in the morning, is. Saving under a name that already exists replaces it,
because a list with two entries of one name is a list nobody can use.

**A file you can drop.** The importer reads files off disk and the only way to
name one was a dialog three clicks away. Dropping one now opens the dialog on
it — through the same path as the picker, so a file that arrives by accident is
as inspectable as one that was chosen on purpose.

Exporting a selection is a new binding rather than a narrowed filter: picking a
handful of lines and handing exactly those to someone is a different act, and
narrowing a filter until only those remain is not something anyone should have
to do. They are written in buffer order, not click order — an export that
reordered a log would be a strange thing to hand over.

The detail panel grew arrows and a position, since walking back to the row to
see the next message is the long way round, and the list now keeps whatever is
selected on screen wherever the selection came from.
The freeze control is a filled accent button, and marking it active set the
text to the accent colour as well — accent on accent, invisible. Held, the
button is the way OUT rather than the thing to press, so it stops shouting:
outlined, accent text on the footer's own background, with a pixel of padding
traded for the border so the box does not jump when it changes.
…matches the screen

CSV goes to a spreadsheet and text goes to a reader. Four formats join them,
each for something that happens to the file afterwards.

**NDJSON**, one object per line, for `jq`, a bulk index, a script. Not a JSON
array: a file of lines can be streamed or tailed while it is still being
written, and a document that has to be closed before it parses cannot. It also
closes an asymmetry — the importer has read JSON lines since v1.5 and nothing
could write them.

**Syslog, RFC 5424 and RFC 3164**, so a capture can be replayed into another
collector, or back into this one. The priority, host and application are the
ones the message arrived with: a relay may rewrite a message's origin on the
way out, but an export that rewrote what was captured would be an export of
something that never happened. A message containing a newline is folded onto
one line, since that is where a syslog record ends.

**An HTML report**, one file with no assets and no network, which opens from an
e-mail attachment on a machine that has never heard of this application. Every
field is escaped: a log line is attacker-controlled text and the report is
opened in a browser by someone who did not write it.

And the part that is not a format. Anonymous mode substitutes for DISPLAY, and
that substitution lives in the interface — so every export until now wrote the
real values while the screen showed stand-ins. Someone attaching that file to a
ticket would publish exactly what they believed they had masked, which is the
trap the mode exists to avoid and the one that produced #49.

So when the mode is on, the export follows the screen and says so, with the
received values one named click away. The interface sends what it is showing
rather than the backend learning to redact: a second implementation would
produce stand-ins that do not even match the ones on screen.

The three ways to export — a filter, a selection, a list of messages — now go
through one place that knows how an export is named, filtered and written.

Verified: NDJSON keeps an embedded newline inside its field rather than
splitting the record; both syslog formats are read back by our own importer
with severity, facility, host, tag, pid, message and timestamp intact, because
if the parser nearest to hand cannot read what we wrote, nothing else will; and
a message containing `<script>` lands in the report as text.
**A saved filter carrying a search term applied it invisibly.** The search box
is deliberately not synced from the store — it is debounced, and following the
store would delete keystrokes — so recalling a saved filter narrowed the list
by a term nothing on screen showed. That is the same trap as a filter whose box
looks empty, one level along. Applying a saved filter now fills the box itself.

**Arrow keys reached the list through an open dialog.** With the import dialog
up, pressing an arrow moved a selection nobody could see, and Escape cleared it
at the same moment the dialog was closing on the same keystroke. The list now
ignores the keyboard while anything is layered over it.

**Importing while the stream was held showed nothing.** The messages arrived,
the view did not rebuild, and the banner counted them — which reads as an
import that did nothing. Importing is a request to see a file, so it releases
the hold.

Found by driving the interface rather than reading it: each one passed
type-checking and looked right in a screenshot.
… mode

That sentence has been true since v1.4 and stopped being true one commit ago:
an export from a screen showing stand-ins now follows the screen unless the
real values are asked for. A setting that describes the opposite of what
happens is worse than one that says nothing.
…arget

Enabling file drop on the Go side is not enough. Wails delivers a drop only
when the element under the cursor carries the CSS custom property
--wails-drop-target with the value "drop"; its dragover handler returns early
otherwise, so a dropped file did nothing at all — no event, no error, no sign
that the window had considered it.

Declared on html, where a custom property inherits, so the whole window is a
target and there is no corner that quietly refuses.

And said out loud while the file is over it: Wails marks its targets with a
class during the drag, and a window that accepts a file but shows nothing until
the mouse is released is indistinguishable from one that ignores it.

Checked the way Wails checks: the property reads back as "drop" from the
sidebar, the filter bar, the header, a row, and a cell inside a row — every
element a cursor can be over when someone lets go.
Enabling file drop in the Go options only ALLOWS it, and runtime.OnFileDrop on
the Go side merely subscribes to an event. What installs the dragover and drop
listeners — and what resolves a dropped file's real path — is the frontend
calling window.runtime.OnFileDrop. Nothing did, so a file landed on the window
and nothing happened: no event, no error, no sign it had been considered.

Registered where the other listeners are, and the round trip through a Go
callback and a custom event is gone with it: the runtime hands the paths
straight to the frontend, which is the only place that wanted them.

Both halves were needed. The runtime also refuses to deliver unless the element
under the cursor is marked a drop target, which the previous commit fixed; that
alone still had nothing listening.

The stubbed runtime keeps the callback now, so the browser test delivers a drop
the way the desktop one does instead of forging the event it used to emit —
which is why the test passed while the feature did not.
The binding disappeared with the Go method it came from; regenerating is what
the previous commit should have carried with it.
@Wasabules
Wasabules merged commit 4af0f56 into main Sep 29, 2026
9 checks passed
@Wasabules
Wasabules deleted the feat-resizable-columns branch September 29, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant