Skip to main content

Centralized Log Viewer (CLV)

CLV is a fast, Textual-powered TUI that gives Linux operators a Windows Event Viewer–inspired experience. Point it at any number of folders and/or individual files, and it discovers, tails, filters and colorizes them — the same way on a desktop terminal and on a headless 80-column SSH session.


Feature Highlights

  • 🔎 Search that works on real logs. The query is a regex matched against the whole line, across every format CLV recognises — and lines it cannot parse are still searchable rather than silently dropped. Smart case: a lowercase query is case-insensitive, an uppercase character opts back in.
  • 🧬 Multi-format parsing. syslog (RFC 3164 and 5424), ISO-8601/bracketed levels, Python logging, JSON lines, logfmt (level=info msg="...", what Go and Rust services write), Common Log Format access logs — and any format a plugin teaches it. Anything else is kept as a raw line with its text intact.
  • 🧵 Stack traces stay attached. A line no format recognises inherits the timestamp and severity of the entry above it, so a traceback survives a "show me only errors" filter along with the ERROR that produced it.
  • 🔬 Select a line, see the whole event. Arrow keys move a cursor through the log; Enter opens a detail pane showing the raw line beside its parsed timestamp, canonical severity, detected format and every field the parser recovered — host, tag, PID, HTTP status, or the flattened keys of a JSON payload.
  • 🪶 Bounded memory, whatever the file size. Opening a source seeks backwards from the end of the file; a 160 MB log opens in ~2 ms using under a megabyte. Tailing reads only what was appended. Compressed members are the honest exception — see Compressed and rotated logs.
  • 🌐 Anywhere includes another machine. Name a host in settings.conf — or press R and add it — and its folders appear in the same tree as the ones on this disk, discovered recursively, tailed, filtered, starred and merged. It reads over ssh with the setup you already have: your agent, your keys, your ~/.ssh/config with its ProxyJump and known_hosts. There is no password option and no sudo option, nothing is installed on the remote and nothing is left running there, and none of it happens until you turn enable_ssh on — see Remote sources over SSH.
  • 🧭 Any file, not just *.log. Name folders or files; every readable text file counts — including UTF-16 exports from Windows and PowerShell, and .ods spreadsheets, which are unpacked into tab-separated rows. Binary files are detected by content and skipped, and include/exclude globs are yours to set.
  • 🗂 Compressed and rotated logs. .gz, .bz2 and .xz are read directly, and app.log + app.log.1 + app.log.2.gz are presented as one source spanning all three, oldest lines first. Only the live member is tailed; the rotated-out ones are read once, and only as far back as your buffer needs.
  • 🧱 Structured columns. o turns a dense log into aligned time, level and source cells with the message starting at the same column on every row, and pretty-prints JSON, XML, HTML, CSS and CSV payloads.
  • 📐 Responsive layout. Breakpoints at 90 and 130 columns reflow the controls; every control stays on screen and keyboard-reachable down to 80 columns.
  • 📊 See when it started. b draws a one-row histogram of the filtered set above the log, coloured by the worst severity in each bucket. It is a control rather than a picture: ←/→ and Enter narrow the time window to the spike you are pointing at, which is an ordinary custom range you can dismiss like any other filter.
  • 🔖 Mark the lines that matter. m bookmarks the line under the cursor, M steps between the marks, and Ctrl+E can export just those. Marks are keyed by content rather than position, so they survive filtering and tailing — and they are session-only, never written to disk.
  • 🧹 Collapse the noise. c folds repeated lines into one ×147 row by normalising the volatile tokens — IDs, IPs, durations, paths — out of them. Nothing is hidden: Enter expands a cluster in place and every line inside is still selectable, markable and exportable.
  • 📤 Get the view out. Ctrl+E writes the filtered entries as JSON Lines, CSV or raw text — the whole filtered set, not just the lines on screen. y copies the selected line — or the whole visible view — to your local clipboard through the terminal, so it works over SSH and tmux where a mouse selection does not.
  • ⧉ Merge several logs into one stream. x adds a log to the merged set, u opens the set as one timestamp-ordered pane with a source column. Filters, navigation, marks, the detail pane and export all work there exactly as they do on a single file. A set may span machines: with SSH configured, local and remote logs interleave in one pane, and node: says which machine each line came from.
  • 🧩 Plugins. Thirteen interfaces — sources, log formats, query operators, computed fields, filter stages, cluster rules, shape contributors, timeline annotations, timeline metrics, watch rule kinds, watch destinations, exporters and commands — published as a versioned API in clv.api, with a worked, copyable example for every one of them. Install one by copying a file into ~/.config/clv/plugins/ — no root, no Python toolchain, and it survives a package upgrade. A file there is listed but not run until you name it in the plugins setting, so installing a plugin and running one stay two separate decisions. A broken plugin is reported, never fatal. A plugin is trusted code — it runs with your privileges, in CLV's process, and can read every log CLV can open; install one the way you would install any other program. clv/plugins/AGENTS.md has the trust model in full.
  • ⭐ Starred logs. Press * on any log to star it. Starred logs are repeated in a group at the top of the tree, so a favourite buried several folders deep is one keystroke away. Star exactly one and CLV opens it on launch. A star whose log has since rotated away is still listed — dimmed, and carrying the ✕ that removes it.
  • 💾 Session state that persists. Filters, toggles, drawer settings and stars come back on restart. The source you merely had open does not: without a star, CLV opens on its discovery summary, so every launch starts from a known state rather than silently resuming a tail.

Installation

curl -fsSL https://raw.githubusercontent.com/r0tifer/Centralized_Log_Viewer/main/install.sh | bash

Downloads the release build for your architecture (x86_64 or aarch64), verifies it against SHA256SUMS — and against the maintainer's GPG signature when one is published — then installs the program tree and a clv launcher on your PATH. Without root it installs under ~/.local.

# Pin a version, choose locations, or require a specific signing key
install.sh --version v3.0.0
install.sh --prefix ~/bin --libdir ~/opt/clv
install.sh --gpg-fpr <fingerprint>     # fail unless SHA256SUMS is signed by this key

Prebuilt packages

Download release assets from GitHub Releases:

# Debian/Ubuntu
sudo dpkg -i centralized-log-viewer_X.Y.Z-1_amd64.deb

# RHEL/Fedora/openSUSE
sudo rpm -Uvh centralized-log-viewer-X.Y.Z-1.x86_64.rpm

Both install the PyInstaller tree under /opt/centralized-log-viewer with a clv launcher in /usr/local/bin. A centralized-log-viewer-linux-<arch>.tar.gz tarball also ships with every release as a universal fallback.

The binaries are built on AlmaLinux 8, so they need glibc 2.28 or newer — RHEL/Rocky/Alma 8+, Debian 11+, Ubuntu 20.04+, and anything more recent. The build fails rather than ships if that floor creeps upward. Nothing is bundled that a system tool will be made to load: CLV strips its own library path before running journalctl, so a bundle built on one distribution cannot break a binary belonging to another.

From source (developers)

git clone https://github.com/r0tifer/Centralized_Log_Viewer.git
cd Centralized_Log_Viewer
python -m pip install -e .
python -m clv   # or: clv

CLV creates ~/.config/clv/settings.conf on first run, whichever method you use.


Configuration

settings.conf is resolved in this order:

  1. ${XDG_CONFIG_HOME:-~/.config}/clv/settings.conf — created on first run.
  2. settings.conf in the repository root — development fallback.
Option Purpose Default
log_dirs Folders and/or files to monitor, comma separated. Folders are searched recursively. /var/log
include_globs Only list files matching these globs. Empty means every text file. (empty)
exclude_globs Never list files matching these globs. archives, binary journals, PDFs
follow_symlinks Follow symlinked directories (cycles are detected). false
skip_binary Skip files whose first block decodes to NUL characters. UTF-16 text, extractable documents (.ods) and compressed members are exempt. true
max_files Stop discovery after this many files. 5000
group_rotated Present a rotated log's members as one source. Overridden by the drawer's Group rotated switch once you touch it. true
max_buffer_lines Lines held in memory per source. 5000
default_show_lines / min_show_lines / show_step Visible-line window and its +/- step. 500 / 10 / 50
refresh_hz Poll frequency for new content. 2
tree_width Starting width of the source tree, in columns. 38
csv_max_rows / csv_max_cols Bound the CSV payload preview only. The structured switch previews JSON, XML, HTML, CSS and CSV; the other four need no limits. 20 / 10
clipboard_max_bytes Most log text one y clipboard copy may carry. Oversized copies are truncated at a line boundary and say so. 65536
watch_rate_limit Seconds a watch rule waits before notifying again; matches inside the window are counted and reported together. 60
watch_bell Ring the terminal bell when a watch rule notifies. false
cluster_lookback How far back, in entries, c may reach to fold a repeated line into a cluster. A bound, not a taste: it keeps one cluster from spanning a session. 200
enable_journald Offer the systemd journal as a source. Off by default: reading it runs journalctl, and CLV spawns no subprocess unasked. The drawer's switch writes this line for you. false
plugins Plugins to load from ~/.config/clv/plugins/, comma separated, named without the .py. A file in that directory is listed but not imported until it is named here — installing a plugin and running one are two decisions. Plugins bundled with CLV are not listed here. (empty)
plugin_time_budget_ms Wall time one plugin may spend on a single pass of the render path. A plugin over it on three consecutive passes is disabled and named in the P dialog. 0 turns the guard off. 250
plugin_read_budget_ms The same, for a plugin-supplied log format, measured over one batch of lines read from a file rather than over a render. 0 turns the guard off. 50
plugin_host_timeout_ms How long a plugin running in its own process may take to answer one call before CLV kills it. The only ceiling here that can actually be enforced — see Running a plugin where it can be stopped. 0 waits forever. 5000
enable_ssh Read log folders on machines named in [ssh:<name>] sections. Off by default, and for a stronger version of the same reason: a remote source spawns a network subprocess. With it false nothing connects, however many hosts are configured. false

