Skip to content

Commit 8fbb4ab

Browse files
authored
Custom label/annotation columns in resource tables (#776) (#872)
Add k9s-style custom columns: surface any metadata.labels[key] / annotations[key] as a sortable, filterable, resizable, removable column from the column picker, with key autocomplete sampled from loaded rows. Persisted per-kind in localStorage. Implemented by materializing each custom column as a self-contained ExtraColumn merged into allColumns + extraColumnsByKey. Pure logic (key encoding, value read, load-boundary sanitize) extracted to utils/custom-columns.ts with unit tests. Closes #776.
1 parent 4d4da5b commit 8fbb4ab

6 files changed

Lines changed: 450 additions & 51 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ Table-based resource browser with smart columns per resource kind.
186186

187187
- Browse all resource types including CRDs
188188
- Search by name, filter by status or problems (CrashLoopBackOff, ImagePullBackOff, etc.)
189+
- Add custom columns from any label or annotation — sortable, filterable, and resizable
189190
- Click any resource for YAML manifest, related resources, logs, and events
190191

191192
### Image Filesystem Viewer

packages/k8s-ui/src/components/resources/ResourcesView.tsx

Lines changed: 254 additions & 47 deletions
Large diffs are not rendered by default.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { parseColumnFilters, serializeColumnFilters } from './resource-utils'
3+
4+
describe('column filter serialization round-trip', () => {
5+
it('round-trips built-in keys', () => {
6+
const filters = { status: ['Running'], namespace: ['kube-system', 'default'] }
7+
expect(parseColumnFilters(serializeColumnFilters(filters))).toEqual(filters)
8+
})
9+
10+
it('round-trips custom-column keys whose own colon collides with the delimiter', () => {
11+
const filters = { 'label:tier': ['control-plane'], 'annotation:foo/bar': ['x'] }
12+
const serialized = serializeColumnFilters(filters)
13+
// The key colon must be encoded so the first literal ':' is the delimiter.
14+
expect(serialized).toBe('label%3Atier:control-plane|annotation%3Afoo%2Fbar:x')
15+
expect(parseColumnFilters(serialized)).toEqual(filters)
16+
})
17+
18+
it('preserves commas inside values', () => {
19+
const filters = { conditions: ['Ready,SchedulingDisabled'] }
20+
expect(parseColumnFilters(serializeColumnFilters(filters))).toEqual(filters)
21+
})
22+
23+
it('parses legacy unencoded built-in keys', () => {
24+
expect(parseColumnFilters('status:Running')).toEqual({ status: ['Running'] })
25+
})
26+
})

packages/k8s-ui/src/components/resources/resource-utils.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1648,8 +1648,12 @@ export function parseColumnFilters(filtersParam: string | null): Record<string,
16481648
for (const pair of filtersParam.split('|')) {
16491649
const colonIdx = pair.indexOf(':')
16501650
if (colonIdx > 0) {
1651-
const key = pair.slice(0, colonIdx).trim()
1651+
const rawKey = pair.slice(0, colonIdx).trim()
16521652
const valStr = pair.slice(colonIdx + 1).trim()
1653+
// Keys are URI-encoded so a custom-column key's own colon (e.g.
1654+
// "label:tier") doesn't collide with the key:value delimiter.
1655+
let key: string
1656+
try { key = decodeURIComponent(rawKey) } catch { key = rawKey }
16531657
if (key && valStr) {
16541658
filters[key] = valStr.split(',').map(v => {
16551659
try { return decodeURIComponent(v.trim()) } catch { return v.trim() }
@@ -1660,12 +1664,13 @@ export function parseColumnFilters(filtersParam: string | null): Record<string,
16601664
return filters
16611665
}
16621666

1663-
// Serialize column filters to URL param format
1664-
// Values are URI-encoded so commas inside values (e.g. "Ready,SchedulingDisabled") survive the round-trip.
1667+
// Serialize column filters to URL param format. Keys and values are both
1668+
// URI-encoded so a colon inside a custom-column key (e.g. "label:tier") or a
1669+
// comma inside a value (e.g. "Ready,SchedulingDisabled") survives the round-trip.
16651670
export function serializeColumnFilters(filters: Record<string, string[]>): string {
16661671
const result = Object.entries(filters)
16671672
.filter(([, v]) => v.length > 0)
1668-
.map(([k, vals]) => `${k}:${vals.map(v => encodeURIComponent(v)).join(',')}`)
1673+
.map(([k, vals]) => `${encodeURIComponent(k)}:${vals.map(v => encodeURIComponent(v)).join(',')}`)
16691674
.join('|')
16701675
return result
16711676
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from './custom-columns'
3+
4+
describe('customColumnKey', () => {
5+
it('encodes source and path', () => {
6+
expect(customColumnKey({ source: 'label', path: 'topology.kubernetes.io/zone' })).toBe('label:topology.kubernetes.io/zone')
7+
expect(customColumnKey({ source: 'annotation', path: 'foo/bar' })).toBe('annotation:foo/bar')
8+
})
9+
10+
it('produces equal keys for same source+path (dedupe contract)', () => {
11+
const a = customColumnKey({ source: 'label', path: 'x' })
12+
const b = customColumnKey({ source: 'label', path: 'x' })
13+
expect(a).toBe(b)
14+
})
15+
16+
it('distinguishes source for the same path', () => {
17+
expect(customColumnKey({ source: 'label', path: 'x' }))
18+
.not.toBe(customColumnKey({ source: 'annotation', path: 'x' }))
19+
})
20+
})
21+
22+
describe('readCustomColumnValue', () => {
23+
const res = (meta: any) => ({ metadata: meta })
24+
25+
it('reads a label value', () => {
26+
expect(readCustomColumnValue(res({ labels: { zone: 'us-east-1a' } }), { source: 'label', path: 'zone' })).toBe('us-east-1a')
27+
})
28+
29+
it('reads an annotation value', () => {
30+
expect(readCustomColumnValue(res({ annotations: { team: 'infra' } }), { source: 'annotation', path: 'team' })).toBe('infra')
31+
})
32+
33+
it('does not cross labels and annotations', () => {
34+
const r = res({ labels: { k: 'fromLabel' }, annotations: { k: 'fromAnnotation' } })
35+
expect(readCustomColumnValue(r, { source: 'label', path: 'k' })).toBe('fromLabel')
36+
expect(readCustomColumnValue(r, { source: 'annotation', path: 'k' })).toBe('fromAnnotation')
37+
})
38+
39+
it('returns empty string for a missing key', () => {
40+
expect(readCustomColumnValue(res({ labels: { a: '1' } }), { source: 'label', path: 'b' })).toBe('')
41+
})
42+
43+
it('returns empty string when metadata/bag is absent', () => {
44+
expect(readCustomColumnValue({}, { source: 'label', path: 'b' })).toBe('')
45+
expect(readCustomColumnValue(res({}), { source: 'annotation', path: 'b' })).toBe('')
46+
expect(readCustomColumnValue(undefined, { source: 'label', path: 'b' })).toBe('')
47+
})
48+
49+
it('coerces non-string values, but maps null/undefined to empty (not the literal "null")', () => {
50+
expect(readCustomColumnValue(res({ labels: { n: 5 as any } }), { source: 'label', path: 'n' })).toBe('5')
51+
expect(readCustomColumnValue(res({ labels: { n: null as any } }), { source: 'label', path: 'n' })).toBe('')
52+
})
53+
})
54+
55+
describe('sanitizeCustomColumnDefs', () => {
56+
it('returns [] for non-array input', () => {
57+
expect(sanitizeCustomColumnDefs(undefined)).toEqual([])
58+
expect(sanitizeCustomColumnDefs(null)).toEqual([])
59+
expect(sanitizeCustomColumnDefs({ source: 'label', path: 'x' })).toEqual([])
60+
expect(sanitizeCustomColumnDefs('label:x')).toEqual([])
61+
})
62+
63+
it('keeps well-formed defs', () => {
64+
const defs = [
65+
{ source: 'label', path: 'zone' },
66+
{ source: 'annotation', path: 'team' },
67+
]
68+
expect(sanitizeCustomColumnDefs(defs)).toEqual(defs)
69+
})
70+
71+
it('drops entries with invalid source, missing or blank path', () => {
72+
const raw = [
73+
{ source: 'label', path: 'good' },
74+
{ source: 'lable', path: 'typo-source' },
75+
{ source: 'label', path: '' },
76+
{ source: 'label', path: ' ' },
77+
{ source: 'annotation' },
78+
null,
79+
'label:x',
80+
]
81+
expect(sanitizeCustomColumnDefs(raw)).toEqual([{ source: 'label', path: 'good' }])
82+
})
83+
84+
it('drops a non-string path without throwing (predicate short-circuits before trim)', () => {
85+
const raw = [
86+
{ source: 'label', path: 42 },
87+
{ source: 'label', path: { nested: true } },
88+
{ source: 'annotation', path: 'ok' },
89+
]
90+
expect(() => sanitizeCustomColumnDefs(raw)).not.toThrow()
91+
expect(sanitizeCustomColumnDefs(raw)).toEqual([{ source: 'annotation', path: 'ok' }])
92+
})
93+
94+
it('trims paths so the load path matches the add path', () => {
95+
expect(sanitizeCustomColumnDefs([{ source: 'label', path: ' zone ' }]))
96+
.toEqual([{ source: 'label', path: 'zone' }])
97+
})
98+
99+
it('dedupes by key, keeping the first occurrence', () => {
100+
const raw = [
101+
{ source: 'label', path: 'zone' },
102+
{ source: 'label', path: 'zone' },
103+
{ source: 'label', path: ' zone ' },
104+
{ source: 'annotation', path: 'zone' },
105+
]
106+
expect(sanitizeCustomColumnDefs(raw)).toEqual([
107+
{ source: 'label', path: 'zone' },
108+
{ source: 'annotation', path: 'zone' },
109+
])
110+
})
111+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// User-defined table columns sourcing a metadata.labels[path] or
2+
// metadata.annotations[path] value. Persisted per-kind in localStorage and
3+
// materialized (in ResourcesView) into a self-contained ExtraColumn so they
4+
// ride the existing render/sort/filter override rails.
5+
6+
export type CustomColumnSource = 'label' | 'annotation'
7+
8+
export interface CustomColumnDef {
9+
source: CustomColumnSource
10+
path: string
11+
}
12+
13+
// Stable identity used as the React key, ExtraColumn.key, visibility-set
14+
// membership, and dedupe key. Built-in column keys never use a `:` prefix, so
15+
// `label:`/`annotation:` can't collide with them. Build-only — never parsed
16+
// back into source+path, so a colon inside `path` is harmless.
17+
export function customColumnKey(d: CustomColumnDef): string {
18+
return `${d.source}:${d.path}`
19+
}
20+
21+
export function readCustomColumnValue(resource: any, d: CustomColumnDef): string {
22+
const bag = d.source === 'label' ? resource?.metadata?.labels : resource?.metadata?.annotations
23+
const v = bag?.[d.path]
24+
return typeof v === 'string' ? v : v == null ? '' : String(v)
25+
}
26+
27+
// Drop anything that isn't a well-formed def — guards the localStorage load
28+
// boundary where a corrupted/hand-edited blob could otherwise be a non-array
29+
// (crashing a later .map) or carry empty/invalid entries (dead columns).
30+
// TypeScript's guarantees stop at JSON.parse, so this is the one place the
31+
// (source, non-empty path) invariant must be enforced at runtime. Paths are
32+
// trimmed and deduped by key so the load path produces the same defs the add
33+
// path would — a hand-edited blob can't yield a padded key or two columns
34+
// sharing one key.
35+
export function sanitizeCustomColumnDefs(raw: unknown): CustomColumnDef[] {
36+
if (!Array.isArray(raw)) return []
37+
const seen = new Set<string>()
38+
const out: CustomColumnDef[] = []
39+
for (const c of raw) {
40+
if (!c || (c.source !== 'label' && c.source !== 'annotation') || typeof c.path !== 'string') continue
41+
const def: CustomColumnDef = { source: c.source, path: c.path.trim() }
42+
if (def.path === '') continue
43+
const key = customColumnKey(def)
44+
if (seen.has(key)) continue
45+
seen.add(key)
46+
out.push(def)
47+
}
48+
return out
49+
}

0 commit comments

Comments
 (0)