Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,19 @@ 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"
}
}
```

| Field | Values | Description |
|-------|--------|-------------|
| `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

Expand Down
9 changes: 7 additions & 2 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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) {
Expand All @@ -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
}
Comment thread
cursor[bot] marked this conversation as resolved.
})
if err != nil {
log.Printf("[settings] Failed to save settings: %v", err)
Expand All @@ -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)
}
Expand Down
65 changes: 65 additions & 0 deletions internal/server/server_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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,
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions internal/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 38 additions & 12 deletions packages/k8s-ui/src/components/resources/ResourcesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1772,6 +1776,8 @@ export function ResourcesView({
onSelectedKindChange,
hideSidebar = false,
onCreateResource,
defaultSort = null,
onSortChange,
extraLeadingColumns,
onRowSelect,
}: ResourcesViewProps) {
Expand All @@ -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<string | null>(null)
const [sortDirection, setSortDirection] = useState<SortDirection>(null)
const [sortColumn, setSortColumn] = useState<string | null>(defaultSort?.column ?? null)
const [sortDirection, setSortDirection] = useState<SortDirection>(defaultSort?.direction ?? null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
// Filter state
const [columnFilters, setColumnFilters] = useState<Record<string, string[]>>(initialFilters.columnFilters)
Expand Down Expand Up @@ -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 => {
Expand Down
30 changes: 30 additions & 0 deletions web/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
7 changes: 7 additions & 0 deletions web/src/components/resources/ResourcesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -197,6 +201,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
onOpenWorkloadLogs={openWorkloadLogs}
// Create resource
onCreateResource={handleCreateResource}
// Default sort preference
defaultSort={defaultSort}
onSortChange={setDefaultSort}
/>
<CreateResourceDialog
open={createDialogOpen}
Expand Down
Loading
Loading