Invalid values fall back to safe defaults; the app never fails to start because of a malformed settings file. Most discovery options are also editable at runtime in the Advanced drawer.

Remote host sections

One [ssh:<name>] section per machine, alongside [log_viewer]. Everything except log_dirs is optional, and every option left out falls back to the global value above. See Remote sources over SSH for what these do and why two options you might look for are absent.

Option Purpose Default
(the section name) CLV's name for the machine: what the tree shows, what node: matches, and the address connected to unless host overrides it. (required)
host The address or ~/.ssh/config alias to connect to, when it differs from the name. (the section name)
user SSH user. Left out, ssh decides — from ~/.ssh/config or your local username. (unset)
port SSH port. Outside 1–65535 the host is skipped and reported, never clamped: clamping would connect somewhere you never named. 22
identity_file A private key to offer. An unreadable one warns and keeps the host, since ssh-agent may already hold the key. (unset)
log_dirs Folders and/or files on that machine, comma separated. Must be absolute; a relative entry is ambiguous and refused. (required)
enabled Whether this host is read at all. space toggles it in the R dialog. true
include_globs / exclude_globs Per-host overrides of the global glob lists. An empty value means "explicitly no filtering", which is not the same as leaving the line out. (inherit)
max_files Per-host discovery budget, so one noisy machine cannot consume the whole allowance and truncate the others. (inherit)
max_buffer_lines Per-host history budget. The pressure valve for a slow link. (inherit)
correct_clock_skew Apply this host's measured clock offset when ordering a merged view. Skew is always reported; correcting it is opt-in, and the pane says when it is on. false

The R dialog edits the first seven; the rest are file-only. That costs them nothing — writeback is key-level and never regenerates a section, so options the dialog does not show, comments you wrote, and even a line this version refuses all survive an edit untouched.

Two options do not exist, and their absence is enforced by the schema rather than by convention: there is no password key of any spelling, and no sudo key of any spelling. Writing one is reported as unsupported and the value never reaches CLV's model of the host. See the section on authentication for what to do instead.


Upgrading

Your settings file is created once, from the template shipped with whichever build you first installed. That template is two thirds prose — every option is introduced by the comment block explaining what it does — so a file created two years ago is still handing you two-year-old documentation, even though every release since may have added options.

Nothing on the launch path ever rewrites that file. CLV does not edit your configuration behind your back while starting up. What it does instead is tell you, once per version, which settings your file does not carry, in the discovery summary.

Closing the gap is an explicit action:

clv --upgrade-config            # fold your settings into the newer template
clv --print-default-config      # or just read the newer template
clv --version                   # which build you are actually running

--upgrade-config rewrites ~/.config/clv/settings.conf from the shipped template and:

  • keeps every value you set — written into the new template, so it arrives surrounded by the current prose rather than the old;
  • keeps every [ssh:<name>] host, copied across byte for byte, including a host CLV itself cannot parse;
  • keeps options this version no longer documents, under a # --- Carried over from your previous settings file banner, rather than silently dropping them;
  • does not keep comments you wrote yourself inside [log_viewer]. The new template is the base, and there is nowhere sensible to reattach a note about an option whose surrounding prose has been rewritten.

Because of that last point it always saves the previous file first, as ~/.config/clv/settings.conf.bak-<timestamp>. It is also a no-op when there is nothing to do: the file carries a config_version marker, and a file already at the current version is not even touched.

install.sh runs --upgrade-config for you after installing, so an upgrade picks this up without a second command. Skip it with:

./install.sh --no-config-upgrade      # or CLV_NO_CONFIG_UPGRADE=1

Under sudo, the installer runs the upgrade as the invoking user rather than as root, so it updates your settings file and not /root's.

None of this is required. Every setting your file does not carry is already in effect at its default, and remote hosts are managed from R and the Advanced drawer without touching the file at all. If your machines are already named in ~/.ssh/config, Scan SSH config in the Advanced drawer lists the aliases that are not configured yet and writes the ones you pick — it imports the alias and a log_dirs line, leaving ssh to apply your own Host block.

Usage

clv              # launch the TUI
python -m clv    # module entry point

Command line

clv with no arguments launches the viewer, and always will. Everything else is a subcommand that prints something and exits without starting a screen:

clv doctor                  # what this build is, what it read, what every plugin did
clv plugin list             # what is installed, without importing any of it
clv plugin info <name>      # one plugin's manifest, origin and signature
clv plugin install <src>    # from a path, a .tar.gz or an https URL
clv plugin verify [name]    # re-check installed plugins against their manifests
clv plugin remove <name>    # delete it and stop it being enabled
clv plugin trust <signer>   # trust a key that signs plugins
clv --version               # which build you are actually running
clv --print-default-config  # the newer settings template, to read
clv --upgrade-config        # fold your settings into it (see Upgrading)

clv doctor is the first thing to run when something is missing. It reports the version and interpreter, which settings file was read and anything in it CLV could not honour, your plugin directories and what is in them, and one block per plugin: the interfaces it supplies, where it came from, whether it loaded, and the reason if it did not. It needs no terminal, so it works over a pipe and in a CI step, and it exits 0 even when a plugin is broken — a broken plugin is what it is for reporting.

No clv plugin command imports a plugin. That is the point of them rather than an implementation detail: looking at what is installed is exactly what you do before deciding to trust it, and a listing that ran the code would be a poor way to inspect something you are unsure about. clv plugin info will tell you what a plugin declares, who signed it and what settings it is waiting for, without ever executing a line of it. Use clv doctor to see what a plugin actually did once enabled.

Exit codes are 0 success, 1 failure, and 2 a usage error.

Two things clv deliberately does not do. It does not take a log to open — clv /var/log/syslog says so and points at a and at log_dirs, because a source named on the command line would be forgotten the moment you closed the viewer. And a plugin cannot add a subcommand: an installed file must not be able to change what a shell command does. A plugin that wants to be invoked supplies a command instead, reachable from C and from its own key.

Getting help

Press ?. The footer entry says ? Help and clicking it does the same thing.

Help opens as a modal over the whole view, in five pages selected by the tabs along the top:

Page Covers
Overview What CLV is, adding sources, the tree's groups, where settings live
Search The query box, field terms and their operators, time windows, severity, and what an empty pane is telling you
Sources Discovery and what it skips, compressed and rotated sets, starring, merging, remote hosts over SSH, the journal
Reading The cursor and the detail pane, structured columns, the timeline, marks, clustering, saved views, watch rules, export and the clipboard
Keys Every keybinding, grouped by what it does

← and → change page, 1–5 jump straight to one, and ↑/↓ scroll the page you are on. Dismiss it with ?, Esc or q. It reopens on the page you left, so ? still lands on Keys for as long as that is what you are using it for.

The Keys page is the complete list. The footer only has room for the first handful at narrow widths, and everything it drops — along with any key an installed plugin added — is there. It is generated from the bindings themselves and cannot fall out of date.

One wrinkle worth knowing: while the cursor is in the query input, ? types a literal question mark, because it is a valid regex character. Press Esc first if the input has focus. Tailing continues while help is open.

Starred logs

* stars the log under the tree cursor, or the one you are reading. Starred logs are repeated as a group at the top of the tree, so a favourite is one keystroke away however deep it sits:

⭐ Starred
  ⭐ ✕ logs/auth.log
  ⭐ ✕ logs/runtime_2026-08-04.log   ← dimmed: the last scan did not find it
📂 /var/log
   ⭐ auth.log                        the star replaces the file icon
    📄 syslog

