sofka 0.1.1

A Kubernetes TUI, reimagined in Rust
sofka-0.1.1 is not a library.

sofka

A Kubernetes TUI, reimagined in Rust - built on kube-rs and ratatui, async-first from the ground up.

Why "sofka"

That's Sophie. She sits behind the monitor and watches it - not occasionally, constantly, with the specific narrowed-eye expression of someone who has noticed a pod in CrashLoopBackOff and is judging you for it. She doesn't miss a state change. She doesn't get distracted. She is, functionally, a cluster watchman who happens to be a cat.

sofka is the Serbian diminutive of Sophia - "wisdom," fittingly, since watching things closely and knowing when something's wrong is more or less the whole job description of both a good cluster TUI and a good cat.

This is a from-scratch reimagining of k9s (originally ~51k lines of Go), not a line-for-line port. It keeps the spirit - a fast, keyboard-driven cluster navigator - but rethinks the architecture around a single generic object pipeline instead of one hand-written renderer per resource kind.

How it differs from k9s

  • One generic render pipeline, not one file per kind. k9s ships a dedicated Go file (struct + ColorerFunc) per resource type it knows about. sofka has one DynamicObject → cells function with curated columns for the common kinds and a NAME/AGE fallback for everything else - so a CRD nobody's written a renderer for still lists, sorts, and filters correctly on day one.
  • Flux CD is a first-class citizen, not a plugin. t opens a Suspend/Resume/Reconcile-now menu for Kustomizations, HelmReleases, git/helm/oci repositories, buckets, image automation, and notification alerts/receivers - patching spec.suspend and the reconcile.fluxcd.io/requestedAt annotation directly via the k8s API. No flux binary required, and it composes with bulk multiselect.
  • Port-forwards run in the background. Starting one doesn't freeze the TUI for its whole lifetime; :pf lists active forwards and stops them individually while others keep running. They're killed automatically on quit rather than left orphaned.
  • Bulk actions via multiselect. space marks rows for delete, kill, or Flux suspend/resume/reconcile across many resources at once - not one-row-at-a-time.
  • CRD rows drill into their custom resources, not their YAML - enter on a CustomResourceDefinition resolves its served version and lists the actual objects.
  • Skins, not a single fixed palette. Built-in Catppuccin/Gruvbox/Nord/ Dracula palettes selectable in config, with per-swatch hex overrides. Every semantic color (row status, severity badges, headers, borders) is derived from the active palette, so a skin change is consistent everywhere at once.
  • A combined row colorer. Whole-row status tinting like k9s (healthy rows, errors, pending, completed all read as one color), plus a distinct STATUS badge and outlier coloring on RESTARTS/CPU/MEM so a crash-looping or resource-hungry pod still pops out of an otherwise uniform row.

Why it's faster

Not a marketing number - these are specific, checkable design choices:

  • No GC. Rust's ownership model means zero garbage-collector pauses. Watching thousands of pods/CRs across a large cluster grows the in-memory store, but redraw latency doesn't get jittery as that store grows the way a GC'd runtime's can under sustained allocation pressure.
  • Batched redraws. The event loop drains every pending watch message before triggering one redraw (while let Ok(m) = rx.try_recv()). A rollout touching 50 pods costs one render pass, not fifty.
  • Cached row computation. Sorting and fuzzy-filtering the visible rows only reruns when the underlying data or the filter text actually changed (a dirty-flag-guarded cache), not on every frame or every keystroke against the full object set.
  • No subprocess overhead for the hot paths. Delete, scale, suspend/ resume/reconcile, and CRD drill-down are direct kube API calls (JSON merge-patches over the existing client), not a kubectl/flux process fork+exec per action.
  • Generation-tagged streams. Switching views doesn't wait for an old watcher to tear down - stale messages are dropped by generation tag the instant a newer watch takes over, so navigation never stalls behind a slow-to-cancel stream.

Features

  • Connect to the current kubeconfig context, including exec credential plugins (e.g. GKE).
  • API discovery of every resource type on the cluster, with k9s-style short aliases (po, dp, svc, no, cm, sts, ds, ks, hr, …) and correct precedence (core pods beats pods.metrics.k8s.io).
  • Live watch of any kind via kube::runtime::watcher, streamed into an in-memory store.
  • Curated columns for common kinds (pods, deployments, replicasets, statefulsets, daemonsets, services, nodes, namespaces, configmaps, secrets, jobs, cronjobs, PVC/PV, ingresses, endpoints, CustomResourceDefinitions) with a NAME/AGE fallback for everything else.
  • Live CPU/MEM columns for pods and nodes from the metrics API, with outlier coloring; degrades gracefully when metrics-server is absent.
  • Drill-down navigation with a breadcrumb stack: workload/service → pods, node → its pods, pod → containers, namespace → re-scope, CRD → its custom resources. esc pops back.
  • Command palette (:) - fuzzy over the full resource catalog and built-in commands (ctx, pulse, xray, diff, pf) together, plus fuzzy row filtering (/) with matched-character highlighting.
  • Multiselect (space) for bulk delete/kill/suspend/resume/reconcile.
  • Pulse dashboard (:pulse) - cluster-health tiles, refreshed every 5s.
  • Xray tree (:xray) - hierarchical view from the current kind down through owner references to pods and containers.
  • Flux CD controls (t) - suspend/resume/reconcile menu, native k8s API patches.
  • Background port-forwards (f/F to start, :pf to manage).
  • Plugins - config-defined shell-out commands bound to keys, scoped per resource.
  • Diff (:diff) - unified diff of the live object vs its last-applied-configuration.
  • RBAC-aware palette - hides resource kinds you can't list.
  • Namespace switcher (n) and context switcher (:ctx).
  • YAML view (y) and describe (d, via kubectl).
  • Logs (l) - per-container on a pod, aggregated across all matching pods on a workload/service. In-logs search (/) with highlighting; p for previous-container logs. ANSI color codes from the source app are parsed and mapped onto the active skin, not printed as literal escapes.
  • Skinnable - built-in Catppuccin/Gruvbox/Nord/Dracula palettes plus per-swatch overrides in config.
  • Config file (TOML): aliases, default namespace/resource, plugins, skin.

