Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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)
}
Expand All @@ -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) {
Expand All @@ -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
}
Comment thread
cursor[bot] marked this conversation as resolved.
})
if err != nil {
log.Printf("[settings] Failed to save settings: %v", err)
Expand All @@ -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)
}
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"`
}

// mu serializes Load-mutate-Save cycles to prevent concurrent PUTs from
Expand Down
13 changes: 8 additions & 5 deletions packages/k8s-ui/src/components/resources/ResourcesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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<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 @@ -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) => {
Expand Down
6 changes: 6 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 } = useDefaultSort()

// Dock actions
const openLogs = useOpenLogs()
const openWorkloadLogs = useOpenWorkloadLogs()
Expand Down Expand Up @@ -183,6 +187,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
onOpenWorkloadLogs={openWorkloadLogs}
// Create resource
onCreateResource={handleCreateResource}
// Default sort preference
defaultSort={defaultSort}
/>
<CreateResourceDialog
open={createDialogOpen}
Expand Down
66 changes: 66 additions & 0 deletions web/src/hooks/useDefaultSort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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'

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<DefaultSort | null>(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 }
}
Loading