From 321647a2fcbb0f8fd442c3cc31e41dbc552b1c17 Mon Sep 17 00:00:00 2001 From: 7adityaraj <17121540+7adityaraj@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:21:54 +0300 Subject: [PATCH 1/3] feat(settings): add defaultSort user preference for resource table --- docs/configuration.md | 7 +- internal/server/server.go | 9 ++- internal/server/server_smoke_test.go | 65 ++++++++++++++++++ internal/settings/settings.go | 8 +++ .../components/resources/ResourcesView.tsx | 13 ++-- .../components/resources/ResourcesView.tsx | 6 ++ web/src/hooks/useDefaultSort.ts | 66 +++++++++++++++++++ 7 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 web/src/hooks/useDefaultSort.ts diff --git a/docs/configuration.md b/docs/configuration.md index 060496a2d..3808dbd57 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,7 +49,11 @@ User preferences for the UI. Managed via the Settings dialog or `PUT /api/settin "theme": "system", "pinnedKinds": [ { "name": "Deployments", "kind": "Deployment", "group": "" } - ] + ], + "defaultSort": { + "column": "age", + "direction": "desc" + } } ``` @@ -57,6 +61,7 @@ User preferences for the UI. Managed via the Settings dialog or `PUT /api/settin |-------|--------|-------------| | `theme` | `light`, `dark`, `system` | UI theme preference | | `pinnedKinds` | Array of `{name, kind, group}` | Resource kinds pinned to the sidebar | +| `defaultSort` | `{column, direction}` | Default sort applied when switching resource kinds. `column` is any sortable column (`name`, `age`, `status`, `cpu`, `memory`, etc.). `direction` is `asc` or `desc`. Omit to use built-in defaults. | ## Cluster Connection Precedence diff --git a/internal/server/server.go b/internal/server/server.go index 33509591e..692049be9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2739,6 +2739,7 @@ func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { // user_preferences. Audit stays because it's cluster-shared policy. loaded.Theme = "" loaded.PinnedKinds = nil + loaded.DefaultSort = nil } s.writeJSON(w, loaded) } @@ -2754,8 +2755,8 @@ func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) { // defense-in-depth check so a raw call that bypasses the intercept // doesn't silently succeed and cause a cluster-shared settings.json // to get mutated by one user. - if cloudMode() && (patch.Theme != "" || patch.PinnedKinds != nil) { - s.writeError(w, http.StatusBadRequest, "theme and pinnedKinds are managed by Radar Cloud; use /api/preferences instead") + if cloudMode() && (patch.Theme != "" || patch.PinnedKinds != nil || patch.DefaultSort != nil) { + s.writeError(w, http.StatusBadRequest, "theme, pinnedKinds, and defaultSort are managed by Radar Cloud; use /api/preferences instead") return } result, err := settings.Update(func(current *settings.Settings) { @@ -2765,6 +2766,9 @@ func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) { if patch.PinnedKinds != nil { current.PinnedKinds = patch.PinnedKinds } + if patch.DefaultSort != nil { + current.DefaultSort = patch.DefaultSort + } }) if err != nil { log.Printf("[settings] Failed to save settings: %v", err) @@ -2774,6 +2778,7 @@ func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) { if cloudMode() { result.Theme = "" result.PinnedKinds = nil + result.DefaultSort = nil } s.writeJSON(w, result) } diff --git a/internal/server/server_smoke_test.go b/internal/server/server_smoke_test.go index fb82a0f8f..bdd95e3ab 100644 --- a/internal/server/server_smoke_test.go +++ b/internal/server/server_smoke_test.go @@ -783,6 +783,7 @@ func TestSmokeCloudMode_SettingsGetStripsUserScoped(t *testing.T) { // Seed real values into the persisted store so we can prove they're // stripped at the HTTP boundary, not just missing from the file. put(t, "/api/settings", `{"theme":"dark","pinnedKinds":[{"name":"pods","kind":"Pod","group":""}]}`) + put(t, "/api/settings", `{"defaultSort":{"column":"age","direction":"desc"}}`) t.Setenv("RADAR_CLOUD_MODE", "true") @@ -794,6 +795,9 @@ func TestSmokeCloudMode_SettingsGetStripsUserScoped(t *testing.T) { if _, has := body["pinnedKinds"]; has && body["pinnedKinds"] != nil { t.Errorf("pinnedKinds leaked under cloud mode: %v", body["pinnedKinds"]) } + if _, has := body["defaultSort"]; has && body["defaultSort"] != nil { + t.Errorf("defaultSort leaked under cloud mode: %v", body["defaultSort"]) + } } // TestSmokeCloudMode_SettingsPutRejectsUserScoped: under RADAR_CLOUD_MODE, @@ -816,6 +820,67 @@ func TestSmokeCloudMode_SettingsPutRejectsUserScoped(t *testing.T) { if resp2.StatusCode != http.StatusBadRequest { t.Errorf("PUT with pinnedKinds under cloud mode got %d, want 400", resp2.StatusCode) } + + resp3 := put(t, "/api/settings", `{"defaultSort":{"column":"age","direction":"desc"}}`) + defer resp3.Body.Close() + if resp3.StatusCode != http.StatusBadRequest { + t.Errorf("PUT with defaultSort under cloud mode got %d, want 400", resp3.StatusCode) + } +} + +func TestSmokePutSettingsDefaultSort(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + payload := `{"defaultSort":{"column":"age","direction":"desc"}}` + resp := put(t, "/api/settings", payload) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var body map[string]any + json.NewDecoder(resp.Body).Decode(&body) + + ds, ok := body["defaultSort"].(map[string]any) + if !ok { + t.Fatalf("defaultSort missing or wrong type: %v", body["defaultSort"]) + } + if ds["column"] != "age" { + t.Errorf("defaultSort.column = %v, want age", ds["column"]) + } + if ds["direction"] != "desc" { + t.Errorf("defaultSort.direction = %v, want desc", ds["direction"]) + } + + // Verify it persists + var loaded map[string]any + assertOK(t, get(t, "/api/settings"), &loaded) + lds, _ := loaded["defaultSort"].(map[string]any) + if lds["column"] != "age" { + t.Errorf("persisted defaultSort.column = %v, want age", lds["column"]) + } +} + +func TestSmokePutSettingsDefaultSortPreservesOther(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + // Set theme first + put(t, "/api/settings", `{"theme":"dark"}`) + + // Now set defaultSort — theme should be preserved + resp := put(t, "/api/settings", `{"defaultSort":{"column":"name","direction":"asc"}}`) + defer resp.Body.Close() + + var body map[string]any + json.NewDecoder(resp.Body).Decode(&body) + if body["theme"] != "dark" { + t.Errorf("theme was overwritten: got %v", body["theme"]) + } + ds, _ := body["defaultSort"].(map[string]any) + if ds["column"] != "name" { + t.Errorf("defaultSort.column = %v, want name", ds["column"]) + } } // TestSmokeCloudMode_PprofNotMounted verifies that /debug/pprof/* is not diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 9c44838b6..13536eb07 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -28,11 +28,19 @@ func DefaultAuditConfig() AuditConfig { } } +// DefaultSort describes the user's preferred sort column and direction for +// the resource table. An empty struct means "use the built-in default". +type DefaultSort struct { + Column string `json:"column"` // e.g. "age", "name", "status" + Direction string `json:"direction"` // "asc" or "desc" +} + // Settings holds user preferences persisted across restarts. type Settings struct { Theme string `json:"theme,omitempty"` PinnedKinds []PinnedKind `json:"pinnedKinds,omitempty"` Audit *AuditConfig `json:"audit,omitempty"` + DefaultSort *DefaultSort `json:"defaultSort,omitempty"` } // mu serializes Load-mutate-Save cycles to prevent concurrent PUTs from diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index 3c70b3d43..3aad46b18 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -1628,6 +1628,8 @@ interface ResourcesViewProps { hideSidebar?: boolean /** Callback when the [+] create button is clicked. Receives the currently selected kind info. */ onCreateResource?: (kind: { name: string; kind: string; group: string } | null) => void + /** User-preferred default sort column and direction. Applied when switching resource kinds instead of resetting to null. */ + defaultSort?: { column: string; direction: 'asc' | 'desc' } | null } // Default selected kind @@ -1700,6 +1702,7 @@ export function ResourcesView({ onSelectedKindChange, hideSidebar = false, onCreateResource, + defaultSort = null, }: ResourcesViewProps) { const location = useMemo(() => ({ search: locationSearch, pathname: locationPathname }), [locationSearch, locationPathname]) const initialFilters = getInitialFiltersFromURL() @@ -1716,8 +1719,8 @@ export function ResourcesView({ onSelectedKindChange?.(selectedKind) }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps const [searchTerm, setSearchTerm] = useState(initialFilters.search) - const [sortColumn, setSortColumn] = useState(null) - const [sortDirection, setSortDirection] = useState(null) + const [sortColumn, setSortColumn] = useState(defaultSort?.column ?? null) + const [sortDirection, setSortDirection] = useState(defaultSort?.direction ?? null) const [lastUpdated, setLastUpdated] = useState(null) // Filter state const [columnFilters, setColumnFilters] = useState>(initialFilters.columnFilters) @@ -2559,14 +2562,14 @@ export function ResourcesView({ return } prevKindRef.current = selectedKind.name - setSortColumn(null) - setSortDirection(null) + setSortColumn(defaultSort?.column ?? null) + setSortDirection(defaultSort?.direction ?? null) setOpenColumnFilter(null) if (!isSyncingFromURL.current) { setColumnFilters({}) } setProblemFilters([]) - }, [selectedKind.name]) + }, [selectedKind.name]) // eslint-disable-line react-hooks/exhaustive-deps // Toggle sort for a column const handleSort = useCallback((column: string) => { diff --git a/web/src/components/resources/ResourcesView.tsx b/web/src/components/resources/ResourcesView.tsx index 88ce5c302..7ce480acf 100644 --- a/web/src/components/resources/ResourcesView.tsx +++ b/web/src/components/resources/ResourcesView.tsx @@ -6,6 +6,7 @@ import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../a import { useAPIResources } from '../../api/apiResources' import { initNavigationMap } from '@skyhook-io/k8s-ui' import { usePinnedKinds } from '../../hooks/useFavorites' +import { useDefaultSort } from '../../hooks/useDefaultSort' import { useOpenLogs, useOpenWorkloadLogs } from '../dock' import { ResourcesView as BaseResourcesView, @@ -116,6 +117,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o // Pinned kinds const { pinned, togglePin, isPinned } = usePinnedKinds() + // Default sort preference + const { defaultSort } = useDefaultSort() + // Dock actions const openLogs = useOpenLogs() const openWorkloadLogs = useOpenWorkloadLogs() @@ -183,6 +187,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o onOpenWorkloadLogs={openWorkloadLogs} // Create resource onCreateResource={handleCreateResource} + // Default sort preference + defaultSort={defaultSort} /> { if (!res.ok) console.warn('[settings] Failed to persist defaultSort:', res.status) }) + .catch((err) => console.warn('[settings] Failed to persist defaultSort:', err)) +} + +export function useDefaultSort() { + const [defaultSort, setDefaultSortState] = useState(loadDefaultSort) + + // Sync from server (persisted settings survive port changes in desktop app) + useEffect(() => { + fetch(apiUrl('/settings'), { credentials: getCredentialsMode(), headers: getAuthHeaders() }) + .then((res) => res.ok ? res.json() : null) + .then((data) => { + if (data?.defaultSort?.column && data?.defaultSort?.direction && !loadDefaultSort()) { + setDefaultSortState(data.defaultSort) + localStorage.setItem(STORAGE_KEY, JSON.stringify(data.defaultSort)) + } + }) + .catch((err) => console.warn('[settings] Failed to load defaultSort from server:', err)) + }, []) + + const setDefaultSort = useCallback((sort: DefaultSort | null) => { + setDefaultSortState(sort) + saveDefaultSort(sort) + }, []) + + return { defaultSort, setDefaultSort } +} From 1d31a956a51c562f0d088258b4f45b08ae00a10e Mon Sep 17 00:00:00 2001 From: 7adityaraj <17121540+7adityaraj@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:33:02 +0300 Subject: [PATCH 2/3] feat(settings): persist manual sort and expose default sort in UI --- .../components/resources/ResourcesView.tsx | 26 ++++-- web/e2e/settings.spec.ts | 30 +++++++ .../components/resources/ResourcesView.tsx | 3 +- .../components/settings/SettingsDialog.tsx | 80 +++++++++++++++++++ 4 files changed, 131 insertions(+), 8 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index 3aad46b18..ec21952f9 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -1630,6 +1630,8 @@ interface ResourcesViewProps { onCreateResource?: (kind: { name: string; kind: string; group: string } | null) => void /** User-preferred default sort column and direction. Applied when switching resource kinds instead of resetting to null. */ defaultSort?: { column: string; direction: 'asc' | 'desc' } | null + /** Called when the user manually changes the sort, so callers can persist it as the new default. */ + onSortChange?: (sort: { column: string; direction: 'asc' | 'desc' } | null) => void } // Default selected kind @@ -1703,6 +1705,7 @@ export function ResourcesView({ hideSidebar = false, onCreateResource, defaultSort = null, + onSortChange, }: ResourcesViewProps) { const location = useMemo(() => ({ search: locationSearch, pathname: locationPathname }), [locationSearch, locationPathname]) const initialFilters = getInitialFiltersFromURL() @@ -2573,21 +2576,30 @@ export function ResourcesView({ // Toggle sort for a column const handleSort = useCallback((column: string) => { + let newColumn: string | null + let newDirection: SortDirection + if (sortColumn === column) { // Cycle: asc -> desc -> null if (sortDirection === 'asc') { - setSortDirection('desc') + newColumn = column + newDirection = 'desc' } else if (sortDirection === 'desc') { - setSortColumn(null) - setSortDirection(null) + newColumn = null + newDirection = null } else { - setSortDirection('asc') + newColumn = column + newDirection = 'asc' } } else { - setSortColumn(column) - setSortDirection('asc') + newColumn = column + newDirection = 'asc' } - }, [sortColumn, sortDirection]) + + setSortColumn(newColumn) + setSortDirection(newDirection) + onSortChange?.(newColumn && newDirection ? { column: newColumn, direction: newDirection } : null) + }, [sortColumn, sortDirection, onSortChange]) // Get sortable value from a resource for a given column const getSortValue = useCallback((resource: any, column: string, kind?: string): string | number => { diff --git a/web/e2e/settings.spec.ts b/web/e2e/settings.spec.ts index 0139083a7..0ba692edd 100644 --- a/web/e2e/settings.spec.ts +++ b/web/e2e/settings.spec.ts @@ -116,4 +116,34 @@ test.describe('Settings API', () => { // Clean up await request.put('/api/settings', { data: { theme: 'system' } }) }) + + test('PUT /api/settings with defaultSort persists correctly', async ({ request }) => { + const putRes = await request.put('/api/settings', { + data: { defaultSort: { column: 'name', direction: 'asc' } }, + }) + expect(putRes.ok()).toBeTruthy() + const body = await putRes.json() + expect(body.defaultSort?.column).toBe('name') + expect(body.defaultSort?.direction).toBe('asc') + + // Read back + const getRes = await request.get('/api/settings') + const getBody = await getRes.json() + expect(getBody.defaultSort?.column).toBe('name') + expect(getBody.defaultSort?.direction).toBe('asc') + + // Clean up + await request.put('/api/settings', { data: { defaultSort: null } }) + }) +}) + +test.describe('Settings dialog — user preferences', () => { + test('default sort section is visible in settings dialog', async ({ page }) => { + await page.goto('/') + await page.waitForSelector('header', { timeout: 10000 }) + + await page.locator('button[title="Settings"]').click() + await expect(page.getByText('User Preferences')).toBeVisible() + await expect(page.getByText('Default Sort Column')).toBeVisible() + }) }) diff --git a/web/src/components/resources/ResourcesView.tsx b/web/src/components/resources/ResourcesView.tsx index 7ce480acf..43fd8f484 100644 --- a/web/src/components/resources/ResourcesView.tsx +++ b/web/src/components/resources/ResourcesView.tsx @@ -118,7 +118,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o const { pinned, togglePin, isPinned } = usePinnedKinds() // Default sort preference - const { defaultSort } = useDefaultSort() + const { defaultSort, setDefaultSort } = useDefaultSort() // Dock actions const openLogs = useOpenLogs() @@ -189,6 +189,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o onCreateResource={handleCreateResource} // Default sort preference defaultSort={defaultSort} + onSortChange={setDefaultSort} /> (null) const [configDirty, setConfigDirty] = useState(false) const [loadError, setLoadError] = useState(null) + const { defaultSort, setDefaultSort } = useDefaultSort() // Load config on open useEffect(() => { @@ -173,6 +175,12 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { isDesktop={isDesktop} onChange={updateConfigField} /> + +
+

User Preferences

+

Saved instantly. Also updated automatically when you sort a resource table.

+ +
{/* Footer */} @@ -211,6 +219,78 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { ) } +// -- Default Sort Section ----------------------------------------------------- + +const SORT_COLUMNS = [ + { value: '', label: 'None (table default)' }, + { value: 'name', label: 'Name' }, + { value: 'namespace', label: 'Namespace' }, + { value: 'age', label: 'Age' }, + { value: 'status', label: 'Status' }, +] + +function DefaultSortSection({ + defaultSort, + onDefaultSortChange, +}: { + defaultSort: { column: string; direction: 'asc' | 'desc' } | null + onDefaultSortChange: (sort: { column: string; direction: 'asc' | 'desc' } | null) => void +}) { + const handleColumnChange = (column: string) => { + if (!column) { + onDefaultSortChange(null) + } else { + onDefaultSortChange({ column, direction: defaultSort?.direction ?? 'asc' }) + } + } + + const handleDirectionChange = (direction: 'asc' | 'desc') => { + if (defaultSort?.column) { + onDefaultSortChange({ column: defaultSort.column, direction }) + } + } + + return ( +
+
+ +

Applied when switching between resource types

+ +
+ + {defaultSort?.column && ( +
+ +
+ {(['asc', 'desc'] as const).map((dir) => ( + + ))} +
+
+ )} +
+ ) +} + // -- Startup Configuration Tab ------------------------------------------------ function StartupConfigTab({ From 57da7f34d60d58c0db6141dece18c0373f8c149a Mon Sep 17 00:00:00 2001 From: 7adityaraj <17121540+7adityaraj@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:56:56 +0300 Subject: [PATCH 3/3] feat(settings): apply default sort immediately without page refresh --- .../src/components/resources/ResourcesView.tsx | 11 +++++++++++ web/src/components/settings/SettingsDialog.tsx | 2 +- web/src/hooks/useDefaultSort.ts | 12 +++++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index ec21952f9..7933ca816 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -2574,6 +2574,17 @@ export function ResourcesView({ setProblemFilters([]) }, [selectedKind.name]) // eslint-disable-line react-hooks/exhaustive-deps + // Apply defaultSort changes immediately when the user updates the preference in settings + const isDefaultSortMounted = useRef(false) + useEffect(() => { + if (!isDefaultSortMounted.current) { + isDefaultSortMounted.current = true + return + } + setSortColumn(defaultSort?.column ?? null) + setSortDirection(defaultSort?.direction ?? null) + }, [defaultSort]) + // Toggle sort for a column const handleSort = useCallback((column: string) => { let newColumn: string | null diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx index 76b7c8fef..e5507c3b2 100644 --- a/web/src/components/settings/SettingsDialog.tsx +++ b/web/src/components/settings/SettingsDialog.tsx @@ -254,7 +254,7 @@ function DefaultSortSection({
-

Applied when switching between resource types

+

Applies immediately and when switching between resource types