Installation

Prebuilt binaries for macOS (aarch64/x86_64) and Linux (aarch64/x86_64) are attached to each GitHub release. Nix users can run it directly without installing anything:

nix run github:nklmilojevic/sofka

or build from source (see Development).

macOS: "cannot be opened because the developer cannot be verified"

The release binaries aren't signed/notarized yet, so if you download a tarball through a browser and extract it, Gatekeeper will refuse to run it - this is expected, not a broken build. Clear the quarantine flag once:

xattr -d com.apple.quarantine sofka

(or right-click the binary in Finder → Open, and confirm through the dialog once). Signing and notarization are planned for the next release, at which point this step won't be necessary.

Configuration

$XDG_CONFIG_HOME/sofka/config.toml (or ~/.config/sofka/config.toml):

default_namespace = "kube-system"
default_resource  = "deployments"

[aliases]
dep = "deployments"

[skin]
name = "gruvbox"        # catppuccin-mocha (default), -latte, -frappe,
                         # -macchiato, gruvbox, nord, dracula

[skin.colors]            # optional per-swatch overrides
red = "#fb4934"

[[plugins]]
key = "g"
name = "argocd-sync"
command = "argocd"
args = ["app", "sync", "$NAME"]
scopes = ["deployments"]   # omit for all resources

Headless modes (no TTY required)

sofka --check                # connect, run discovery, print a summary, exit
sofka pods --snapshot        # render one frame of a resource view to stdout
sofka dp -A --snapshot       # deployments, all namespaces

These double as CI smoke tests.

Usage

sofka [RESOURCE] [-n NAMESPACE] [-A]

  RESOURCE          resource to open (alias/plural/kind), default: pods
  -n, --namespace   namespace to start in
  -A, --all-namespaces

Keys

Key Action
:<resource> command palette - fuzzy over kinds and built-in commands
enter drill down (workload/svc → pods, node → its pods, pod → containers, ns → re-scope, CRD → its resources)
esc go back / pop the view stack / clear filter / clear marks
j/k, /, g/G navigate
S / I cycle sort column / invert sort direction
space mark/unmark row for bulk actions
/ fuzzy filter
n / 0 namespace switcher / all namespaces
shift-j jump to owner/controller
o show the node hosting the selected pod
ctrl-r refresh the watch
y / d view YAML / describe (kubectl)
l / p logs (workload = all matching pods) / previous-container logs
c copy resource name to clipboard
e edit in $EDITOR (kubectl edit)
s shell into pod / scale a workload (context-dependent)
a attach to pod
i set container image
r rollout restart (workloads) / refresh (elsewhere)
f / shift-f port-forward (pods/services) - runs in the background
t Flux: suspend/resume/reconcile menu
ctrl-d / ctrl-k delete / kill (marked rows, or current)
:q, ctrl-c quit
? help

Logs view: / search+highlight · s autoscroll · w wrap · t timestamps · x stop/resume stream · c copy buffer · ctrl-s save to file · esc back. The newest line anchors to the bottom of the viewport.

Interactive actions (e, s-shell, a) suspend the TUI and shell out to kubectl; delete/scale/restart/set-image/suspend/resume/reconcile/ port-forward go through the kube API (or a backgrounded process) directly.

Architecture

main.rs      CLI (clap), terminal lifecycle, the async select! event loop,
             and the --check / --snapshot headless modes.
app.rs       All application state + input handling (a mode state machine:
             Table / Command / Filter / Detail / Logs / FluxMenu /
             PortForwards / Help / Namespaces / …). Spawns watch/log/
             port-forward tasks.
k8s.rs       Cluster connect, API discovery, alias registry + group-priority
             resolution, watch-task spawning, namespace listing.
store.rs     In-memory resource store + the Msg enum that watch tasks send to
             the UI (generation-tagged so stale streams are dropped).
columns.rs   Per-kind column definitions and cell extraction from
             DynamicObjects (the "render" layer), with unit tests.
ui.rs        All ratatui rendering: header, table, scrollable views, popups,
             status bar.
theme.rs     Palette + semantic styles, skin resolution.

Data flow: watcher tasks push generation-tagged Msgs over an mpsc::UnboundedSender; the main tokio::select! loop folds them into the Store, batches any other queued updates before redrawing, and shares that same loop with terminal input and a 1s tick (age columns, dead port-forward reaping) - so the UI never blocks on the network.

Development

cargo run -- pods            # run against current context
cargo test                   # unit tests (no cluster required)
cargo clippy --all-targets   # lints (clean)

License

Dual-licensed under MIT or Apache-2.0, at your option - the Rust ecosystem standard.