Custom label/annotation columns in resource tables (#776)#872
Merged
Conversation
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.
…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.
… 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).
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
❌ 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.
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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
ExtraColumnabstraction (ownrender/getSortValue/getFilterValue) that host code uses to inject a multi-cluster "Cluster" column, flowing throughextraColumnsByKey— which already overrides all three render paths. A custom column is just an internally-generatedExtraColumnappended toallColumnsand merged into that map, so it reuses those rails with no changes toCellContent's switch,getSortValue, orgetCellFilterValue. Keys are prefixed (label:/annotation:) so they never collide with built-in keys.The pure logic (key encoding, value read, and a load-boundary
sanitizeCustomColumnDefsthat trims, dedupes, and drops malformed entries) lives inutils/custom-columns.tswith 15 unit tests; only the JSXrenderstays 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)
allColumns— sinceallColumnsnow 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.JSON.parseboundary), and the autocompletedatalistuses a per-instanceuseId()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.yamlpower — skipped to preserve the no-config positioning).Verification
make tscclean; 526@skyhook-io/k8s-uitests pass (19 across the new column utils + filter-serialization round-trip). Visually verified end-to-end against thekindGitOps-demo cluster (1280/1920/2560 + dark mode): add akubernetes.io/archcolumn 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.labelsormetadata.annotationskey 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,sanitizeCustomColumnDefson load) and are turned into internalExtraColumns merged intoextraColumnsByKey, reusing the existing render/sort/filter rails without extendingCellContentswitches. The picker gains a pinned add-column footer (label/annotation toggle, datalist autocomplete from sampled row keys, per-instanceuseId), remove buttons on custom columns, and refactored host-extra collision handling viafilterHostExtras.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 solabel:…keys don’t break thekey:valueserializer. 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.