Skip to content

Custom label/annotation columns in resource tables (#776)#872

Merged
nadaverell merged 7 commits into
mainfrom
feature/776-custom-columns
Jun 6, 2026
Merged

Custom label/annotation columns in resource tables (#776)#872
nadaverell merged 7 commits into
mainfrom
feature/776-custom-columns

Conversation

@nadaverell

@nadaverell nadaverell commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #776. Adds k9s-style custom columns — users can surface any node/pod (or any kind's) metadata.labels[key] / metadata.annotations[key] as a real column in the resource table, configured from the existing column picker.

Custom columns are fully first-class: sortable, filterable, resizable, removable, and persisted per-kind in localStorage. Two users asked for this; the node-labels-as-columns case from the issue is the motivating example.

How it works

The table already had a self-contained ExtraColumn abstraction (own render / getSortValue / getFilterValue) that host code uses to inject a multi-cluster "Cluster" column, flowing through extraColumnsByKey — which already overrides all three render paths. A custom column is just an internally-generated ExtraColumn appended to allColumns and merged into that map, so it reuses those rails with no changes to CellContent's switch, getSortValue, or getCellFilterValue. Keys are prefixed (label: / annotation:) so they never collide with built-in keys.

The pure logic (key encoding, value read, and a load-boundary sanitizeCustomColumnDefs that trims, dedupes, and drops malformed entries) lives in utils/custom-columns.ts with 15 unit tests; only the JSX render stays in the component.

In the column picker: each custom column gets a remove (×) button, and a pinned footer (so the input never scrolls below the fold) offers a Label/Annotation toggle plus a text input with key autocomplete sampled from the loaded rows. Long values truncate with a tooltip.

State & persistence subtleties (worth a reviewer's eye)

  • The settings-load effect is keyed to the kind, not allColumns — since allColumns now changes at runtime on add/remove, keeping it in deps would re-read localStorage mid-edit and clobber the change. The effect reconstructs the column list locally instead.
  • The skip-initial-save guard is re-armed per kind so a kind switch can't persist the previous kind's columns under the new kind's storage key.
  • Persisted defs are sanitized on load (untyped JSON.parse boundary), and the autocomplete datalist uses a per-instance useId() so two tables on one page (fleet compare) don't cross-suggest.

Scope

localStorage-only, labels + annotations only, all resource kinds. Deferred: server-side / cross-device persistence, and arbitrary JSONPath/status-field columns (full k9s views.yaml power — skipped to preserve the no-config positioning).

Verification

make tsc clean; 526 @skyhook-io/k8s-ui tests pass (19 across the new column utils + filter-serialization round-trip). Visually verified end-to-end against the kind GitOps-demo cluster (1280/1920/2560 + dark mode): add a kubernetes.io/arch column on Nodes → arm64, persists across reload and Nodes→Pods→Nodes round-trips, remove + reset clear it.

🤖 Generated with Claude Code


Note

Medium Risk
Touches core ResourcesView state (columns, filters, URL sync, localStorage) across all kinds; logic is well-tested but regressions in column picker or kind-switch persistence are possible.

Overview
Adds k9s-style custom columns to the Resources table: users can define columns from any metadata.labels or metadata.annotations key via the column picker, with sort, filter, resize, remove, and per-kind localStorage persistence.

Custom defs live in utils/custom-columns.ts (key encoding, value read, sanitizeCustomColumnDefs on load) and are turned into internal ExtraColumns merged into extraColumnsByKey, reusing the existing render/sort/filter rails without extending CellContent switches. The picker gains a pinned add-column footer (label/annotation toggle, datalist autocomplete from sampled row keys, per-instance useId), remove buttons on custom columns, and refactored host-extra collision handling via filterHostExtras.

Column settings load/save is tightened so kind switches don’t cross-write storage, stale-default migration doesn’t wipe saved custom columns, and runtime add/remove doesn’t re-trigger load from allColumns. URL column filters now URI-encode keys so label:… keys don’t break the key:value serializer. README documents the feature; vitest covers custom columns and filter round-trips.

Reviewed by Cursor Bugbot for commit 2a08f76. Bugbot is set up for automated code reviews on this repo. Configure here.

Users can add columns sourcing any metadata.labels[key] or
metadata.annotations[key] value to any resource table, via the existing
column picker. Custom columns are sortable, filterable, resizable,
removable, and persisted per-kind in localStorage.

Implemented by materializing each custom column as a self-contained
ExtraColumn and merging it into allColumns + extraColumnsByKey, so it
rides the render/sort/filter override rails that already exist for
host-injected columns — no changes to CellContent, getSortValue, or
getCellFilterValue. The add-column UI offers key autocomplete sampled
from the loaded rows.
@nadaverell nadaverell requested a review from hisco as a code owner June 5, 2026 22:02
Comment thread packages/k8s-ui/src/components/resources/ResourcesView.tsx Outdated
Comment thread packages/k8s-ui/src/components/resources/ResourcesView.tsx
…stence

Review follow-ups:
- Extract customColumnKey/readCustomColumnValue + a sanitizeCustomColumnDefs
  load-boundary guard into utils/custom-columns.ts with unit tests. A
  malformed/legacy localStorage blob (non-array custom, blank path, bad
  source) previously could crash the load effect's .map or add dead columns;
  it's now filtered on load.
- Re-arm the skip-initial-save guard per kind. The ref was set once per
  component lifetime, so on a kind switch the save effect could persist the
  previous kind's columns (now including custom columns) into the new kind's
  storage key before state caught up.
The picker dropdown was a single max-h-[400px] overflow-auto container, so on
kinds with many columns (e.g. Nodes) the add-column input was pushed below the
fold — only the Label/Annotation toggle showed, the text input was clipped.
Split into a flex column: fixed header, scrollable column list, fixed add-form
footer, so the input is always visible.
Comment thread packages/k8s-ui/src/components/resources/ResourcesView.tsx
… share extras filter

- sanitizeCustomColumnDefs now trims paths and dedupes by key, so a
  hand-edited localStorage blob yields the same defs the add path would —
  no padded keys, no two columns sharing one key. + tests (incl. non-string
  path drops without throwing, locking the predicate short-circuit order).
- Datalist id is now per-instance via useId() so two ResourcesView tables on
  one page (fleet compare) don't cross-suggest each other's keys.
- Extract filterHostExtras() shared by the allColumns memo and the load
  effect so the built-in-collision rule can't drift between them.
- Comment cleanups: drop inaccurate "legacy" framing (the field is new),
  clarify the per-kind save-guard mechanism (async setState, not effect order).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 597bbb5. Configure here.

Comment thread packages/k8s-ui/src/components/resources/ResourcesView.tsx
- Make custom (and any extra) columns header-sortable when they carry
  getSortValue — previously the header gated sortability on a hardcoded
  built-in key list, so custom columns couldn't be sorted despite the wiring.
- Add extraColumnsByKey to the filteredResources deps: custom columns hydrate
  in a later effect, so a reload with a URL filter on a custom key could keep
  a cached result computed before those columns existed.
- removeCustomColumn now also clears any columnFilters entry and resets sort
  for that key — otherwise the table keeps filtering (and syncing to the URL)
  on a column that no longer exists, with no UI to clear it.
- Don't run the stale-pure-defaults migration when custom columns are
  persisted — it cleared them from storage (and un-hid hidden ones).
…colons

Custom-column keys contain a colon (label:tier, annotation:foo), which is the
same delimiter parseColumnFilters splits on. A filter on a custom column
serialized to `label:tier:control-plane` and parsed back to key `label` /
value `tier:control-plane`, so the URL→state re-sync mangled the key — the
filter stopped matching any column (blank table) and couldn't be cleared by
removing the column. URI-encode the key on serialize and decode on parse so
the key:value delimiter is unambiguous; built-in keys (no special chars) are
unchanged and legacy URLs still parse. + round-trip tests.
@nadaverell nadaverell merged commit 8fbb4ab into main Jun 6, 2026
9 checks passed
@nadaverell nadaverell deleted the feature/776-custom-columns branch June 6, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom Columns

1 participant