The verbs sit between the icon and the name rather than after it, so a long path can never push them off the panel; the icon keeps the left edge of the row inert, so a click aimed at selecting cannot land on a control.

On a ⭐ row Keyboard
✕ Unstar this log *
the name Open it Enter

Star exactly one log and CLV opens it on launch. Star several and it does not: that is a set of favourites rather than an instruction about what to open, and the group is there to jump from.

A star outlives the log it points at. When the last scan could not find the source, the row is dimmed rather than dropped, and selecting it says why — a rotated-away file, a journal source whose switch is off, a host that was unreachable. Those are different problems with different fixes, so they get different sentences. The star is kept because a rotated log comes back; it carries the same ✕ as any other row, so keeping one has never meant being stuck with it.

Merging sources

The name promises centralized logs, and until now it delivered centralized discovery — you still read one file at a time. x on any log in the tree adds it to the merged set; u opens the set as one timestamp-ordered stream:

⭐ Starred
  ⭐ ✕ logs/auth.log          alpha.log   2026-08-11 10:00:00 INFO  request accepted
⧉ Merged (2 sources)   ←     beta.log    2026-08-11 10:00:01 INFO  upstream connect
  ⧉ logs/alpha.log     click alpha.log   2026-08-11 10:00:02 ERROR upstream timeout
  ⧉ logs/beta.log      to open beta.log  2026-08-11 10:00:03 WARN  retrying
📂 /var/log
   ⧉📄 alpha.log
   ⧉📄 beta.log
    📄 auth.log

The set is repeated as a group below the starred logs, so it is one keystroke away however deep its members are buried; each member also carries a ⧉ where it sits in the folder tree. The row carries its verbs as separate click targets, so a click can say which one it meant:

On the ⧉ Merged row Keyboard
⧉ Open the set as one stream u
✎ Save the set under a name V
✕ Empty the set, to start another X
the name Expand / collapse its members Enter

Selecting one member below it opens just that log, which is also sometimes what you want. Every group in the tree — Views, Providers, Starred, Merged — arrives collapsed, because a shortcut that unfolds itself pushes the rest of the tree off screen. A source column names the origin of every row (abbreviated as the terminal narrows), and the status line names the set. Adding or removing a source edits those rows in place — it never re-runs discovery, and it never collapses folders you had opened. Every other feature works exactly as it does on a single log — filters, n/N navigation, g, marks, the detail pane and Ctrl+E. That is the point: merging is not a mode with its own reduced feature set. The origin travels as a field, so source:beta.log is a query you can write, and it shows up in the detail pane's property list for free.

Some specifics worth knowing:

  • max_buffer_lines applies per source, so three merged logs cost three buffers and no member is crowded out by a louder one. The merged stream is a view over those buffers, never a fourth copy of the lines.
  • Lines without a timestamp are never dropped. They are anchored directly after the last timestamped line from their own source — which is what keeps a stack trace attached to the error above it — and the status line counts them, because they were placed by inference rather than by their own clock.
  • Mixed timezone-aware and naive timestamps merge rather than refusing to. If any source is naive the offsets are dropped, matching what the time-window filter already does; if every source is aware they are kept, so two time zones order correctly.
  • Only members you merged are polled, at the same rate one source was. A merged view does not multiply the poll frequency by the member count.
  • A member that disappears from disk is reported and the rest keep going.
  • The set persists in session.json and can be captured in a saved view.

Naming a set. A merged set is not limited to one: ✎ on the row (or V) saves it as a named view, ✕ empties the working set so you can build the next one, and v (or the Views group at the top of the tree) switches between them. Renaming and deleting a saved set live in that picker — r and d. Clearing the working set never touches what you saved. So web tier and db tier can be two different groups of logs, each one keystroke away, and applying one moves the merged group in the tree to match.

The set has to be open when you save — press u first. A view saved while a single log is on screen deliberately records no set, so a view about one file never drags someone else's merged group around with it. Note also that a view is a filter bundle first: it captures your query, severity and time window alongside the set, so save it with the filters you want to come back to.

A merged set can span machines. With remote sources configured, a local log and a log on another host open in the same timestamp-ordered pane — comparing one path across a fleet is what the feature is for.

Ctrl+X builds that set in one press. On any log, it gathers the same path from every machine the last scan found it on and opens the result, rather than making you press x on five leaves inside five separately collapsed host trees. It reports what it did: how many sources it merged, which walked hosts do not have that path, and which hosts it could not reach at all — three different facts, kept apart, because a host that was unreachable has told CLV nothing about its files and saying "not on web03" would be a confident answer with no evidence behind it. When the members share a basename, the pane's source column switches to naming the machine, since that is the part that differs.

Two things are worth knowing before you read causation out of the interleaving:

  • Ordering across machines is only as trustworthy as their clocks, and CLV says so rather than hiding it. Clock skew between hosts is measured and reported beside the merged view's anchored count; correcting for it is opt-in per host, and when it is on the pane states that timestamps are being adjusted. The raw line is never rewritten either way.
  • node: is the machine CLV read a line from; host: is what the line says about itself. Syslog, access logs and journald all normalise into host, so it keeps meaning exactly what it always did and no saved query changes meaning. node:web01 status>=500 is the query you want across a fleet.

Managing hosts. R opens the host dialog: add, edit, enable, disable and remove machines, with a Test connection that makes one bounded probe and reports what it found. Changes are written back into settings.conf in place, so the comments, per-host budgets and glob overrides you have written there survive an edit untouched. The next section covers setup, the authentication model and what a non-GNU remote gives up.

Remote sources over SSH

A root may live on another machine. CLV connects with ssh, reads the files there, and puts them in the same tree as the ones on this disk — discovered recursively, opened, tailed, filtered, starred, merged. A remote log is not a second-class source type; that is the whole point, and it is why this is core plumbing rather than a plugin bolted on the side.

It is off until you say otherwise. With enable_ssh = false — the default — nothing connects and no ssh process is spawned, however many hosts are configured. Reading the journal spawns a subprocess; reading a remote host spawns a network subprocess, which raises the bar for asking rather than lowering it.

Setting a host up

Three routes, and none of them requires the other:

  • The dialog. R lists your machines: add, edit, enable, disable, remove, and Test connection, which makes one bounded probe and reports what it found. Also reachable from the Add Source dialog (a).
  • Scan SSH config. If your machines are already named in ~/.ssh/config, the Advanced drawer's Scan SSH config lists the Host aliases that are not configured here yet and writes the ones you pick — the alias and a log_dirs line, nothing else, leaving ssh to apply your own Host block.
  • The file, one [ssh:<name>] section per machine:
[log_viewer]
enable_ssh = true

[ssh:web01]
log_dirs = /var/log, /srv/app/logs

That is a complete host. The name after ssh: is CLV's name for the machine: it is what the source tree shows, what node: matches in a query, and the address connected to — so if the name is already a Host alias in your ~/.ssh/config, there is nothing else to write. Add host = only when CLV's name for a machine is not the address to reach it at.

Authentication: your agent and your keys, and nothing else

CLV inherits ~/.ssh/config wholesale — aliases, ProxyJump, per-host keys, known_hosts, agent forwarding. A host you can already reach by hand is a host CLV can read.

There is no password option, and there will not be one. No password field exists in the settings schema, in any dialog, in session state, or in memory. Every invocation carries BatchMode=yes, which turns every interactive prompt into a clean failure with a usable reason instead of a process waiting on a stdin nobody is reading. A connection that would need you to type something is reported as unreachable, with the reason. Load your key with ssh-add, or point identity_file at one.

Host key verification is never disabled. StrictHostKeyChecking=no and UserKnownHostsFile appear in no argv CLV builds, not behind a flag and not for testing — there is a test asserting they never will. The first connection to a machine you have not trusted yet therefore fails, and says:

the host key is not trusted. Connect once by hand to verify it: CLV never disables host key checking.

Do exactly that — ssh web01 once, check the fingerprint, accept it — and CLV works from then on. A key that has changed gets a different and louder message, because that is a different fact.

There is no sudo option either, anywhere, local or remote. CLV reads as the configured SSH user and never escalates. A log that user cannot read is reported, naming the host and the path, with the fix: add the user to a group that can read it (adm, systemd-journal) or set an ACL on the file. Reading a log by becoming someone else is not something CLV will do on your behalf.

The connection itself

One multiplexed connection per host (ControlMaster=auto), so per-file commands cost a round trip rather than a handshake. The socket lives in a 0700 directory under $XDG_RUNTIME_DIR, is named from a hash rather than the host name (a long FQDN overflows sun_path, and the failure mode is silent loss of multiplexing), and is torn down explicitly with ssh -O exit when the host is reconfigured, when enable_ssh goes off, and at shutdown. ControlPersist is 60 seconds as a backstop for a CLV that was killed rather than quit — a persisted multiplex socket is a live authenticated connection any local process running as you can ride, so leaving one behind is a real exposure rather than an untidiness.

Nothing is installed on the remote host and nothing is left running there. CLV issues bounded commands and one tail -F per open source, and that tail is killed when the connection closes — including the case where an idle log means it would otherwise never notice.

When a host goes away, the pane says so. A dropped link is not a log that went quiet, and CLV will not render it as one: there is a toast, a status-line segment, and an explanation in the empty pane. Reconnection is bounded — six attempts at 1, 2, 5, 15, 30 and 60 seconds — and then it stops and tells you, naming Ctrl+R. Ctrl+R resets the backoff and keeps the connections that are still good.

Non-GNU remotes, and what degrades

find -printf, stat -c and dd iflag=skip_bytes are GNU extensions. BusyBox and BSD do not have all of them, so capability is probed at connect, never assumed, and the host gets one of four command profiles:

Profile find -printf stat format Ranged read
gnu yes %d %i %s %Y dd iflag=skip_bytes
busybox no %d %i %s %Y dd iflag=skip_bytes
bsd no %d %i %z %m tail -c +N | head -c M
posix no (none) tail -c +N | head -c M

Only the last row loses anything you would notice. Rotation detection compares (device, inode), so a host with no readable stat degrades to comparing size and modification time — which misfires on a log rotated inside the same second, and CLV reports rotation conservatively rather than pretending. Everything else is a different argv for the same result. Alpine containers are a first-class target, not an edge case, and there is an opt-in test suite that runs against one.

Test connection in the R dialog tells you which profile a host got, along with its measured clock skew.

Discovery of a remote root is one command, not one per file — that is the specific cost that makes reading 400 remote files unusable, and there is a test that counts commands so it cannot come back. Opening a source reads a bounded tail; tailing transfers only appended bytes. poll() never makes a round trip at all, so the UI does not stall twice a second per merged source.

Two per-host settings are the pressure valve, and both fall back to the global value when absent:

  • max_files — one noisy machine should not consume the whole file allowance and truncate the others.
  • max_buffer_lines — five merged remote sources pull five times the history over the link on open. Lower it for a slow connection.

Known limitations

Honest rather than absent:

  • Plain-text export carries no machine column. JSON Lines and CSV both carry node; the plain-text format deliberately does not, because prefixing a host onto a raw line would put text in an export that no log on any machine contained.
  • A clipboard copy carries no provenance. y emits entry.raw only, which is exactly what an all-local merged copy has always done.
  • The R editor offers seven of the twelve host options. correct_clock_skew, both glob overrides and both budgets are file-only, because twelve fields do not fit beside a list, a hint and a button row at 80 columns. Editing is key-level, so leaving them alone in the dialog preserves them exactly.

sshfs is a legitimate alternative

If the machine is already mounted, or you would rather mount it, CLV has always been able to read a mounted remote folder and needs none of the code above to do it — you get full fidelity, real inodes, and every feature, for no configuration at all. What you pay is a per-file round trip during discovery, which is the specific thing this feature exists to avoid on a tree of a few hundred files. Neither answer is wrong. This one exists for people who do not want a mount.

Compressed and rotated logs

.gz, .bz2 and .xz files are read directly — no zcat, no unpacking to a temp file, and nothing written anywhere. .zst is still excluded, because there is no decompressor for it in the Python standard library and adding one would be CLV's first new runtime dependency.

More usefully, the members of a rotated log are presented as one source:

📂 /var/log
   🗂 syslog (4 files)
      📄 syslog
      📄 syslog.1
      📄 syslog.2.gz
      📄 syslog.3.gz
   📄 auth.log

Selecting the syslog set reads all four in order, oldest lines first, into one pane — so a query, a time window or a g jump crosses a rotation boundary without you opening anything else. The status line names the member the cursor is currently in. Every member is still listed underneath and can still be opened on its own.

Recognised names, after any compression suffix: app.log.1, app.log-20260811, app.log.2026-08-11. A gap in the numbering is fine. Turn grouping off with group_rotated in settings.conf or the Group rotated switch in the Advanced drawer.

On bounded memory. CLV's usual promise is that nothing reads a whole file: opening a source seeks backwards from the end. A deflate stream has no cheap backwards seek, so a compressed member is the exception, and it is worth being precise about what is and is not bounded:

  • Memory is bounded, by max_buffer_lines. Lines stream through a fixed-size buffer, so a 400 MB decompressed member costs no more than a small one.
  • Work is proportional to the member, not to what you see. Reading the last 500 lines of a .gz means decompressing it to get there. A second cap on decompressed bytes stops a decompression bomb, degrading to "here is what was read" rather than exhausting the machine.
  • The budget is shared across the set and spent newest-first. Members are read back from the live one until max_buffer_lines is met, then no further. A set whose newest member already fills the buffer opens as fast as a single file, and CLV says how many members it actually read.
  • Only the live member tails. Nothing appends to syslog.2.gz, so it is read once and never polled again.

The systemd journal

On Linux the OS event log is the journal — binary, so no amount of file discovery will ever find it. CLV reads it through a plugin that shells out to journalctl -o json --follow, and offers:

Source
System journal everything, as one stream
This boot / Previous boot --boot 0, --boot -1
One node per .service unit the closest thing to Event Viewer's Windows Logs → Application / Security / System

Journal sources appear in a Providers group in the tree and behave like any other source from there on: the same filters, cursor, marks, detail pane and export. Because the plugin normalises the journal's own fields, field queries work immediately — unit:sshd.service, host:web01, pid:991, and the raw _SYSTEMD_UNIT spelling too.

It is off by default, and turning it on is a decision you make explicitly. Reading the journal means running a subprocess, and CLV does not spawn one unless asked — which is also why the journal ships as a plugin rather than as part of core. With enable_journald false, nothing is spawned and nothing is enumerated. Enable it either by setting enable_journald = true in settings.conf, or with the Journal (systemd) switch in the Advanced drawer, which turns it on and writes that line back to your settings file so the choice is made once rather than every launch.

On a machine without systemd — or without journalctl on PATH — CLV runs normally, the switch is disabled, and the drawer says why. That is a statement about this machine only: a laptop with no systemd can still read the journals of a systemd fleet, which is covered below.

Two details worth knowing. A severity bucket is pushed down to journalctl --priority where that actually filters something (error, warn, info), so debug records are not carried across a pipe just to be discarded; debug and trace map to priority 7, which is everything, so nothing is pushed down and CLV does not pretend otherwise. And the initial read is bounded by --lines, the journal's equivalent of the backwards seek CLV does on a file.

Journal sources behave like any other source, including the parts that used to be missing. A unit can be starred with *, so sshd.service sits at the top of the tree instead of several rows down the Providers group. It can be added to the merged set with x and opened with u alongside ordinary log files, so an application's own log and the journal of the unit running it interleave in one timestamp-ordered pane — which is usually the pair you actually want. And a starred unit comes back on the next launch.

If the journal is off, or the machine has no systemd, a starred unit says so in those words rather than reporting a missing file, and the star is kept: turning the switch back on brings it back rather than making you find the unit again.

What a journal source still is not is a file. Include and exclude globs describe filenames and never hide a unit — *.service does not match sshd.service here, because the unit is not a file with that name. And nothing groups units into rotated sets: journald manages its own retention, so there is no .1 or .2.gz to gather.

The journal on another machine. With remote hosts configured, each one offers its own journal and its own units, listed as web01: sshd.service and readable exactly like the local ones — starred, merged, filtered, tailed. The canonical use is the one the merge exists for: the same unit across a fleet in a single timestamp-ordered pane, with node: saying which machine each line came from and the hosts' measured clock offsets applied to the ordering.

It needs both switches on, and neither implies the other. Reading the journal runs a subprocess; reading it on another machine runs a network subprocess, so enable_journald and enable_ssh are two separate consents. With either off, nothing is enumerated and nothing is spawned.

A host with no journalctl — an Alpine container, say — simply offers no journal and says so in the drawer. That is a capability, not a failure: it puts no error anywhere, and the rest of the fleet is unaffected. A host that is unreachable is likewise skipped rather than retried, because it has already reported that fact to the rest of CLV and spending a connection timeout to rediscover it is what makes a reload feel broken.

Nothing is left running on the far side. A remote journalctl --follow on an idle unit would never notice its connection had gone — a process only learns its pipe is closed when it next writes — so the follow watches its own input and stops when CLV does.

Inspecting an event

The log pane has a cursor. Arrow keys move it a line at a time, PgUp/PgDn a screen at a time, Home/End to either end, and a mouse click selects the line you click on.

Enter on the selected line opens the detail pane, which lists:

