May 2026
Middle-ground tools between Grafana Loki and a custom React dash
Context: my dash has a custom event/log browser (AG Grid + tRPC + React) that does correlation tagging, integer IDs from UUIDs, colour-coded cells, freeze-filters etc. Loki is too plain for this; the React dash works but is heavy to maintain. Survey of what sits between those two extremes.
TLDR per tool
Quick concept refreshers
Datasette: take a SQLite file, run datasette serve events.db, get a queryable read-only web UI. Plugin hook render_cell lets you return custom HTML per cell (so colour-coded integer tags = ~30 lines of Python). Single Python process, zero ceremony. Made by Simon Willison. Bad fit for live streams (you'd replicate Postgres → SQLite first), great for "here's a frozen dataset, explore it".
Evidence.dev: write pages/foo.md with SQL blocks fenced as ```sql and components like <DataTable data={events} /> → builds a static HTML site. Sources: Postgres, BigQuery, Snowflake, DuckDB. Page-level filters yes; complex live filtering/correlation no.
ClickHouse: column-store = stores all values of one column contiguously instead of all fields of one row. Means SELECT avg(latency) reads only the latency column, not the other 50 fields. Same-column values compress like crazy too. Result: count/group over 100s of millions of rows in seconds on one box. Trade-off: weak transactions, painful updates/deletes — append, query, expire only. Used by Uber logging, Cloudflare analytics, Sentry, PostHog. Run via docker run clickhouse/clickhouse-server, single binary, no JVM.
Grafana custom panel: yes you can use AG Grid + React inside one (@grafana/create-plugin scaffold + npm i ag-grid-react). Pain isn't React — it's Grafana's plugin build tooling, signing for prod, mapping data frame format → AG Grid rows, panel options editor. Trade your infra pain for Grafana's plugin ergonomics.
Text mind map
Decision heuristic for future-me
- Try Grafana transformations first — zero new infra, often gets further than expected
- Retool / Appsmith if I want to stop maintaining a frontend — best fit for the exact problem shape (table + custom cells + buttons)
- Grafana custom panel plugin if committed to Grafana and need AG Grid back
- ClickHouse only when Postgres event queries start timing out — orthogonal concern (storage, not UI)
- Skip Datasette/Evidence/Metabase/Streamlit/Kibana for this problem shape
Bulk-wrap words with substitute, and reclaiming s for mini.surround
Two related nvim things — wrapping every word in a list with round(...), and dropping flash.nvim so mini.surround can keep its default s prefix.
1. Substitute inside a visual selection (very-magic mode)
Goal: turn SELECT bet_id, amount, payout, amount_usd → SELECT bet_id, round(amount), round(payout), round(amount_usd).
Visually select the words after bet_id, (charwise v, then move to end), then:
Breaking it down:
:s/.../.../g— substitute,g= all matches on the line (not just the first)\v— very magic mode. Means most regex metachars (+,(,|,%V) work without backslashes. Without\vyou'd write\%V\w\+.%V— "only inside the current visual selection". Needed because:sfrom visual mode auto-fills'<,'>which is a line range, not a character range — so without%Vit would also try to wrapbet_id.\w+— one or more "word" characters (letters, digits, underscore). Why still\wunder\v? Because\vonly changes which chars need escaping;\wis the name of the character class itself (same as\d,\s). Very-magic doesn't replace\wwithw— barewwould just match the literal letter "w".round(&)—&in the replacement = the whole match. So each matched word gets wrapped.
2. Dropping flash.nvim, keeping mini.surround's default s mappings
flash.nvim claimed s/S in normal/visual/operator mode, which collided with mini.surround's sa/sd/sr. Removed flash entirely (f/t// cover most jumps fine). mini.surround now works with its defaults.
ELI5 mini.surround:
sa= surround add,sd= delete,sr= replace- After
sayou give it: (a) what to wrap — a textobject likeiw(inner word) or a visual selection — then (b) what to wrap with — a single char:([{"', orffor a function call (prompts for a name). - It does one wrap per invocation. No auto-iteration over commas/words.
Examples:
saiw)onamount→(amount)— add, inner word, with)saiwfonamount, typeround<CR>→round(amount)- visual-select
a, b, cthensafround<CR>→round(a, b, c)— wraps the whole selection as one unit sd"inside"hello"→hello— delete surrounding quotessr({on(x)→{x}— replace(with{
So: for the SQL line, substitute (option 1) wins because it's bulk. mini.surround shines for one-off wraps where typing round() and arrowing back in feels clunky.
Markdown viewer comparison
Looking for a good way to preview .md files in a browser. Two categories: terminal renderers (render in-terminal) and browser-based (serve to localhost).
Browser-based
Terminal-based
LSP issues
pasting this here while i remember - having some nvim LSP issues getting errors spewed out into messages
attempting nixos on raspberry pi
trying to follow this guys guide... so i made 0 notes last time... https://mtlynch.io/nixos-pi4/
so going to hydra and latest builds actually fuck it just used nixos minimal ISO (64 bit arm) from their downloads page... https://nixos.org/download/#nix-install-linux
back to hydra again (sigh) - think it needs a full image with booting capabilities or soetmhing
so
- download image (go to hydra click job => latest builds)
- just use the link claude gave me... https://hydra.nixos.org/job/nixos/release-25.05/nixos.sd_image.aarch64-linux/latest
- download the zst
- run the zst unpack zstd -d ~/Downloads/nixos-image-sd-card-25.05.813814.ac62194c3917-aarch64-linux\ (2).img.zst
- burn with caligula to SD (
sudo caligula burn <path>.img) - important — disconnect pikvm's virtual USB-MSD before booting. Pi boot order tried USB-MSD first and got stuck on pikvm's emulated drive, never falling back to SD. In pikvm UI: Drive → eject/disconnect.
Why the minimal ISO didn't work, but the SD image did
Was burning nixos-minimal-*-aarch64-linux.iso first. That ISO is built for generic aarch64 UEFI servers — expects something to provide UEFI so it can chainload GRUB. Pi 4's boot ROM has no UEFI; it only knows to find start4.elf + config.txt on a FAT partition. So the minimal ISO is unbootable on a Pi alone.
The SD image (nixos-image-sd-card-*.img.zst) bundles everything Pi-native: start4.elf, U-Boot, kernel, rootfs. The Pi firmware boots it directly with no shim.
(Alternative: keep the minimal ISO and put pftf UEFI firmware on a separate SD — that gives the Pi UEFI capability and the ISO boots normally. Two-stage; the mtlynch guide uses this route. SD image is simpler.)
Boot chain comparison — Pi vs x86 vs Pi+pftf
Every boot has 4 stages: ROM → FIRMWARE → BOOTLOADER → KERNEL. What differs is where each stage lives.
Key takeaways:
- On x86 UEFI, firmware lives in the motherboard chip and natively understands the EFI System Partition convention (
EFI/BOOT/BOOTxxx.EFI). - On Pi 4, the chip-ROM is deliberately tiny — only knows to find
start4.elfon a FAT partition and run it. The real firmware (start4.elf) lives on the SD card. config.txt= Pi firmware config file (analogous to UEFI variables).u-boot= the Pi-side equivalent of GRUB (readsextlinux.conf, picks a kernel).- pftf packages a full UEFI implementation as a "kernel" that
start4.elfloads — once running, anything downstream sees a UEFI machine. - The same conceptual chain (ROM → firmware → bootloader → kernel) exists everywhere; only filenames and conventions differ.
A "Pi-bootable image" = one containing start4.elf + config.txt + u-boot.bin. NixOS SD image, Ubuntu Pi image, Raspberry Pi OS — all siblings. NixOS minimal ISO, Ubuntu server ISO, Debian aarch64 netinst — all siblings on the other side, designed for UEFI machines.
Messages log and notification history
Because i forgot AGAIN - im writing another note because i keep forgetting 😓 LMAO.
Linux font system — refresher
Came up investigating why -> was rendering as an arrow in both Kitty and Brave's URL bar.
The font family abstractions
serif, sans-serif, monospace etc. are generic family names — not real fonts. They're abstract labels. When an app asks for "monospace", fontconfig resolves that label to a concrete font file based on your config. The app never picks a file directly.
standardised in Brave's font settings = the browser's default proportional font (what you read body text in). Brave maps it to sans-serif under the hood.
The resolution chain
Where each app actually gets its font
Chromium renders the URL bar text in the fixed-width (monospace) font from chrome://settings/fonts, not the sans-serif. If your monospace font has ligatures, -> in a URL becomes →.
fc-* tools cheatsheet
Ligatures
Ligatures are an OpenType feature — the font file contains substitution rules that replace a sequence of characters (->) with a single combined glyph (→). Two feature flags control this:
Programming ligature fonts: Fira Code, JetBrains Mono, Cascadia Code, Hasklig, Iosevka.
Space Mono (including the Nerd Font variant) does not have ligatures — if -> still renders as an arrow, check what font is actually being loaded (use fc-query above).
Disabling per app:
NixOS font config map
GTK and Qt pick up fontconfig defaults only if no app-specific or DE-specific override exists. GNOME overrides via gsettings (set by gnome-tweaks or dconf). KDE overrides via Plasma System Settings. Without a DE, they fall straight through to fontconfig — making your NixOS config the single source of truth.
Terminal Control Characters — why C-[ = Escape and C-/ = C-_
Came up debugging why a Telescope mapping #!lua ['<C-/>'] = 'to_fuzzy_refine' registered fine but silently did nothing when pressed.
Root cause: the terminal converts your keypress into a raw byte before Neovim sees anything. The byte for Ctrl+/ is the same byte as Ctrl+_. Neovim names that byte #!lua '<C-_>'. The mapping was registered under #!lua '<C-/>' — wrong name, no match.
ASCII ranges and what bit operations do
The 128 ASCII characters split into four ranges. Bit operations move between them:
Key points from the diagram:
- Ctrl key = "I am in the blue box, clear bit 6" → lands in red (control codes)
- Case toggle = "I am in the blue box, set bit 5" → lands in green (lowercase)
A–Zmap cleanly:A(65) ↔a(97),Z(90) ↔z(122)- Edge case:
_(95) + 32 = 127 = DEL — the "lowercase" of_is the Delete character
What actually happens when you hold Ctrl
The terminal (kitty, xterm, etc.) doesn't send Ctrl+A as a chord — it sends a single byte. The byte is computed by taking the character's ASCII value and stripping bit 6 (subtracting 64). This is a hardware-era convention from 1960s teletypes, documented in ANSI X3.4 / ASCII standard.
Columnar bit view — exactly which bit changes:
Ctrl+A → 0x01
Vim name: <C-a> — "increment number" in normal mode
Ctrl+[ → 0x1B (Escape!)
Vim name: <C-[> — identical to <Esc>, always, no exceptions
Ctrl+_ → 0x1F
Vim name: <C-_> — also what Ctrl+/ sends (see below)
This works cleanly for the @A–Z[\]^_ range (ASCII 64–95). Outside that range (like / = 47), behaviour is terminal-specific.
The Ctrl+/ wrinkle
/ is ASCII 47 — below the clean range. 47 & 31 = 15, not useful. But on a US keyboard, / and ? share the same physical key. Most terminals use the shifted character (?) when applying Ctrl to keys outside the range:
Ctrl+/ → 0x1F (via ?)
Vim receives byte 0x1F and names it <C-_> — NOT <C-/>
#!lua ['<C-/>'] = 'to_fuzzy_refine' silently failed
The mapping registered under the name #!lua '<C-/>', but the terminal sent 0x1F which Neovim labels #!lua '<C-_>'. Different names → no match → fell through to Telescope's default <C-/> handler (which_key).
- Neovim registers this name, but the terminal never produces this exact byte sequence
0x1F— this is what the terminal actually sends forCtrl+/
Surprising aliases (all terminal-level — byte set before Neovim sees it)
#!lua '<C-[>' and #!lua '<Esc>' are the same byte — you cannot remap one without the other
Both produce 0x1B. Kitty's extended keyboard protocol can distinguish them, but only if the application (Neovim, Telescope) opts in. Telescope does not.
Verify what your terminal actually sends
NixOS boot hangs
Date: 2026-06-04 — after rebuilding the machine with UWSM + Hyprland.
Three separate issues, all showing up on every boot. Investigated with a diagnostic script (scratchpads/investigate-uwsm-hang.sh).
TL;DR
- Frozen in tuigreet —
NetworkManager-wait-onlinetakes 34s and blocksgraphical.target; UWSM has a 60s timeout that races it - 15 coredumps every boot —
hypridlehard-crashes because~/.config/hypr/hypridle.confdoesn't exist - pixman BUG log spam — NVIDIA + Hyprland upstream rendering bug, non-fatal
Issue 1 — "graphical.target is queued for start, waiting for 60s…"
What you see: After logging in via tuigreet, a countdown appears. On a good network day it completes. On a bad network day (offline, DHCP slow, cold boot) it hits zero, Hyprland never launches, and you're left in a non-responsive tuigreet.
The dependency chain causing the race:
The boot timeline shows the race clearly — tuigreet is ready at ~4s, the user can log in at ~15s, but UWSM needs graphical.target which isn't active until 40s:
Evidence — the smoking gun:
This boot worked. If NM-wait-online had taken 45s instead of 34s, Hyprland would never have started.
The unresponsive tuigreet happens when UWSM's 60s timeout expires. It exits, greetd gets the session back, but the VT (Virtual Terminal) state is mangled — the screen shows the last countdown digit and won't accept input.
Fix:
Docker's daemon doesn't need the network to be fully online before starting. This removes the 34s block entirely.
Issue 2 — hypridle crashes every single boot (all 15 coredumps)
What you see: After leaving the machine the screen may go dark (DPMS) but the session is never locked. On return: blank screen, no lock prompt, machine feels dead.
Why: hypridle 0.1.7 throws an unhandled C++ std::runtime_error if its config file doesn't exist. It doesn't print a warning and exit — it calls abort(). systemd restarts it 5 times in 72ms then gives up.
Evidence:
Fix: create ~/.config/hypr/hypridle.conf. Minimal working example:
lock_cmd to your locker
hyprlock is the standard Hyprland locker. swaylock works too. The file just needs to exist and parse — hypridle won't crash even if the lock command fails at runtime.
Issue 3 — pixman "Invalid rectangle" spam (NVIDIA)
Non-fatal. Hyprland keeps running. Just log noise and occasional minor rendering glitches.
What's happening: Hyprland's damage-tracking code (via aquamarine/wlroots) computes zero-size or negative rectangles under certain NVIDIA GPU states, then passes them to pixman's region API. Pixman logs the bug and recovers.
Driver version confirmed:
Workaround — add to ~/.config/hypr/hyprland.conf:
Not fixable in config alone. The env vars reduce frequency. Track github.com/hyprwm/Hyprland for a proper fix.
Fix summary
Domain knowledge: how does this all fit together?
systemd-analyze critical-chain
critical-chain TARGET walks the After= ordering graph backwards from your target, picks the slowest predecessor at each step, and prints the resulting chain. The numbers mean:
@40s= this unit became active 40 seconds after boot start+10s= this unit itself took 10 seconds to start
It's a critical path algorithm (same idea as in project management Gantt charts). Every unit in the chain is a bottleneck — removing any one of them wouldn't speed things up because the others are still there.
The actual unit files that created the dependencies:
Docker added Wants=network-online.target for server use cases (pulling images at boot, restarting containers). On a desktop where you start docker manually, it's dead weight and costs 35 seconds.
hypridle: where does it come from if it's not in the config?
programs.hyprlock.enable = true in nixos-core-desktop.nix installs hyprlock and hypridle as a pair — they're companion tools (hypridle = idle detection daemon that decides when to trigger the lock; hyprlock = the actual lock screen). The NixOS programs.hyprlock module pulls in both.
The mechanism for auto-starting: the hypridle package ships its own systemd unit file with:
NixOS sees this and creates a symlink /etc/systemd/user/graphical-session.target.wants/hypridle.service → pointing back to the unit in the nix store. When graphical-session.target activates (i.e. after you log in and Hyprland starts), hypridle starts automatically. You never wrote hypridle anywhere — it arrived as a silent side-effect of enabling hyprlock.
SYSLOG_IDENTIFIER — what it is
Every journal entry is stored as structured key=value fields. SYSLOG_IDENTIFIER is the field that identifies who wrote a message. For systemd-started processes, it defaults to the binary name. Programs can override it in code or via SyslogIdentifier= in their unit file.
The hex hashes in journalctl -F SYSLOG_IDENTIFIER are kernel/firmware messages (GRUB, EFI, initrd) — printk() doesn't set a name so they get a hash. Ignore them. The human-readable names are what you'd filter by.
UWSM sets four identifiers, one per startup phase:
Canonical reference: man systemd.journal-fields or https://systemd.io/JOURNAL_NATIVE_PROTOCOL/
Useful commands:
nvim-treesitter ELI5 — how tree-sitter, parsers, queries, and nvim all fit together
Date: 2026-06-05 — after NixOS 26.05 upgrade broke treesitter config and telescope.
TL;DR
- Tree-sitter = a C library that parses source code into a syntax tree
- Parsers = compiled grammar files (
.so) — one per language - Queries = pattern-matching rules (
.scmfiles) that say "highlight this node red" - vim.treesitter = Neovim's built-in integration of tree-sitter (since Neovim 0.5)
- nvim-treesitter (plugin) = used to do everything; now (v1.0 rewrite) just installs parsers + provides queries
- nvim-treesitter v1.0 requires Neovim 0.12 nightly — we're on 0.11.7, which is why things broke
The full picture
Step 1 — Tree-sitter gives you a syntax tree
You give tree-sitter source code text, it gives back a tree. That's it. That's all it does.
Without this tree, highlighting is done with regex (dumb string matching). With the tree, Neovim knows foo is a function name, not just a word that happens to follow function. That's why treesitter highlighting is more accurate and doesn't break across multiple lines.
Step 2 — Parsers are the compiled grammar for one language
A parser is a .so (compiled C library) file — one per language. It contains the grammar rules that let tree-sitter understand that language's syntax.
Without lua.so, tree-sitter cannot parse Lua. Neovim 0.11 bundles several parsers baked into its own binary (lua, vim, vimdoc, bash, markdown). Everything else you install via :TSInstall.
Step 3 — Queries tell Neovim what to DO with the tree
A parser gives you the tree. A query (.scm file) tells Neovim what to do with specific tree nodes.
Queries are the link between "I have a parsed tree" and "I want highlighting / folds / indents". Without queries, the parser is useless.
Step 4 — What Neovim provides built-in vs what needs a plugin
Key point: Neovim knows HOW to fold (the mechanism). But it needs a query file to know WHAT to fold. nvim-treesitter provides those query files for 100+ languages. That's its main job now.
Folds specifically — where does Neovim end and the plugin begin?
Neovim has always had folding. The fold mechanisms (manual, indent, marker, expr) are built-in. Tree-sitter folding uses expr mode:
So: the fold engine = Neovim. The fold map for each language = nvim-treesitter's query files.
Text objects — what are they and where do they come from?
vaf (visual around function), dic (delete inside class), ]m (jump to next method) — these are not built into Neovim, and not part of nvim-treesitter core. They come from the separate plugin nvim-treesitter-textobjects.
How it works:
Without nvim-treesitter-textobjects installed, vaf does nothing. It's a separate plugin that uses the tree-sitter API.
What changed across Neovim versions
The short version: Neovim has been slowly absorbing the plugin's functionality. Every release, something that needed the plugin now works natively. By 0.12, enough moved in that the plugin shed its feature modules entirely.
So what does nvim-treesitter actually do today?
After v1.0:
- Installs parsers —
python.so,typescript.so, etc. via:TSInstall. Neovim only bundles ~10 parsers. There are 100+. - Provides query files — the
.scmfiles for highlighting, folding, indenting across all those languages. Without these, Neovim has the parser but no rules for what to do with it.
That's it. It's a data package + an installer. All the actual doing moved into Neovim.
The query files are developed fast (new languages, bug fixes) and need to be versioned separately from Neovim itself. Bundling 100+ languages' query files into Neovim's binary would bloat it and slow down the release cycle.
What broke on the 26.05 upgrade
Three specific breakages:
The version mismatch issue
The new plugin explicitly says "Neovim 0.12.0 or later (nightly)". The master branch is locked and supports 0.11. If more things break, pin nvim-treesitter back to master branch in lazy-lock.json or the lazy spec:

