htop for your whole fleet — with nothing installed on the remote. If you can ssh into a host and its Nix daemon trusts you, you can watch its live processes, CPU, memory, swap, disk, and network. drishti ships its own agent over the SSH connection on first connect — no package to install, no inbound port to open, no daemon to configure on the far end.
Browser (SolidJS) ↔ local parent server (Bun) ↔ remote agent over ssh stdio, on the typed reactive transport @kolu/surface + Effect RPC over ssh.
▶ Watch drishti in action — a short screencast demoing the multi-host fleet view and live htop drill-down.
Screenshots:
- Zero install on the remote. The agent closure is shipped over SSH (
nix copy --derivationthen realise) on first connect and reused after. The agent is built from its own minimal derivation, so editing the UI or parent server doesn't change its hash — a drishti upgrade doesn't force a fresh closure copy on the next reconnect. The remote needs only passwordlessssh+ anix-daemonthat trusts your user — no agent binary to install, no inbound port, no config file to drop. The agent runs as a durable daemon under~/.local/state/drishti/(gate + socket + history ring);ssh … drishti-agent --stdiofronts it so the session outlives the SSH link and idle-exits after 60 minutes with no connections. - Zero config locally. No database, no setup. Metric history is retained by the agent daemon (on disk under
~/.local/state/drishti/history.ring.json) so charts survive parent deploys and reconnects.nix runand you're watching. - One pane, many hosts, mixed arch. A macOS laptop can drive Linux and macOS remotes from a single
nix run— drishti probes each host's Nix system and ships the matching build. - Cross-OS, same facts. Linux and macOS are observed through the same versioned
osfactscontract — including cache-aware available memory. On macOS, Apple gates other users' task info from ordinary binaries, so drishti supplements only osfacts-blind per-process CPU/RSS with the privileged system/bin/ps.
nix run github:srid/drishti # localhost (default)
nix run github:srid/drishti -- user@host # one remote
nix run github:srid/drishti -- localhost a.lan b.lan # multiple hosts (tabbed UI)Open http://localhost:7720. The UI opens on the fleet tab — a single overview pane with one live summary card per host (connection state, CPU, memory, swap, disk, load average, uptime, and a 30m CPU/memory/swap/disk history sparkline); click a card (or a host's tab) to drill into that host's full htop. Every view has its own URL, so you can bookmark or share a link straight to a host: selecting a host updates the address to http://localhost:7720/?host=user@host (the fleet overview is the bare http://localhost:7720/), and opening such a link — or reloading — lands directly on that host. The browser tab is titled after the host drishti itself runs on — drishti@<hostname> (e.g. drishti@zest) — so a row of tabs (or installed apps) watching different fleets is self-labelling rather than five identical drishti tabs.
Important
drishti is single-user software. It is meant to run on your own machine and be reached from that machine — it binds 127.0.0.1 (loopback) by default. Its RPC surface is unauthenticated (no accounts, sessions, or access control), so drishti is not designed to be exposed to the internet or shared between users. If you put it on a public address or a shared network you do so at your own risk — prefer a trusted private overlay such as tailscale serve, and see Deployment for the --bind and origin-allowlist knobs.
- Live time-series charts (CPU% / memory% / swap% / disk%) over a rolling 1m / 5m / 15m / 30m window, switched by a segmented control. The agent daemon samples every host on each poll tick and keeps a durable ring on disk — so the chart survives page reloads, tab switches, parent deploys, and reconnects, and is already populated the first time you open a host. The fleet overview carries the same trend at a glance: each host card shows a compact 30m CPU/memory/swap/disk sparkline drawn from the same ring (pinned to the widest window, no per-card picker). A corrupt or unknown-version ring surfaces as a typed "unavailable" state rather than a silently empty chart. Disk is root-filesystem (
/) fullness — a slow-moving capacity gauge, not disk I/O. A tab left in the background pauses its live views after ~20s and shows a "Paused" placeholder — a hidden tab can't display telemetry, so it stops decoding the 2s frames for it; because the agent keeps sampling regardless, the full history is intact the instant the tab is back in view. - Runtime host management — the
+ add hostbutton adds hosts, the×on each tab removes one. Added/removed hosts persist to$XDG_STATE_HOME/drishti/hosts.json(override withDRISHTI_HOSTS_FILE), sonix run github:srid/drishtiwith no args restores the last session. - Per-host view memory — the chart window, process sort column, and process filter stick across reloads via
localStorage, remembered per host. A light/dark toggle (top-right of the tab strip) overrides the OS theme and is remembered globally; until you touch it, the theme follows your system preference. The address-bar / installed-app tint (the PWAtheme-color) tracks the chosen theme — driven reactively from the in-app toggle rather than the OS media query — so it stays in step even when the toggle overrides the system preference. - One host-observation boundary on both OSes — the agent invokes the Nix-baked
osfactsV2 binary through its version-gated TypeScript client. One atomic host-wide snapshot supplies process identity, CPU time, UID, RSS, start time, cwd, state/nice/threads, argv, claimed listeners, and host-wide unclaimed listeners where the OS permits; onehostreading supplies load, memory, swap, uptime, cumulative per-core CPU plus model/clock metadata, network counters, and root-disk capacity. Usable facts survive a partial source error and the namedsource · facet · codestatus is shown alongside them. Darwin runs/bin/psbeside every process snapshot and uses its CPU/RSS only where osfacts reports those facets unreadable; readable osfacts values always win. Drishti never searchesPATHand has nolsof,/proc,sysctl,vm_stat,netstat, orstatfsfallback. - Click a process for details — selecting a row opens an inline panel with CPU and memory shares, full argv, cwd, state, nice value, nullable thread count, linked parent PID, start time, and attributed listeners. Listener addresses are decoded to ordinary IPv4/IPv6 text at the render edge. The compact table has no dedicated ports column; listener addresses remain available in details and filtering. Permission-blind sockets stay visible in a separate unclaimed-listener strip (with UID on Linux when available), collapsed to its count by default rather than becoming a falsely empty process port list. Click the row again, the
✕, or pressEscto close. - Dense host drill-down — host identity and headline metrics share a compact header, CPU frequency sits inline with utilization only when osfacts reports it, idle NICs and unclaimed listeners start collapsed, and command plus cwd share one process row. CPU%, USER, memory, and clock-corrected UPTIME remain sortable. A partially unreadable fact gets one subtle hoverable marker at the cell it qualifies; a recovered command-fallback value keeps its number and gains a quiet
↩naming the command on hover; a fully blind process collapses to one dimmed unreadable row instead of repeating an errno across the table. - Idle NICs collapse behind a
+N idletoggle by default, so the few interfaces moving traffic aren't buried under the dozens of always-zero virtual ones (utunN, anpiN, …). - Read-mostly, with explicit process control — telemetry is read-only. The sole per-host mutation is
process.kill: the process detail panel can send SIGTERM or SIGKILL, and reports permission/race failures instead of swallowing them. - Installable PWA, one per host — drishti ships a web app manifest and an emerald aperture icon, so you can install it as a standalone app (desktop, or a phone's home screen). The manifest's name and identity are the server's own
drishti@<hostname>, so installing drishti from two different hosts gives you two distinct, separately-labelled apps in the OS app list — not one ambiguousdrishtithat collides. The shell and its assets are served by@kolu/surface-appunder a strict freshness contract (see below); drishti ships no caching service worker of its own. A Pin app button in the tab strip surfaces the install prompt directly — it appears only on a secure origin (https://…, e.g. atailscale serveFQDN) and hides once drishti is already installed, gated on thecanInstallPwa/isInstalledsignals from@kolu/surface-app'suseSurfaceApp()and rendered via@kolu/solid-pwa-install(which owns the cross-browser install volatility). - Always the deployed build — drishti adopts
@kolu/surface-app, which owns the freshness contract on the wire: the HTML shell is servedno-store, the content-hashed/assets/*bundle and stylesheet are pinnedimmutablefor a year, an asset miss 404s (never the HTML shell under a.jsURL), and the server serves a self-destructing/sw.jsthat unregisters any legacy caching worker an earlier drishti build left behind. The client's build commit (carried on theno-storeHTML shell aswindow.__SURFACE_APP_COMMIT__, read viashellCommit()— never baked into animmutablebundle, which a stamp-only deploy would leave stale; kolu#1319) rides abuildInfocell on the admin surface alongside the server's resolved commit; both commits — plus a server-connection liveness dot — are always visible in a slim status footer pinned to the bottom of the viewport, and when a tab is provably behind the deployed build the footer grows a≠ srvone-tap reload affordance. A returning installed client thus always re-fetches the shell and converges to the deployed build — the stale-client class of bug, gone structurally.
On macOS, Apple gates other users' task info behind a private entitlement. Drishti's ordinary, unprivileged osfacts process cannot read those CPU/RSS facts, so the agent recovers them from Apple's privileged /bin/ps. If that command fails, the process frame still publishes and the failure appears as a /bin/ps source-error status beside any still-blind cells; a process that exits between the two censuses stays honestly unreadable for that poll. Linux needs no supplement.
On macOS 27+, Apple platform signing gates the host-wide unclaimed-listener table, so osfacts reports a named ports_unclaimed source failure there. Claimed listeners for the agent's own processes still work.
Requirements:
- The remote host must be
ssh-reachable with passwordless auth and a workingnix-daemonthat trusts your user (trusted-usersinnix.conf) — drishti provisions the agent by shipping its.drvto the remote withnix copy --derivationand realising it there. - Localhost works without any remote setup.
Hosts behind a bastion (SSH hops). drishti runs plain ssh <host>, so a host reachable only through a jump box needs no drishti config — just an ~/.ssh/config entry with ProxyJump:
Host db-internal
HostName 10.0.0.5
ProxyJump bastion.example.com
Then add db-internal like any other host. The agent spawn, the nix-system probe, and the nix copy of the agent closure all hop through the bastion automatically. The jump host must itself be non-interactive from where drishti runs (drishti forces BatchMode=yes), so a bastion that prompts for a password fails rather than hangs — use a key.
Mixed-architecture host sets are supported: the monitor wrapper bakes a {system → drv} map for x86_64-linux, aarch64-linux, and aarch64-darwin, and the parent probes each host's nix-system on add (via @kolu/surface-remote's resolveSystem, which asks the host's own Nix for builtins.currentSystem) to pick the matching .drv. A macOS user can drive a Linux remote (or both) from one nix run invocation.
Browser (SolidJS UI, tab strip)
│ one WebSocket (admin + keyed host map)
▼
Parent server (Bun, drishti)
│ ssh stdio front → unix socket (Effect RPC, ndjson) ×N
▼
Host 1: drishti-agent daemon Host 2: drishti-agent daemon …
│ ~/.local/state/drishti/ (gate, socket, history.ring.json)
│ osfacts V2; Darwin /bin/ps supplements blind process CPU/RSS
▼
Kernel
The browser opens one WebSocket at /rpc/ws?host=__admin__. That transport multiplexes the admin procedures, surface-app build identity, and a keyed @kolu/surface-map entry for every monitored host. Host identity is the map key rather than a dedicated socket; each entry carries its own fine-grained connection state while sharing the one transport-liveness floor.
Per-host primitives:
| Primitive | Path | Purpose |
|---|---|---|
| Cell | system |
Load averages, cache-aware used/total memory, swap, root-disk (/) capacity, uptime, OS, and hostname. All numeric observations come from one cached osfacts V2 host --load --mem --cpu --net --disk frame. |
| Collection | processes |
Host-wide, keyed by PID — short name plus capped full argv, CPU%, rendered UID, cwd, state/nice/nullable threads, PPID, RSS, start identity, listeners, facet-specific unreadable[], and per-facet command fallbacks[] provenance. On Darwin, /bin/ps fills only osfacts-blind CPU/RSS values. A U-only pid remains represented. Snapshot-then-delta. |
| Collection | unclaimedListeners |
Readable listening sockets whose owning pid could not be attributed — { port, address, uid }. This preserves every unclaimed listener osfacts can observe even when per-process descriptor inspection is blind. |
| Collection | sourceErrors |
Named osfacts E rows (operation, source, facet, code) accompanying otherwise usable partial frames. |
| Collection | cpuCores |
Per-core CPU usage, model, and nullable MHz. The whole grid rides the collection's batched deltas. |
| Collection | networkInterfaces |
Per-NIC network I/O from osfacts V2, keyed by interface name — { rxBytes, txBytes, rxRate, txRate } (cumulative bytes since boot + bytes/sec throughput); loopback filtered out. The UI strip collapses idle interfaces behind a +N idle toggle. |
| Stream | metricHistory |
Agent-daemon-owned durable CPU/memory/swap/disk history ring on disk (~/.local/state/drishti/history.ring.json): full retained snapshot on subscribe, then one delta per poll. Survives parent deploys and reconnects; corrupt/unknown-version rings surface as a typed unavailable standing state (never a silently empty chart). |
| Procedure | process.kill |
Send an allowed signal to a PID on the host that owns it. The UI exposes TERM and KILL. |
The process, listener, source-error, CPU, and network collections are observation-only. process.kill is the one explicit per-host mutation.
Admin surface primitives:
| Primitive | Path | Purpose |
|---|---|---|
| Host map | hosts.entries |
Configured hosts, keyed by host string, with entry status and per-host connection state. |
| Procedure | hosts.add |
Spin up a new host session; persists to the hosts file. |
| Procedure | hosts.remove |
Tear down a host session; persists removal. |
| Procedure | hosts.reconnect |
Re-arm a configured host whose session reached a terminal failure. |
| Procedure | hosts.recheck |
Ask every host session to probe again after browser focus/network recovery. |
just dev # parent server :7720, host=localhost
just dev user@somehost # any ssh target with passwordless access
just dev localhost a.lan b.lan # multiple hosts (per-host arch probe)
just typecheck # tsc --noEmit across the workspace
just fmt # nixpkgs-fmt everything *.nix
just nix-build # build the wrapped monitor binary
just regenerate-bun-nix # after any bun.lock changejust dev exports DRISHTI_AGENT_DRVS_JSON (the per-system .drv map from the flake's agentDrvsJson attribute) and boots Bun in watch mode. The parent probes each host's nix-system as part of bringing the host up — asking the host's own Nix for builtins.currentSystem — and picks the matching .drv from the map, so one dev session can mix architectures. The probe runs inside the connection's spawn cycle, so a host that's unreachable when the probe fires (offline remote, stale ssh-agent) folds into the same retry path as any other connection failure rather than aborting startup. The dev server invokes buildClient() at startup so a single bun --watch covers both server-TS and client-bundle rebuilds. Browser refresh is manual — there's no HMR.
The first connect to a fresh remote ships the agent closure over ssh (nix copy --derivation then nix-store --realise); subsequent connects reuse it. The realised closure is pinned behind a per-host GC root on the remote, so a nix-collect-garbage there can't delete the agent out from under a live session or force a rebuild on the next reconnect. The progress is streamed to the browser via the connection cell, and the overlay shows how long the current phase has been running ("Connecting… 18s") so a slow connect reads as abnormal. A dropped link reconnects automatically (the tab pulses amber, "Reconnecting…"), and what happens next depends on why it's down. If the host is simply unreachable — offline, asleep, or you've roamed onto a different network — drishti keeps retrying indefinitely at a capped backoff (the overlay reads "Host unreachable — retrying…"), so the link comes back on its own once the host is reachable again, no clicking. Only a remote rejection — the host answered but its nix-daemon won't accept the closure because your user isn't in trusted-users — is treated as terminal: after a few attempts the host enters a failed state, since retrying can't fix a misconfiguration. drishti then shows the underlying error, the captured connection log (the real nix copy/ssh output — not a guess at the cause), and a Reconnect button that re-arms the session in place, so you don't have to restart the parent. A connect that comes up but never completes its first RPC — transport alive, handshake wedged — is timed out by a watchdog rather than hanging in "connecting" forever. A host that's unreachable from the very start — offline when the monitor launches — just keeps retrying while every reachable host is monitored normally, instead of one bad host crashing the monitor before its HTTP port is even bound.
Because drishti is usually run on a laptop, it also recovers from sleep: close the lid at home, reopen at a café, and the parent detects the wake and immediately re-probes every host's link rather than waiting ~30s for each stale SSH connection to notice it's dead. The browser does the same when it regains connectivity (online) or you refocus the tab.
A home-manager module runs the monitor as a systemd user service on Linux and as a launchd LaunchAgent on macOS:
{
imports = [ drishti.homeManagerModules.default ];
services.drishti = {
enable = true;
package = drishti.packages.${system}.default;
port = 7720; # default
hosts = [ "user@host-a" ]; # optional; empty = manage hosts at runtime
};
}The monitor binds 127.0.0.1 (loopback) by default — its RPC surface is unauthenticated, so out of the box it is reachable only from the machine drishti runs on. To reach it from elsewhere, front it with a trusted reverse proxy (e.g. tailscale serve, which forwards to loopback), or — behind a firewall — bind all interfaces explicitly with --bind 0.0.0.0 (services.drishti.bind = "0.0.0.0", or the DRISHTI_BIND env var). When a proxy serves the UI from a different origin than the Host drishti receives, allowlist that origin via services.drishti.allowedOrigins (the DRISHTI_ALLOWED_ORIGINS env var) so the browser's WebSocket clears the same-origin check. hosts are passed as positional arguments; leave it empty to let drishti seed from its persisted hosts file ($XDG_STATE_HOME/drishti/hosts.json, overridable via services.drishti.hostsFile) and manage the set from the admin surface. The packaged wrapper already bakes DRISHTI_DIST_DIR / DRISHTI_AGENT_DRVS_JSON and puts openssh + nix on PATH, so the service self-contains its runtime needs.
See nix/home/example/ for a full configuration — a NixOS VM test exercises the systemd path on Linux, and a standalone home-manager activation build exercises the launchd path on Darwin.
On macOS, the LaunchAgent writes stdout to ~/Library/Logs/drishti.out.log and stderr to ~/Library/Logs/drishti.err.log, so crashes and startup failures leave logs alongside other user logs. Every stderr line is timestamped (ISO-8601) and tagged by subsystem — [server], [hosts], [admin], and one [bridge:<host>] per monitored host — so a connection problem can be traced to a specific host on a timeline (on Linux/systemd, journalctl --user -u drishti adds its own timestamps).
drishti/
├─ flake.nix # one input: juspay/bun2nix (rawflake)
├─ default.nix # composer — exposes drishti, drishti-agent, drishti-client
├─ shell.nix # mkShell + hydrate-script shellHook
├─ package.json # bun workspaces ["packages/*"]
├─ bunfig.toml # [install] linker = "hoisted"
├─ bun.nix # generated by bun2nix
├─ justfile
├─ npins/sources.json # nixpkgs + kolu pins
├─ nix/
│ ├─ nixpkgs.nix
│ ├─ overlay.nix # kolu-surface, kolu-surface-remote, …
│ ├─ env.nix # DRISHTI_KOLU_SURFACE{,_NIX_HOST}
│ ├─ packages/
│ │ ├─ kolu-package.nix # mkKoluPackage factory
│ │ ├─ drishti/default.nix # monitor + client build (full app tree)
│ │ └─ drishti-agent/default.nix # scoped agent build — minimal inputs (issue #38)
│ └─ home/
│ ├─ module.nix # home-manager module (systemd / launchd)
│ └─ example/ # example config — CI-built (VM + launchd checks)
├─ scripts/
│ └─ hydrate-kolu-packages.sh
└─ packages/ # bun workspace members ["packages/*"]
├─ common/src/surface.ts # per-host wire contract — agent + monitor share it
├─ agent/src/{main.ts, proc.ts} # remote-side agent (its own scoped build)
└─ app/
└─ src/
├─ common/{metrics.ts, history.ts, admin-surface.ts} # monitor-internal shared (admin surface + metric math)
├─ server/ # parent server
│ ├─ main.ts # multi-host WS dispatch
│ ├─ router.ts # per-host router fragment
│ ├─ admin-router.ts # host-set router fragment
│ ├─ hostRegistry.ts # per-host session pool
│ ├─ archMap.ts # compose kolu's resolveSystem with the drv map
│ ├─ hostsStore.ts # $XDG_STATE_HOME/drishti/hosts.json
│ └─ build.ts # client bundler
└─ client/ # SolidJS UI
├─ App.tsx # MultiHostApp + TabStrip + HostView
├─ wire.ts # one admin socket + keyed host surface map
└─ {main.tsx, index.html, styles.css}
@kolu/surface, @kolu/surface-remote, @kolu/surface-app, and @kolu/solid-pwa-install aren't on npm. They're vendored from the juspay/kolu repo via npins, exposed as Nix-store paths through nix/overlay.nix, and hydrated into node_modules/@kolu/* by scripts/hydrate-kolu-packages.sh. The hydrate script takes (src, dest) pairs so adding another @kolu/* package is a one-line addition to the overlay + env — as @kolu/solid-pwa-install demonstrates.
To bump the kolu pin:
nix shell nixpkgs#npins -c npins update koluThe client bundler is a hand-rolled Bun.build pipeline (packages/app/src/server/build.ts) with a small solidJsxPlugin (babel-preset-solid + babel-preset-typescript). Same bundle code path runs in dev (server invokes it at startup) and Nix (build derivation runs it during buildPhase). Tailwind v4 compiles via @tailwindcss/cli as part of the same pipeline.
CI runs via odu — "a CI runner you attach to" — invoked straight from upstream (nix run github:juspay/odu -- run). odu ships its own generic lane runner (nix copyd to remote lanes), so this repo re-exports nothing. The canonical pipeline is the [metadata("ci")] DAG in ci/mod.just; it builds every flake output, type-checks, checks formatting + bun.nix freshness, asserts agent .drv stability, and boots the home-manager example. Lane hosts come from ~/.config/odu/hosts.json (falling back to ~/.config/justci/hosts.json); a live run is attachable (nix run github:juspay/odu -- attach) and agents drive it through odu's MCP server (mcp__odu__*).
One upstream issue currently shapes how CI runs:
- crates.io blocks
curl/*User-Agent. Everycrate-*.tar.gzfetched bybun2nix's rust dep tree fails itspkgs.fetchurlwith HTTP 403.ci/mod.just's_prefetch-cratesrecipe (scripts/ci-prefetch-crates.sh) sidesteps this by fetching missing crates with a Mozilla UA and injecting them vianix-store --add-fixed. Idempotent and content-addressed, so the workaround disappears the first time upstream fetchurl learns to set a non-curl UA — or once bun2nix's Rust deps fetch fromstatic.crates.iodirectly. Tracking issue: TBD.
Builds are pushed to Juspay's shared OSS Attic cache at cache.nixos.asia/oss. The .github/workflows/nix-cache.yml workflow builds the default package on linux + darwin for every push and PR and pushes each closure via ryanccn/attic-action. The cache is wired in as a substituter through the flake's nixConfig, so a local nix build pulls prebuilt closures instead of rebuilding — accept the flake config (accept-flake-config = true, or answer y when prompted) to opt in.
AGPL-3.0-or-later (matches upstream @kolu/surface).