Property
The raw line Exactly as it appears in the file
Timestamp Normalised, or — when the line carries none
Level The canonical severity, or —
Format Which format matched — down to unrecognised
Continuation Whether the timestamp and level were inherited from the line above
…then every field host, tag, pid, status, or a JSON payload's keys flattened to dotted paths

d opens and closes the pane without moving the cursor, and there is a Detail pane switch in the Advanced drawer. Whether it is open is remembered between runs; which line you had selected is not.

Not every line has fields — four of the formats CLV recognises carry none, and an unrecognised line carries nothing but its text. The pane says which case it is rather than showing you an empty list.

Where the pane goes depends on the width: beside the log from 130 columns, below it between 90 and 130, and in place of the log at 80, where the two cannot share the screen. Press d to get the log back.

Moving the cursor pauses follow mode. Otherwise incoming lines would drag the view out from under the line you just pointed at. The status bar says so — "paused — cursor moved, End resumes" — and End or w starts following again.

Structured columns

Press o to turn a dense log into columns. CLV already recovers a timestamp, a severity and a set of named fields from every line it can parse; with the switch off, all of that is thrown away at render time and you read the raw text. With it on, the recovered structure becomes fixed cells and the message starts at the same screen column on every row:

Aug  7 09:25:01 web01 sshd[1123]: Failed password for root from 10.0.0.5 port 22
Aug  7 09:25:01 web01 CRON[9021]: (root) CMD (/usr/bin/backup.sh)
Aug  7 09:25:02 web01 kernel: TCP: dropping request, ratelimit exceeded

becomes

09:25:01 sshd[1123]  Failed password for root from 10.0.0.5 port 22
09:25:01 CRON[9021]  (root) CMD (/usr/bin/backup.sh)
09:25:02 kernel      TCP: dropping request, ratelimit exceeded

The cells replace the prefix they came from. A row that showed both would be wider than the line it replaced, which is the opposite of the point. Nothing is lost: the raw line is untouched in the detail pane, in what y copies, and in every export.

What you get depends on what the set actually contains, decided once per redraw:

Cell Shown when
Time Anything in view carries a timestamp. Gains a MM-DD prefix when the lines span more than one day, and milliseconds only when the log records them — a column of .000 is four cells of nothing.
Level Anything in view declares a severity.
Source The program, unit or client varies across the lines in view. A column repeating one value is width taken from the message. In a merged view this cell names the source instead, and the program moves to a chip.
Message Always. It is the last thing to give way as the terminal narrows, and a line that wraps hangs under this cell rather than restarting at column zero.

Fields the cells did not use appear after the message as dim key=value chips, chosen per format rather than swept up — a journal line carries around forty fields, and showing them all is the density problem this feature exists to fix. Access logs are the case that matters: an entry's message is only its request line, so status is always shown and a 500 never disappears off the row.

Everything gives way in order as the terminal narrows — the source cell, then the date, then the level — so the message keeps its room down to 80 columns.

Payloads that are whole documents still get pretty-printed. When a line's message is JSON, XML, HTML, CSS or CSV, it renders in a bordered panel beneath the row, syntax-highlighted in a palette that follows your terminal theme. The detectors are deliberately hard to convince: prose with commas in it is not a CSV table, <stdin>: line 3 is not HTML, and Java{name:bob} is not a stylesheet. A missed preview costs you one glance at a line that was already readable; a false one buries three real entries.

Repeat clustering (c) and the columns work together — a collapsed group keeps its ▸ ×147 count and states its time span in the time cell where there is room for one. A cluster never gets a payload panel, because a border per repeat group is exactly the noise clustering removes.

The severity timeline

b opens a one-row histogram above the log: event volume over the time your filtered lines cover, each column coloured by the worst severity in it.

▁▁▂▁▁▃▂▁▁▁█▆▃▁▁▁▂▁▁·······▁▂▁▁▁▂▁▁▃▁▁▁▂▁▁▁▁▂▁▁▁▁▁▁▂▁▁▃▂▁▁▁▁▁
2026-08-07 09:14:20–09:14:30 · 61 events · ERROR

Volume is the height of the block, not the colour, so the shape of a spike reads on a monochrome terminal. A column where nothing happened is a · rather than a gap, so the axis does not look like it stopped.

It is a control, not a picture. ←/→ move the selection, Home/End jump to either end, and Enter narrows the time window to the selected bucket — which is an ordinary custom range, so it appears as a Time: chip and is dismissed like any other filter. Clicking a column does both at once. The bar then re-buckets over the narrower window, so pressing Enter again drills in further.

The histogram is built from the filtered set, so it answers "when did the thing I am looking at happen" rather than "when did anything happen". It is built from the buffer only — no second read of the file — and while it is hidden it is not maintained at all. A source whose lines carry no timestamp says so in the caption instead of drawing an empty rectangle.

Whether the bar is open is remembered between runs, along with a Timeline switch in the Advanced drawer. Which bucket you had selected is not.

A plugin can mark the axis and change what a bucket measures. A TimelineAnnotation puts deploys, incidents or maintenance windows on the bar — the marked column is underlined in the mark's own severity colour, its label shows in the caption, and shift+← / shift+→ step between marks. A TimelineMetric scales the bar by something other than the line count: bytes, durations, retries. The caption then leads with the measurement and names the plugin supplying it, because a bar of bytes and a bar of lines are the same glyphs.

A metric is declared as a value per entry and CLV does the summing, which is not a simplification — it is what keeps the histogram foldable, so a tailed line still finds its bucket by arithmetic instead of forcing a rebuild. A median cannot be expressed here, and that is the interface working rather than a limitation of it. examples/timeline_marks.py in your plugin directory works both halves through.

Marking lines

m marks the line under the cursor and M steps between the marks, wrapping with a notification. Marked lines carry a ● in the gutter — a glyph, not just a colour, so it reads on a monochrome terminal — and the status bar keeps a count. Ctrl+E then offers Marked lines only, which is the point: mark the three lines that matter while reading a five-thousand-line buffer, then export exactly those.

A mark is recorded as the source path plus a digest of the line's text, not as a position. That is what lets it survive the ring buffer evicting lines above it, and lets a line a filter hid come back still marked. Two consequences worth knowing: identical lines in one source share a mark, and once a line has rotated out of the buffer entirely its mark is dropped.

Marks are never written to disk. They are session-only, and they stay that way on purpose: the digest is derived from log content, and session.json records paths and settings, never anything about what a log contained. Closing CLV forgets them.

Noise reduction

c collapses repeated lines into one row with a count:

  2026-08-07 09:25:01 - ERROR - connection refused for 10.0.0.5:5432 after 1.25s
  2026-08-07 09:25:01 - ERROR - connection refused for 10.0.0.6:5433 after 0.90s
  2026-08-07 09:25:02 - ERROR - connection refused for 10.0.0.7:5433 after 2.10s
  … 144 more like it …
  2026-08-07 09:27:14 - WARN  - disk almost full

  ▸ ×147 09:25:01→09:27:09  2026-08-07 09:25:01 - ERROR - connection refused …
  2026-08-07 09:27:14 - WARN  - disk almost full

Two lines collapse together when they look the same once the volatile tokens are normalised away — quoted strings, timestamps, UUIDs, IPv6 and IPv4 addresses (with ports), hex, paths, floats and integers, applied in that order. So request 8821 took 12ms and request 8822 took 47ms are one event, and the line that is genuinely different stays on its own. Severity is part of the match: a WARN and an ERROR that read alike stay apart, as do identical lines from two different logs in a merged view.

Collapsing never hides a line. It is a display transform, not a filter. Enter on a cluster row expands it in place and gives back exactly the lines that went into it, byte-identical and in order; every one of them is then selectable, markable and exportable like any other. Enter again closes it. Marking a collapsed cluster is refused with a note asking you to expand it first — one keystroke should not mark a hundred and forty-seven lines behind a single gutter dot.

The row shows the count, the span from first to last, and the cluster's severity in its colour. Ctrl+E gains a Clustered option that writes one row per group with cluster.count, cluster.first and cluster.last as fields; expanded output remains the default.

Clusters only form within cluster_lookback entries (200 by default, see Configuration), so one cluster cannot quietly span a whole session and swallow an event from an hour ago. Whether clustering is on is remembered between runs; which clusters you had open is not.

The rules are extensible by plugin, and deliberately not from settings.conf. A plugin can add a token to normalise away — an address, a pod name, a colour escape your logs are full of — and can widen the key so two streams stay in separate clusters. What it cannot be is a list of regexes in a config file: a typo there is a silently mis-clustered pane, with no review, no test and no way to tell a bad rule from a bad log. A plugin is Python someone can read and test, and CLV checks what it declares before running any of it. clv/examples/cluster_rules.py, in your plugin directory, is a worked one.

Saved views

A filter set you had to think about is worth keeping. V names the one that is active; v opens the list of saved ones.

