diff --git a/docs/configuration.md b/docs/configuration.md index c399263c3..3612d7d41 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 8feaeaaf1..721d0ad48 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3095,6 +3095,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) } @@ -3110,8 +3111,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) { @@ -3121,6 +3122,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) @@ -3130,6 +3134,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 9d3f77b24..8158fe3f9 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"` // ActiveNamespaces maps kubeconfig context name → user-chosen namespace // override (the in-app namespace switcher's last selection per cluster). // Empty value (or missing key) means no override → fall back to the diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index 93a9fc15d..006c721db 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -1655,6 +1655,10 @@ 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 + /** 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 /** Columns prepended to KNOWN_COLUMNS for every kind. For example, a * multi-cluster host can inject a leading Cluster column. Each extra * column is self-contained (own render/sort/filter), so the host @@ -1772,6 +1776,8 @@ export function ResourcesView({ onSelectedKindChange, hideSidebar = false, onCreateResource, + defaultSort = null, + onSortChange, extraLeadingColumns, onRowSelect, }: ResourcesViewProps) { @@ -1789,8 +1795,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) @@ -2675,32 +2681,52 @@ 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 + + // 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 + 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 6d28dc2e2..80429f104 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, setDefaultSort } = useDefaultSort() + // Dock actions const openLogs = useOpenLogs() const openWorkloadLogs = useOpenWorkloadLogs() @@ -197,6 +201,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o onOpenWorkloadLogs={openWorkloadLogs} // Create resource 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 ( +
+
+ +

Applies immediately and when switching between resource types

+ +
+ + {defaultSort?.column && ( +
+ +
+ {(['asc', 'desc'] as const).map((dir) => ( + + ))} +
+
+ )} +
+ ) +} + // -- Startup Configuration Tab ------------------------------------------------ function StartupConfigTab({ diff --git a/web/src/hooks/useDefaultSort.ts b/web/src/hooks/useDefaultSort.ts new file mode 100644 index 000000000..fe376f865 --- /dev/null +++ b/web/src/hooks/useDefaultSort.ts @@ -0,0 +1,76 @@ +import { useState, useCallback, useEffect } from 'react' +import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config' + +export interface DefaultSort { + column: string // e.g. "age", "name", "status", "cpu", "memory" + direction: 'asc' | 'desc' +} + +const STORAGE_KEY = 'radar-default-sort' + +// Module-level subscribers so all hook instances stay in sync within the same tab +type Listener = (sort: DefaultSort | null) => void +const listeners = new Set() + +function loadDefaultSort(): DefaultSort | null { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) + if (parsed?.column && parsed?.direction) return parsed + } + } catch { + // ignore parse errors + } + return null +} + +function saveDefaultSort(sort: DefaultSort | null) { + try { + if (sort) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(sort)) + } else { + localStorage.removeItem(STORAGE_KEY) + } + } catch { + // ignore storage errors + } + fetch(apiUrl('/settings'), { + method: 'PUT', + credentials: getCredentialsMode(), + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify({ defaultSort: sort }), + }) + .then((res) => { 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) + + // Register this instance so other callers can push updates to it + useEffect(() => { + listeners.add(setDefaultSortState) + return () => { listeners.delete(setDefaultSortState) } + }, []) + + // 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) => { + saveDefaultSort(sort) + listeners.forEach(l => l(sort)) + }, []) + + return { defaultSort, setDefaultSort } +}