V  →  name it "5xx on web01"      (query, severity, time window, search
                                   options, globs and the open log)
v  →  Enter applies it            r renames · d deletes (twice) · Esc closes
      or click  [Close] [Delete] [Apply]

Saved views also appear as a group at the top of the source tree, above the starred logs, so applying one is a single click or a couple of arrow keys. Each row carries its own verbs, between the icon and the name so a long name cannot push them off the panel:

📑 Views
  📑 ✎ ✕ 5xx on web01
  📑 ✎ ✕ auth failures
On a 📑 row Keyboard
the name Apply the view Enter
✎ Rename it v then r
✕ Delete it v then d, twice

Both markers open the picker on that view rather than acting where they are. A merged set is a working set and its ✕ empties it on one click; a view is named work and CLV has no undo, so deleting one always asks — once, in the place that already knew how.

Applying a view puts everything back at once — one repaint, not one per field — and reopens the log it was saved against. If that log has since gone, the filters are applied anyway and a notification says which source was missing: a rotated-away file should not turn a view into a dead end.

Views live in session.json, so they survive restarts. Like everything else there, a view records settings and one path only — never a log line, never what it matched.

Watch rules

Tailing means waiting for something. A watch rule says what, so you can stop reading every line: W opens the manager, a adds a rule, Enter edits one, space enables or disables it, d twice deletes it.

A rule is a name, a pattern in the same syntax the query box uses (so tag:kernel oom-killer works), and an action:

Action Effect
Highlight only Matching lines are drawn with a distinct background
Notify only A notification, nothing visual
Highlight + notify Both (the default)

The highlight is a background, deliberately: severity is carried in the text colour, so a watched INFO line reads as watched rather than as an error.

Notifications are rate limited. The first match for a rule is reported at once; anything else inside the window is counted and reported together — "Watch 'oom-killer' matched 143 lines." The window is watch_rate_limit in settings.conf (60 seconds by default), and a rule matching every line therefore costs one message a minute rather than one per line. Set watch_bell = true if you want the terminal bell as well; it is off by default.

Opening a log highlights the lines already in the buffer that match, but says nothing about them — you asked to be told about what happens next, not about what happened before you asked. Enabled rules appear as chips beside the filter chips, and dismissing a chip disables that rule without deleting it. The Advanced drawer has a switch for the whole set and a count of what is live.

Rules persist in session.json, the same as saved views: a pattern is something you typed, not something a log contained. Everything runs in-process for the life of the session — no daemon, no desktop notification service, no subprocess.

Both halves are extensible. A plugin can add a rule kind — "five of these within a minute" rather than "this pattern matched" — and the Kind button appears in the rules dialog as soon as one is installed. A plugin can also add a destination, so a hit can go to a file, a webhook or anywhere else instead of only to a toast. Two guarantees hold whatever is installed: a destination is fed what the rate limiter already coalesced, so it cannot be used for a storm; and it runs on its own thread, so one that blocks cannot stall the viewer. A destination receives a rule name and a count, and receives your log lines only if it declares that it wants them — a plugin that does is flagged in the plugins dialog (P). A rule records the kind it needs, so one whose plugin is gone is kept exactly as written, marked unusable and named, rather than quietly matching something else. See clv/plugins/AGENTS.md.

Exporting

Ctrl+E writes the entries the filters kept to a file. Three formats ship:

Format What it contains
JSON Lines One object per entry — raw line, timestamp, level, message, detected format, continuation flag and every parsed field. The only lossless option.
CSV A fixed, rectangular table of the same columns, plus a node column, with the remaining parsed fields as one JSON column.
Plain text The raw lines, byte-identical to what is on screen.

Three things worth knowing:

  • It exports the whole filtered set, not just the lines that fit on screen. The dialog states the count before it writes, so +/- never changes what an export contains.
  • A merged export names its machines. The node column (CSV) and the node field (JSON Lines) carry the machine each line was read from, and the default filename names one of them — web01-syslog-20260817-142530. node is empty for a local source, which has no machine to name. Plain text is left alone: it is the raw lines and nothing CLV added.
  • Writing is atomic (a sibling temp file, then a rename), and overwriting an existing file takes a second press of Export. A permission error is reported as a notification, not a traceback.

Any Exporter plugin you have installed is listed in the same dialog, below the built-ins. The Advanced drawer shows the full list read-only, so you can see what is available without opening the dialog.

One wrinkle: while the cursor is in the query input, Ctrl+E moves it to the end of the line — that binding belongs to the input. Press Tab or click the log pane first.

Copying to the clipboard

y copies to your local clipboard using an OSC 52 escape sequence. It copies the selected line when the cursor is on one, and the lines currently on screen — filters and the visible-line window included — when it is not. Nothing is selected until you move the cursor, so y on a freshly opened log still copies the view.

y and Ctrl+L solve the same problem from opposite ends and both are kept:

Needs Works over SSH / tmux
y A terminal that honours OSC 52 Yes
Ctrl+L copy mode A local mouse selection No

A copy larger than clipboard_max_bytes is truncated at a line boundary, keeping the newest lines, and the notification says how many were dropped — there is no silent partial copy. If your terminal renders the sequence as garbage, turn it off with the Clipboard (OSC 52) switch in the Advanced drawer; the setting is remembered, and Ctrl+L remains.

Keyboard shortcuts

Key Action
? Open help (then ← → change page, 1-5 jump)
/ Focus the query input
Enter Apply filters (in the query input) · open the detail pane (in the log pane)
Esc Clear the query
a Add a log source (or open the remote host list from the same dialog)
t / s Cycle time window / severity
f Toggle the Advanced drawer
* Star / unstar the log under the cursor
x Add / remove the log under the cursor from the merged set
Ctrl+X Merge this path across every host that has it, then open it
u Open the merged set as one timestamp-ordered stream
X Empty the merged set
↑ / ↓ Move the line cursor
PgUp / PgDn Move the line cursor a screen at a time
Home / End First / last line (End also resumes following)
n / N Next / previous match (or warning-and-worse, with no query)
g Go to a timestamp
m / M Mark the cursor line / jump to the next mark
v / V Saved views (then r renames, d deletes) / save the current filters as a view
W Watch rules (then a adds, d deletes)
d Show / hide the event detail pane
b Show / hide the severity timeline (then ← → Enter to filter to a bucket, shift+← shift+→ to step between plugin annotations)
c Collapse / expand repeated lines (then Enter on a cluster row)
w Follow new lines (auto-scroll) on/off
o Structured columns (time · level · source · message) on/off
Ctrl+B Switch between tree and log pane (compact widths)
[ / ] Narrow / widen the source tree
+ / - Show more / fewer lines
Ctrl+E Export the filtered entries to a file
y Copy the selected line, or the visible lines, to the clipboard (OSC 52)
Ctrl+L Copy mode (hides all chrome)
R Add, edit, test and remove remote hosts (SSH); also reachable from a
P Manage plugins (then space toggles, r re-enables)
C Run a plugin command (then Enter runs the highlighted one)
Ctrl+S Save added sources to settings.conf
Ctrl+R Reload configuration and rescan
q Quit

Every action has a keyboard path; mouse is fully supported but never required.

A plugin may add keys to this list, and they are always hidden. A plugin command can ask for a key, and it never appears in the footer: the footer's ordering is tuned against an 80-column floor and a plugin cannot know what its entry would push off. Press ? to see every binding an installed plugin added, listed under Plugins beside CLV's own. A key a plugin asks for that CLV already uses is refused rather than taken — the built-in keeps working, and the command stays runnable by name from C.


How filtering behaves

Understanding two rules explains everything the pane does:

  1. The query never drops what it cannot parse. It matches raw line text, so unstructured lines are searchable like any other.
  2. Severity and time filters only hide lines that demonstrably lack what you asked for — and when they do, the empty pane says so, e.g. "No matches — 12 have no detected severity (nothing in this source declares a level)."

Field queries

The parser recovers more than a timestamp and a level from each line — a hostname, a program tag, an HTTP status, every key of a JSON payload — and the query box can ask about any of it:

tag:sshd host:web01 status>=500 timeout|refused
└──────────── field terms ─────┘ └─── regex ──┘

Terms combine with and. Anything that is not a term is the regex it has always been, matched against the whole raw line.

Operator Means Example
key:value contains, smart-case (case-sensitive only if you type a capital) host:web
key: the field is present at all pid:
key=value exactly equal, case-sensitive host=web01
key!=value not equal tag!=cron
key>value key>=value key<value key<=value numeric when both sides are numbers, alphabetical otherwise status>=500

Quote a value to keep spaces or colons inside it: msg:"disk full".

The operator set is extensible, and so is the list of names. A plugin can add a comparison token of its own and a field that is derived rather than parsed — the shipped examples/field_regex.py adds ~ for "this field matches this regex" and age for seconds since the line was written, so host~^web[0-9]+ and age<60 level:error become things you can type. They work everywhere a built-in term does, including in a saved view and in a watch rule. CLV's own seven tokens are reserved and a computed field never overrides what a line actually said, so nothing you already search for changes. See clv/plugins/AGENTS.md for the interfaces.

A saved view or watch rule records which plugins its query needs. If one is not installed the record is kept exactly as you wrote it, marked ⚠ with the plugin named, and refused rather than applied — because host~^web without its operator is not a narrower search, it is a regex that happens to parse. Install the plugin again and it works again; nothing was rewritten in the meantime.

Which names work depends on the source. The parser's own vocabulary — host, tag, pid, msgid, ident, user, request, status, size — is always available, and every key a JSON or logfmt line carries is added as soon as one is read. Start typing a name and the field list drops down under the input: Tab takes the first suggestion, ↓ steps into the list, Esc dismisses it. The Advanced drawer keeps a one-line reminder of the syntax under Search options.

Nothing you already search for changes. A word that is not a known field name is text, so sshd: and kernel: oom-killer search for exactly what they always did, and so does a timestamp like 10:30:00. The cost of that guarantee is that a mistyped field name (hsot:web01) is searched for as text rather than reported — which is why the completions exist.

A line that has no such field is hidden and counted, like every other filter here: "No matches — 214 carry no 'status' field (this source's format does not report it)." If you are filtering a syslog with status>=500, that sentence is the answer.

Navigating what you filtered to

n and N move the line cursor forward and back through the lines worth stopping at, wrapping at either end with a notification rather than going quiet. What counts as "worth stopping at" depends on what is active:

Active n steps between
A query Its matches — and since the query filters, that is every visible line. What n adds here is the position: "match 12 of 47", in the hit counter and the status bar.
A severity bucket Entries in that bucket.
Neither WARN and above. Stepping every entry would only duplicate the down arrow; warnings are included because they usually precede the failure.

g asks for a time and moves the cursor to the first entry at or after it. It takes an absolute timestamp (2026-08-07 09:25:01) or an offset from now (-15m, -6h, -2d; a bare 15m means the past). Entries with no parsed timestamp cannot answer a question about time, so they are skipped — and the notification says how many, rather than quietly ignoring part of the source.


Architecture

Layer Location Responsibility
App shell clv/app.py Layout, routing, lifecycle. No parsing or IO.
Services clv/services/ parsing, filtering, discovery, reader, config, sources, export, clipboard. UI-free and independently testable.
Widgets clv/widgets/ Self-contained UI components owning their own CSS.
Plugins clv/plugins/ Extension interfaces and the loader.
State clv/storage.py JSON session persistence (atomic writes).

Styling is CSS-only: no module assigns .styles.* at runtime except for the user-adjustable tree width. Responsive behavior comes from breakpoint classes (-compact / -narrow / -wide) that the app sets and widget CSS keys off.


Plugins

CLV is extended by plugins: thirteen interfaces, published as a versioned API in clv.api, covering everything from where lines come from to what a bucket on the timeline measures. This chapter is installing and managing them. Writing one starts at clv/plugins/README.md.

Installing a plugin

Copy the file in, name it, restart:

mkdir -p ~/.config/clv/plugins
cp redact_secrets.py ~/.config/clv/plugins/

Then in ~/.config/clv/settings.conf, under [log_viewer]:

plugins = redact_secrets

Names are the file name without the .py, comma separated, matched without regard to case. A directory redact_secrets/ containing an __init__.py works the same way. No root, no Python toolchain, and nothing that a package upgrade overwrites — this works identically on a .deb/.rpm/tarball install and on a source checkout. CLV creates the directory on first run and leaves a README.txt in it saying the same thing.

Copying the file in does not run it. CLV lists what it finds in that directory and does not import it until the name appears in plugins. The Advanced drawer says how many are installed but not enabled; a name you list that isn't there is reported by name, so a typo says so rather than doing nothing.

Installing a packaged plugin

A plugin published as a tarball carries a clv-plugin.toml declaring its name, its version and the SHA-256 of every file it ships. clv plugin install checks all of that before anything is copied:

clv plugin install ./nginx_format-1.2.0.tar.gz
clv plugin install https://example.org/plugins/nginx_format-1.2.0.tar.gz
clv plugin install --sha256 e3b0c442... https://example.org/nginx_format.tar.gz

Installing still does not enable. The command prints the exact line to add to plugins and leaves the adding to you — the same rule as copying a file in, at the command line, where it would be most convenient to break it.

The manual cp above stays supported and stays documented. It is the path that works with no network, no manifest and no packaging, and clv plugin install ./my_plugin.py is the same act with a record kept of it.

What CLV checks, in this order, before a byte reaches your plugin directory:

Check What happens if it fails
--sha256, if you gave one The archive is hashed as a file and the install stops before anything is unpacked
Its size A download over 16 MB, or an archive expanding past 64 MB, is stopped mid-read
The archive's shape A member with .., an absolute path, a symlink, a hard link or a device node is refused and nothing is extracted
The manifest's checksums A file that does not match aborts the install, naming the file, before anything is copied
The signature A broken signature refuses; an absent or untrusted one installs and is reported

--sha256 is the only one of those that does not come from inside the archive. Everything else reads something the archive says about itself, and an archive swapped in transit says whatever its replacer wanted — it can rewrite the files and the manifest's checksums together. A digest you got from the download page cannot be rewritten that way, so publish one if you distribute a plugin, and use one if you are given one.

Downloads are https only and never follow a redirect to another host or to http. Nothing is ever imported to inspect it, at any point.

Signatures, and who decides

A plugin may ship a detached signature beside its manifest. CLV checks it against the keys you have trusted, and ships none of its own:

clv plugin trust "alice@example.com $(cat alice.pub)"
clv plugin trust --list
clv plugin verify                 # re-check everything installed

A signature is reported in one of five states — verified, unsigned, untrusted (signed by a key you have not trusted), unverifiable (no ssh-keygen installed) and bad. Only bad refuses an install: an absent signature is a choice the author made, a broken one is evidence. The format is OpenSSH's own, so a key you already keep in ~/.ssh/allowed_signers works unchanged, and CLV never needs a key of yours.

CLV ships no trusted keys and will not. A bundled key would make CLV the arbiter of which plugins are legitimate, which is a hosted plugin index arriving through a side door — see Non-Goals in clv/plugins/AGENTS.md. There is no index, no search and no auto-update, deliberately.

Checking it is still what you installed

clv plugin verify              # re-hash everything, re-check every signature
clv plugin info nginx_format   # manifest, origin, signature, its settings section

clv doctor reports a mismatch too, beside the plugin's own row, because that is the command you are asked for when something is wrong. A plugin you edited yourself reports as changed as well — that is the check working, not a warning about you.

Signatures are re-checked against the keys you trust now, not the keys you trusted at install. Trusting a signer later turns an untrusted plugin into a verified one with no reinstall, which is the whole reason to trust keys rather than files.

Removing one

clv plugin remove nginx_format            # files gone, name out of `plugins`
clv plugin remove --purge nginx_format    # also delete its [plugin:…] section

Removing takes the name out of plugins — leaving it would report the plugin as missing on every launch from then on — but keeps its [plugin:<name>] section, so reinstalling does not mean setting it up again. --purge removes that too.

A worked example, already on your machine

~/.config/clv/plugins/examples/ holds nine complete, commented plugins — one for every interface CLV publishes. Each is copyable as it stands and each argues for what it declares rather than describing it. Copy one up a level to use it, or as the starting point for your own:

cd ~/.config/clv/plugins && cp examples/nginx_error.py .

then add nginx_error to plugins. Nothing in examples/ is listed or run: it is one directory down and CLV only looks in the directory itself, so the plugin count keeps meaning plugins you installed.

Example Interface What it does
nginx_error.py LogFormat Reads nginx's error log — a format the built-in matchers do not recognise, so every line of one is a raw line without it
field_regex.py QueryOperator, ComputedField Adds the ~ operator and the age field described under Field queries
redact_secrets.py FilterStage Hides the value beside password=, token= and friends wherever the line is shown — the pane, the detail pane, an export, the clipboard
cluster_rules.py ClusterRule, ShapeContributor Folds repeats c could not: a Kubernetes pod suffix and an ANSI colour run, and one field that keeps two streams apart
timeline_marks.py TimelineAnnotation, TimelineMetric Marks deploys on the timeline and scales its bars by bytes rather than by lines
watch_alerts.py WatchMatcher, WatchSink Adds a burst rule kind — "five of these within a minute" — and a destination that appends every hit to a file you name
html_report.py Exporter Adds an HTML report to Ctrl+E: one self-contained file carrying the query that produced it
container_logs.py LogSourceProvider Offers every running container as a source, tailing live through podman or docker
commands.py Command Adds two commands to C, one of which opens a panel

Three of those ship inert — watch_alerts, timeline_marks and container_logs do nothing at all until their [plugin:…] section says so. That is the pattern any plugin that sends your logs somewhere, or runs something, is expected to follow: redact_secrets and html_report ship working because neither acts on the world.

Teaching CLV a format is a plugin's job like any other. A LogFormat gets offered every line the built-ins declined; what it returns is an entry on equal terms with a built-in's — searchable by field query, bucketed by the timeline, folded by c, shown in the detail pane and exportable, with its own source cell and chips in the structured view. clv/plugins/AGENTS.md has the interface.

So is teaching the query box a new word. A QueryOperator adds a comparison token and a ComputedField adds a queryable field derived rather than parsed — see Field queries. Both add vocabulary and neither adds grammar: there is still no OR, no parentheses and no precedence.

Managing what is installed

P — or the Plugins button in the Advanced drawer (f) — opens a list of everything CLV found, one row per plugin, with its name, the interfaces it supplies, where it came from, and one of five states:

State What it means
loaded Running.
not enabled Installed and waiting to be named in plugins, or switched off.
failed It raised, or CLV could not read it. The row shows the message.
incompatible It asked for a CLV or plugin API this build is not. Both versions are named.
isolated Running in a separate process CLV can stop — see Running a plugin where it can be stopped.

Space enables or disables the highlighted row, and r puts back a plugin a failure took out of service. Nothing is written until the dialog is closed, so Esc genuinely cancels.

Two consequences the dialog states as you toggle, because they are not symmetrical:

  • Enabling a plugin CLV has not already imported needs a restart. Plugins are imported once at startup and there is no hot reload; the name is written to settings.conf immediately, and it loads next launch.
  • Disabling a plugin CLV shipped lasts for the session only. The plugins key governs your own plugin directory, so a bundled plugin has no name in it to remove, and it comes back on restart.

Plugin failures are summarised in one line in the log panel rather than listed there — the detail, in full, is in this dialog.

A plugin that is merely slow is disabled too. A filter stage runs over every buffered line on every render, and a render happens on every keystroke in the query box — so one that is slow is indistinguishable from CLV being broken, and it used to say nothing at all. Each stage is now timed; one that goes over plugin_time_budget_ms (250 ms by default) on three consecutive passes turns up here as failed, with how long it took and what the ceiling was, and r puts it back. Set the key to 0 if you would rather have the slow plugin. A plugin that teaches CLV a format is timed the same way against plugin_read_budget_ms, measured over one batch of lines read rather than over a render — its parse runs once per line as the file is read, not once per keystroke.

A plugin is trusted code. It is Python imported into CLV's own process: it runs with your privileges and can read every file you can, including every log CLV has open. The interfaces bound what CLV asks of a plugin, not what a plugin can do, and CLV does not sandbox one — install one the way you would install any other program. The trust model and a checklist for reviewing someone else's plugin are in clv/plugins/AGENTS.md.

Running a plugin where it can be stopped

Everything above is about a plugin that fails. A plugin that hangs is a different problem, and until now CLV had no answer to it: a budget works by measuring a pass that finished and declining to start the next one, so a plugin that never returns takes the viewer with it.

A plugin can run in a process of its own instead:

[plugin:shipper]
isolated = true

or its author can ask for the same thing with isolated = True on the class. Either way CLV starts a child the first time that plugin is needed, and a call that takes longer than plugin_host_timeout_ms (5 s by default) ends with the child killed, the plugin disabled, and the reason in P — where r puts it back and starts a fresh one.

Writing it in settings.conf buys one thing the class attribute cannot: CLV never imports that module into its own process at all, so code the plugin runs at import is contained too.

This contains crashes, hangs and leaks. It does not make an untrusted plugin safe — the child runs as you, with your files and your credentials, and everything in the paragraph above stays true of it.

Four kinds can ask: exporters, watch sinks, commands and timeline annotations. The rest are called once per line or once per entry, where a round trip between processes is not a slower version of the same program but a different one; they are refused at load, by name, with the reason. Source providers are refused too, for their own reason: CLV polls a source's reader from the event loop on every tick.

An isolated plugin's print() goes nowhere — its output would otherwise land on the terminal CLV is drawing on — so it talks to you through the same toasts every other plugin uses.

For development, CLV_PLUGIN_PATH names extra directories (:-separated) searched ahead of the user directory, so a plugin can be run from where it is being edited. It is a development mechanism, not an install path, and the plugins enable-list still applies.

Writing a plugin

Drop a module into ~/.config/clv/plugins/ and name it in plugins (above), or — for a plugin shipped as part of CLV itself — into clv/plugins/filters/ (or sources/ / exporters/), or publish one from an installed package under the clv.plugins entry point group.

from clv.api import FilterContext, FilterStage, LogEntry, setting_list


class Redact(FilterStage):
    name = "redact_secrets"
    requires_api = ">=1.0,<2.0"

    def apply(self, entry: LogEntry, context: FilterContext) -> Optional[LogEntry]:
        matcher = self._matcher
        if matcher is None or not self._suspect(entry.raw):
            return entry
        ...


def register() -> list[FilterStage]:
    return [Redact()]

That is the shape of every plugin: import from clv.api, subclass one interface, say which API you were written against, and hand the instances back. It is also a genuine excerpt — the whole file is ~/.config/clv/plugins/examples/redact_secrets.py on your machine, and its docstring argues for each of those lines rather than describing them.

Return None from apply to drop a line. A plugin that fails to import, fails its version check, or raises at runtime is disabled and reported in the Advanced drawer — it cannot take the app down.

Import from clv.api. It publishes the interfaces, the LogEntry and filter types you are handed, the severity helpers and the field vocabulary — the same objects CLV uses itself, not copies — and it is the only part of CLV under a stability promise. It carries its own PLUGIN_API_VERSION, which is what requires_api constrains: pin that rather than CLV's release number and your plugin stops caring which CLV it is running on. Everything else, clv.services.* included, is internal and may move. The full contract, the deprecation policy and the published list are in clv/plugins/AGENTS.md.

Exporter plugins are reachable from the UI: Ctrl+E lists them alongside the three built-in formats and hands the selected one the whole filtered set. By default an exporter chooses its own destination (export receives the entries and a FilterContext, not a path); one that sets wants_path = True gets the dialog's path input enabled and the operator's choice passed through as destination. An exporter that raises is reported and skipped like any other plugin failure.

LogSourceProvider plugins are wired too: whatever discover() returns appears in a Providers group in the tree, and selecting one opens it like any other source. Implement discover() and open() and CLV wraps your iterator; for a source that tails, implement the optional open_reader(path, *, max_lines) instead and return an object with prime(), poll(), path, RELOAD_NOTICE and — if it holds anything — close(), which CLV calls on every source switch and at shutdown. The shipped journald provider is the worked example.

Provider sources are not filesystem paths, and CLV does not pretend they are: include/exclude globs describe a directory walk and rotated-set grouping is name arithmetic over files that rotate, so both refuse a provider identifier by name. Starring and merging are a different question and do work — a persisted identifier is not a persisted path, and a journal unit is exactly the source worth starring and comparing across a fleet.


Development

python -m pip install -e .
python -m pip install pytest
python -m pytest            # 2551 passed, 1 skipped, 11 deselected
python -m textual run clv/app.py --dev

Release files for centralized-log-viewer 3.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for centralized-log-viewer 3.1.0
File Size Uploaded
centralized_log_viewer-3.1.0.tar.gz 677.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for centralized-log-viewer 3.1.0
File Interpreter ABI Platform
centralized_log_viewer-3.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.4 MB

Release files / centralized_log_viewer-3.1.0.tar.gz

Download URL centralized_log_viewer-3.1.0.tar.gz
Size 677.0 kB
Tags Source
SHA-256 checksum
How to use checksums
6f16b6d564e5ee0518f903811bd59204102519ef59f0fe4870b844ba403c1c7e
BLAKE2b-256 checksum
How to use checksums
d1f9abbb84970ff7ad718a3f02f7dcca0e4ce683cc440f86e8133c120b319b7c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / centralized_log_viewer-3.1.0-py3-none-any.whl

Download URL centralized_log_viewer-3.1.0-py3-none-any.whl
Size 712.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9e41a7d52dbaa1c97aaae2d9efe953f3d5a8065fc3dbfd1b3d6ff8651829f930
BLAKE2b-256 checksum
How to use checksums
c3e6b89f3e5d3f9acfb52e34f3fdb38cc24e015db258df13e54bee76db43f99a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

3.1.0 This release

2 release files

3.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page