@skyhook-io/radar-app 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,7 +35,7 @@
35
35
  "react-markdown": "^10.1.0",
36
36
  "react-virtuoso": "^4.18.7",
37
37
  "remark-gfm": "^4.0.1",
38
- "shiki": "^4.0.1",
38
+ "shiki": "^4.2.0",
39
39
  "yaml": "^2.9.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -53,12 +53,11 @@
53
53
  "devDependencies": {
54
54
  "@playwright/test": "^1.59.1",
55
55
  "@skyhook-io/k8s-ui": "*",
56
- "@tailwindcss/typography": "^0.5.19",
56
+ "@tailwindcss/typography": "^0.5.20",
57
57
  "@tailwindcss/vite": "^4.3.0",
58
58
  "@tanstack/react-query": "^5.100.14",
59
- "@types/diff": "^8.0.0",
60
59
  "@types/node": "^25.7.0",
61
- "@types/react": "^19.2.14",
60
+ "@types/react": "^19.2.17",
62
61
  "@types/react-dom": "^19.2.3",
63
62
  "@vitejs/plugin-react": "^6.0.2",
64
63
  "@xyflow/react": "^12.10.2",
@@ -67,9 +66,9 @@
67
66
  "lucide-react": "^1.16.0",
68
67
  "postcss": "^8.5.14",
69
68
  "prettier": "^3.8.1",
70
- "react": "^19.2.6",
71
- "react-dom": "^19.2.6",
72
- "react-router-dom": "^7.15.0",
69
+ "react": "^19.2.7",
70
+ "react-dom": "^19.2.7",
71
+ "react-router-dom": "^7.17.0",
73
72
  "tailwind-merge": "^3.6.0",
74
73
  "tailwindcss": "^4.2.4",
75
74
  "typescript": "^6.0.2",
package/src/api/client.ts CHANGED
@@ -717,11 +717,10 @@ export function useCapabilities() {
717
717
  })
718
718
  }
719
719
 
720
- // Namespace-scoped capabilities: lazy re-check for exec/logs/portForward when
721
- // global RBAC checks denied them. Users with namespace-scoped RoleBindings may
720
+ // Namespace-scoped capabilities. Users with namespace-scoped RoleBindings may
722
721
  // have these permissions in specific namespaces.
723
- export function useNamespaceCapabilities(namespace: string | undefined, globalCaps: Capabilities) {
724
- const needsCheck = namespace && (!globalCaps.exec || !globalCaps.logs || !globalCaps.portForward)
722
+ export function useNamespaceCapabilities(namespace: string | undefined, globalCaps: Capabilities | undefined) {
723
+ const needsCheck = namespace && globalCaps
725
724
  return useQuery<Capabilities>({
726
725
  queryKey: ['capabilities', namespace],
727
726
  queryFn: () => fetchJSON(`/capabilities?namespace=${encodeURIComponent(namespace!)}`),
@@ -1782,6 +1781,123 @@ export function useBulkDeleteResources() {
1782
1781
  })
1783
1782
  }
1784
1783
 
1784
+ interface BulkWorkloadItem {
1785
+ kind: string
1786
+ namespace: string
1787
+ name: string
1788
+ }
1789
+
1790
+ interface BulkWorkloadMutationResult {
1791
+ requested: number
1792
+ succeeded: number
1793
+ failedMessages: string[]
1794
+ }
1795
+
1796
+ function failedBulkWorkloadMessages(results: PromiseSettledResult<unknown>[]): string[] {
1797
+ return results.flatMap(r => r.status === 'rejected'
1798
+ ? [r.reason instanceof Error ? r.reason.message : String(r.reason)]
1799
+ : []
1800
+ )
1801
+ }
1802
+
1803
+ function bulkWorkloadFailureMessage(action: string, failed: number, total: number, messages: string[]): string {
1804
+ return `Failed to ${action} ${failed} of ${total} workloads:\n${messages.join('\n')}`
1805
+ }
1806
+
1807
+ export function useBulkRestartWorkloads() {
1808
+ const queryClient = useQueryClient()
1809
+
1810
+ return useMutation({
1811
+ mutationFn: async ({ items }: { items: BulkWorkloadItem[] }): Promise<BulkWorkloadMutationResult> => {
1812
+ if (items.length === 0) {
1813
+ return { requested: 0, succeeded: 0, failedMessages: [] }
1814
+ }
1815
+ const results = await Promise.allSettled(
1816
+ items.map(async ({ kind, namespace, name }) => {
1817
+ const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`, {
1818
+ method: 'POST',
1819
+ })
1820
+ if (!response.ok) {
1821
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
1822
+ throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
1823
+ }
1824
+ return { kind, namespace, name }
1825
+ })
1826
+ )
1827
+ const failedMessages = failedBulkWorkloadMessages(results)
1828
+ if (failedMessages.length === items.length) {
1829
+ throw new Error(bulkWorkloadFailureMessage('restart', failedMessages.length, items.length, failedMessages))
1830
+ }
1831
+ return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
1832
+ },
1833
+ meta: {
1834
+ errorMessage: 'Failed to restart some workloads',
1835
+ },
1836
+ onSuccess: (result) => {
1837
+ if (result.failedMessages.length > 0) {
1838
+ showApiError(
1839
+ `Restarted ${result.succeeded} of ${result.requested} workloads`,
1840
+ result.failedMessages.join('\n'),
1841
+ )
1842
+ } else {
1843
+ showApiSuccess('Workloads restarting')
1844
+ }
1845
+ },
1846
+ onSettled: () => {
1847
+ queryClient.invalidateQueries({ queryKey: ['resources'] })
1848
+ queryClient.invalidateQueries({ queryKey: ['topology'] })
1849
+ },
1850
+ })
1851
+ }
1852
+
1853
+ export function useBulkScaleWorkloads() {
1854
+ const queryClient = useQueryClient()
1855
+
1856
+ return useMutation({
1857
+ mutationFn: async ({ items, replicas }: { items: BulkWorkloadItem[]; replicas: number }): Promise<BulkWorkloadMutationResult> => {
1858
+ if (items.length === 0) {
1859
+ return { requested: 0, succeeded: 0, failedMessages: [] }
1860
+ }
1861
+ const results = await Promise.allSettled(
1862
+ items.map(async ({ kind, namespace, name }) => {
1863
+ const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`, {
1864
+ method: 'POST',
1865
+ headers: { 'Content-Type': 'application/json' },
1866
+ body: JSON.stringify({ replicas }),
1867
+ })
1868
+ if (!response.ok) {
1869
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
1870
+ throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
1871
+ }
1872
+ return { kind, namespace, name }
1873
+ })
1874
+ )
1875
+ const failedMessages = failedBulkWorkloadMessages(results)
1876
+ if (failedMessages.length === items.length) {
1877
+ throw new Error(bulkWorkloadFailureMessage('scale', failedMessages.length, items.length, failedMessages))
1878
+ }
1879
+ return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
1880
+ },
1881
+ meta: {
1882
+ errorMessage: 'Failed to scale some workloads',
1883
+ },
1884
+ onSuccess: (result) => {
1885
+ if (result.failedMessages.length > 0) {
1886
+ showApiError(
1887
+ `Scaled ${result.succeeded} of ${result.requested} workloads`,
1888
+ result.failedMessages.join('\n'),
1889
+ )
1890
+ } else {
1891
+ showApiSuccess('Workloads scaled')
1892
+ }
1893
+ },
1894
+ onSettled: () => {
1895
+ queryClient.invalidateQueries({ queryKey: ['resources'] })
1896
+ queryClient.invalidateQueries({ queryKey: ['topology'] })
1897
+ },
1898
+ })
1899
+ }
1900
+
1785
1901
  // Apply (create or update) a resource from YAML
1786
1902
  export interface ApplyResourceResult {
1787
1903
  name: string
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect, useMemo } from 'react'
2
- import { X, Plus, Trash2 } from 'lucide-react'
2
+ import { X, Plus, Trash2, Lock } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
- import { useAuditSettings, useUpdateAuditSettings, useAudit } from '../../api/client'
4
+ import { useAuditSettings, useUpdateAuditSettings, useAudit, useCloudRole } from '../../api/client'
5
5
  import type { CheckMeta } from '@skyhook-io/k8s-ui'
6
6
  import { validateRFC1123Label, type ValidationResult } from '@skyhook-io/k8s-ui/utils/validators'
7
7
 
@@ -14,6 +14,11 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
14
14
  const { data: settings } = useAuditSettings()
15
15
  const { data: auditData } = useAudit(namespaces)
16
16
  const updateSettings = useUpdateAuditSettings()
17
+ // Audit policy is cluster-shared, so writes are owner-gated (enforced
18
+ // server-side too). Non-owners get a read-only view. Non-Cloud callers
19
+ // have no role and pass.
20
+ const { canAtLeast } = useCloudRole()
21
+ const canEdit = canAtLeast('owner')
17
22
  const [ignoredNs, setIgnoredNs] = useState<string[]>([])
18
23
  const [disabledChecks, setDisabledChecks] = useState<string[]>([])
19
24
  const [newNs, setNewNs] = useState('')
@@ -75,6 +80,15 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
75
80
  </div>
76
81
 
77
82
  <div className="px-5 py-4 overflow-y-auto flex-1">
83
+ {!canEdit && (
84
+ <div className="mb-4 rounded-lg border border-theme-border bg-theme-elevated/50 p-3 flex items-start gap-2.5">
85
+ <Lock className="w-3.5 h-3.5 mt-0.5 shrink-0 text-theme-text-tertiary" />
86
+ <p className="text-xs text-theme-text-tertiary">
87
+ Audit policy is shared across everyone using this Radar instance, so editing
88
+ is limited to owners. You can review the current settings here.
89
+ </p>
90
+ </div>
91
+ )}
78
92
  {/* Ignored Namespaces */}
79
93
  <div className="mb-6">
80
94
  <label className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider">
@@ -90,7 +104,8 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
90
104
  <span className="text-sm text-theme-text-primary">{ns}</span>
91
105
  <button
92
106
  onClick={() => setIgnoredNs(ignoredNs.filter(n => n !== ns))}
93
- className="p-1 rounded hover:bg-theme-hover text-theme-text-tertiary hover:text-red-400 transition-colors"
107
+ disabled={!canEdit}
108
+ className="p-1 rounded hover:bg-theme-hover text-theme-text-tertiary hover:text-red-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-theme-text-tertiary"
94
109
  >
95
110
  <Trash2 className="w-3.5 h-3.5" />
96
111
  </button>
@@ -108,6 +123,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
108
123
  onChange={e => setNewNs(e.target.value)}
109
124
  onKeyDown={e => { if (e.key === 'Enter') addNamespace() }}
110
125
  placeholder="Add namespace..."
126
+ disabled={!canEdit}
111
127
  aria-invalid={newNsError ? true : undefined}
112
128
  aria-describedby="new-ns-help"
113
129
  className={clsx(
@@ -119,7 +135,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
119
135
  />
120
136
  <button
121
137
  onClick={addNamespace}
122
- disabled={!canAddNamespace}
138
+ disabled={!canEdit || !canAddNamespace}
123
139
  className="px-3 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
124
140
  >
125
141
  <Plus className="w-4 h-4" />
@@ -155,7 +171,8 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
155
171
  type="checkbox"
156
172
  checked={!disabled}
157
173
  onChange={() => toggleCheck(check.id)}
158
- className="w-4 h-4 rounded border-theme-border text-skyhook-500 focus:ring-skyhook-500"
174
+ disabled={!canEdit}
175
+ className="w-4 h-4 rounded border-theme-border text-skyhook-500 focus:ring-skyhook-500 disabled:opacity-40 disabled:cursor-not-allowed"
159
176
  />
160
177
  <div className="flex-1 min-w-0">
161
178
  <span className="text-sm text-theme-text-primary">{check.title}</span>
@@ -181,14 +198,16 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
181
198
  // text — otherwise the user clicks Save expecting their
182
199
  // entry to be included and it's silently dropped.
183
200
  disabled={
184
- updateSettings.isPending || newNsError !== null || newNsDuplicate
201
+ !canEdit || updateSettings.isPending || newNsError !== null || newNsDuplicate
185
202
  }
186
203
  title={
187
- newNsError
188
- ? 'Fix or clear the pending namespace input before saving'
189
- : newNsDuplicate
190
- ? 'Clear the duplicate pending input before saving'
191
- : undefined
204
+ !canEdit
205
+ ? 'Audit settings can only be changed by owners'
206
+ : newNsError
207
+ ? 'Fix or clear the pending namespace input before saving'
208
+ : newNsDuplicate
209
+ ? 'Clear the duplicate pending input before saving'
210
+ : undefined
192
211
  }
193
212
  className="px-4 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
194
213
  >
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback } from 'react'
2
- import { useAudit, useAuditSettings, useUpdateAuditSettings } from '../../api/client'
2
+ import { useAudit, useAuditSettings, useUpdateAuditSettings, useCloudRole } from '../../api/client'
3
3
  import type { SelectedResource } from '../../types'
4
4
  import { ChecksView, PaneLoader, type CheckResourceRef } from '@skyhook-io/k8s-ui'
5
5
  import { ArrowLeft, ClipboardCheck, Settings } from 'lucide-react'
@@ -21,6 +21,11 @@ export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditVie
21
21
  const { data, isLoading, error } = useAudit(namespaces)
22
22
  const { data: auditSettings } = useAuditSettings()
23
23
  const updateSettings = useUpdateAuditSettings()
24
+ // Audit policy is owner-gated (enforced server-side). Withhold the inline
25
+ // hide affordances from non-owners so they don't click into a 403 — the
26
+ // hide menus render only when these callbacks are passed.
27
+ const { canAtLeast } = useCloudRole()
28
+ const canEdit = canAtLeast('owner')
24
29
  const [showSettings, setShowSettings] = useState(false)
25
30
 
26
31
  const ignoredCount = auditSettings?.ignoredNamespaces?.length ?? 0
@@ -105,8 +110,8 @@ export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditVie
105
110
  catalog={data.checks ?? {}}
106
111
  anyData
107
112
  onResourceClick={onResourceClick}
108
- onHideCheck={hideCheck}
109
- onHideCategory={hideCategory}
113
+ onHideCheck={canEdit ? hideCheck : undefined}
114
+ onHideCategory={canEdit ? hideCategory : undefined}
110
115
  />
111
116
 
112
117
  {showSettings && <AuditSettingsDialog namespaces={namespaces} onClose={() => setShowSettings(false)} />}
@@ -1,17 +1,20 @@
1
1
  import { useState, useMemo, useCallback, useEffect } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
- import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources } from '../../api/client'
4
+ import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads } from '../../api/client'
5
5
  import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
6
6
  import { useAPIResources } from '../../api/apiResources'
7
7
  import { initNavigationMap } from '@skyhook-io/k8s-ui'
8
8
  import { usePinnedKinds } from '../../hooks/useFavorites'
9
9
  import { useOpenLogs, useOpenWorkloadLogs } from '../dock'
10
10
  import {
11
+ canBulkRestartKind,
12
+ canBulkScaleKind,
11
13
  ResourcesView as BaseResourcesView,
12
14
  CORE_RESOURCES,
15
+ intersectWorkloadWrites,
13
16
  } from '@skyhook-io/k8s-ui'
14
- import type { ResourceQueryResult } from '@skyhook-io/k8s-ui'
17
+ import type { Capabilities, ResourceQueryResult, WorkloadWritePermissions } from '@skyhook-io/k8s-ui'
15
18
  import type { SelectedResource } from '../../types'
16
19
  import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
17
20
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
@@ -31,10 +34,45 @@ interface ResourcesViewProps {
31
34
  onClearNamespaces?: () => void
32
35
  }
33
36
 
37
+ type SelectedKindInfo = { name: string; kind: string; group: string } | null
38
+
39
+ const deniedWorkloadWrites: WorkloadWritePermissions = {
40
+ deployments: false,
41
+ daemonSets: false,
42
+ statefulSets: false,
43
+ rollouts: false,
44
+ }
45
+
34
46
  export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) {
35
47
  const location = useLocation()
36
48
  const navigate = useNavigate()
37
49
 
50
+ const { data: capabilities } = useCapabilities()
51
+ const namespaceForCapabilities = namespaces.length === 1 ? namespaces[0] : undefined
52
+ const { data: namespaceCapabilities } = useNamespaceCapabilities(namespaceForCapabilities, capabilities)
53
+ const namespaceCapabilityNames = useMemo(() => namespaces.length > 1 ? [...namespaces].sort() : [], [namespaces])
54
+ const { data: namespaceCapabilitiesList } = useQuery<Array<Pick<Capabilities, 'workloadWrites'>>>({
55
+ queryKey: ['capabilities', 'namespaces', namespaceCapabilityNames],
56
+ queryFn: async () => {
57
+ const results = await Promise.allSettled(
58
+ namespaceCapabilityNames.map(async ns => ({
59
+ namespace: ns,
60
+ capabilities: await fetchJSON<Capabilities>(`/capabilities?namespace=${encodeURIComponent(ns)}`),
61
+ }))
62
+ )
63
+ return results.map((result, index) => {
64
+ if (result.status === 'fulfilled') {
65
+ return { workloadWrites: result.value.capabilities.workloadWrites }
66
+ }
67
+ console.warn(`Failed to fetch namespace capabilities for ${namespaceCapabilityNames[index]}, withholding workload writes:`, result.reason)
68
+ return { workloadWrites: deniedWorkloadWrites }
69
+ })
70
+ },
71
+ enabled: namespaceCapabilityNames.length > 1 && capabilities != null,
72
+ staleTime: 60000,
73
+ })
74
+ const multiNamespaceWorkloadWrites = useMemo(() => intersectWorkloadWrites(namespaceCapabilitiesList), [namespaceCapabilitiesList])
75
+
38
76
  // API resources discovery
39
77
  const { data: apiResources } = useAPIResources()
40
78
 
@@ -44,7 +82,14 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
44
82
  }, [apiResources])
45
83
 
46
84
  // Track the selected kind from the k8s-ui component
47
- const [selectedKind, setSelectedKind] = useState<{ name: string; kind: string; group: string } | null>(null)
85
+ const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(null)
86
+ const workloadWrites = namespaces.length === 0
87
+ ? capabilities?.workloadWrites
88
+ : namespaces.length === 1
89
+ ? namespaceCapabilities?.workloadWrites
90
+ : multiNamespaceWorkloadWrites
91
+ const canBulkRestartSelectedKind = useMemo(() => canBulkRestartKind(selectedKind, workloadWrites), [selectedKind, workloadWrites])
92
+ const canBulkScaleSelectedKind = useMemo(() => canBulkScaleKind(selectedKind, workloadWrites), [selectedKind, workloadWrites])
48
93
 
49
94
  // Lightweight resource counts for sidebar badges (~2KB instead of ~608MB)
50
95
  const namespacesParam = namespaces.join(',')
@@ -148,6 +193,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
148
193
 
149
194
  // Bulk delete
150
195
  const bulkDeleteMutation = useBulkDeleteResources()
196
+ const bulkRestartMutation = useBulkRestartWorkloads()
197
+ const bulkScaleMutation = useBulkScaleWorkloads()
151
198
 
152
199
  // Navigation adapter. k8s-ui constructs paths from `basePath` (which
153
200
  // includes the router basename so they line up with window.location.pathname
@@ -230,6 +277,10 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
230
277
  // Bulk operations
231
278
  onBulkDelete={(items, options) => bulkDeleteMutation.mutate({ items, force: options?.force }, { onSuccess: options?.onSuccess })}
232
279
  isBulkDeleting={bulkDeleteMutation.isPending}
280
+ onBulkRestart={canBulkRestartSelectedKind ? (items, options) => bulkRestartMutation.mutate({ items }, { onSuccess: options?.onSuccess }) : undefined}
281
+ isBulkRestarting={canBulkRestartSelectedKind && bulkRestartMutation.isPending}
282
+ onBulkScale={canBulkScaleSelectedKind ? (items, replicas, options) => bulkScaleMutation.mutate({ items, replicas }, { onSuccess: options?.onSuccess }) : undefined}
283
+ isBulkScaling={canBulkScaleSelectedKind && bulkScaleMutation.isPending}
233
284
  />
234
285
  <CreateResourceDialog
235
286
  open={createDialogOpen}
@@ -1,10 +1,13 @@
1
- import { useState, useEffect, useRef, useCallback } from 'react'
1
+ import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { Settings, X, RotateCcw, Loader2, Copy, Check, Pin, Shield } from 'lucide-react'
3
+ import { Settings, X, RotateCcw, Loader2, Copy, Check, Pin, Shield, Lock } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
5
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
6
6
  import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
7
7
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
8
+ import { useCloudRole } from '../../api/client'
9
+ import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
10
+ import type { DeploymentMode } from '../../types'
8
11
 
9
12
  interface Config {
10
13
  kubeconfig?: string
@@ -12,6 +15,7 @@ interface Config {
12
15
  namespace?: string
13
16
  port?: number
14
17
  noBrowser?: boolean
18
+ browser?: string
15
19
  timelineStorage?: 'memory' | 'sqlite'
16
20
  timelineDbPath?: string
17
21
  historyLimit?: number
@@ -34,6 +38,14 @@ interface SettingsDialogProps {
34
38
  export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsDialogProps) {
35
39
  const dialogRef = useRef<HTMLDivElement>(null)
36
40
  const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
41
+ // Radar configuration (kubeconfig, port, integrations…) is host-level and
42
+ // affects every user of this instance, so it's gated to owners. Personal
43
+ // sections (My permissions) stay visible to everyone. Non-Cloud callers
44
+ // (OSS, OIDC, kubectl plugin) have no role and pass — single-user laptops
45
+ // are never locked out of their own config. Backend enforces this too.
46
+ const { canAtLeast } = useCloudRole()
47
+ const capabilities = useCapabilitiesContext()
48
+ const canEditConfig = canAtLeast('owner')
37
49
  const [configData, setConfigData] = useState<ConfigResponse | null>(null)
38
50
  const [editedConfig, setEditedConfig] = useState<Config>({})
39
51
  const [saving, setSaving] = useState(false)
@@ -122,6 +134,7 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
122
134
  if (!shouldRender) return null
123
135
 
124
136
  const isDesktop = configData?.isDesktop ?? false
137
+ const deploymentMode = capabilities.deployment?.mode ?? 'local'
125
138
 
126
139
  return createPortal(
127
140
  <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -169,33 +182,56 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
169
182
  </div>
170
183
  )}
171
184
  {onShowMyPermissions && (
172
- <div className="mb-4 rounded-md border border-theme-border bg-theme-elevated/50 p-3">
173
- <div className="flex items-center justify-between gap-3">
174
- <div className="min-w-0">
175
- <h3 className="text-sm font-medium text-theme-text-primary">My permissions</h3>
176
- <p className="mt-0.5 text-xs text-theme-text-tertiary">
177
- View what your current identity can do in this cluster.
178
- </p>
185
+ <div className="mb-5">
186
+ <SectionLabel>Personal</SectionLabel>
187
+ <div className="rounded-md border border-theme-border bg-theme-elevated/50 p-3">
188
+ <div className="flex items-center justify-between gap-3">
189
+ <div className="min-w-0">
190
+ <h3 className="text-sm font-medium text-theme-text-primary">My permissions</h3>
191
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
192
+ View what your current identity can do in this cluster.
193
+ </p>
194
+ </div>
195
+ <button
196
+ onClick={onShowMyPermissions}
197
+ className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover rounded-md transition-colors"
198
+ >
199
+ <Shield className="w-3.5 h-3.5" />
200
+ Open
201
+ </button>
179
202
  </div>
180
- <button
181
- onClick={onShowMyPermissions}
182
- className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover rounded-md transition-colors"
183
- >
184
- <Shield className="w-3.5 h-3.5" />
185
- Open
186
- </button>
187
203
  </div>
188
204
  </div>
189
205
  )}
190
- <StartupConfigTab
191
- config={editedConfig}
192
- effectiveConfig={configData?.effective}
193
- isDesktop={isDesktop}
194
- onChange={updateConfigField}
195
- />
206
+
207
+ <SectionLabel>Radar configuration</SectionLabel>
208
+ {canEditConfig ? (
209
+ <StartupConfigTab
210
+ config={editedConfig}
211
+ effectiveConfig={configData?.effective}
212
+ isDesktop={isDesktop}
213
+ deploymentMode={deploymentMode}
214
+ onChange={updateConfigField}
215
+ />
216
+ ) : (
217
+ <div className="rounded-md border border-theme-border bg-theme-elevated/50 p-4 flex items-start gap-3">
218
+ <Lock className="w-4 h-4 mt-0.5 shrink-0 text-theme-text-tertiary" />
219
+ <div className="min-w-0">
220
+ <p className="text-sm font-medium text-theme-text-primary">Owner access required</p>
221
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
222
+ These settings (kubeconfig, server port, timeline, integrations) affect
223
+ every user of this Radar instance, so they're limited to owners. Ask an
224
+ owner if you need a change here.
225
+ </p>
226
+ </div>
227
+ </div>
228
+ )}
196
229
  </div>
197
230
 
198
- {/* Footer */}
231
+ {/* Footer — only the owner-gated config section is editable, so hide
232
+ the save controls entirely for non-owners (personal sections save
233
+ themselves). */}
234
+ {canEditConfig && (
199
235
  <div className="flex items-center justify-between gap-3 p-4 border-t border-theme-border shrink-0">
200
236
  <div className="flex items-center gap-2">
201
237
  <button
@@ -225,25 +261,39 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
225
261
  Save
226
262
  </button>
227
263
  </div>
264
+ )}
228
265
  </div>
229
266
  </div>,
230
267
  document.body
231
268
  )
232
269
  }
233
270
 
271
+ // -- Section label ------------------------------------------------------------
272
+
273
+ function SectionLabel({ children }: { children: ReactNode }) {
274
+ return (
275
+ <h3 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-2">
276
+ {children}
277
+ </h3>
278
+ )
279
+ }
280
+
234
281
  // -- Startup Configuration Tab ------------------------------------------------
235
282
 
236
283
  function StartupConfigTab({
237
284
  config,
238
285
  effectiveConfig,
239
286
  isDesktop,
287
+ deploymentMode,
240
288
  onChange,
241
289
  }: {
242
290
  config: Config
243
291
  effectiveConfig?: Config
244
292
  isDesktop: boolean
293
+ deploymentMode: DeploymentMode
245
294
  onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
246
295
  }) {
296
+ const showBrowserLaunchControls = !isDesktop && deploymentMode === 'local'
247
297
  return (
248
298
  <div className="space-y-4">
249
299
  <p className="text-xs text-theme-text-tertiary">
@@ -291,12 +341,23 @@ function StartupConfigTab({
291
341
  onChange={(v) => onChange('port', v)}
292
342
  />
293
343
 
294
- {!isDesktop && (
295
- <ConfigToggle
296
- label="Open browser on start"
297
- value={!(config.noBrowser ?? false)}
298
- onChange={(v) => onChange('noBrowser', !v ? true : undefined)}
299
- />
344
+ {showBrowserLaunchControls && (
345
+ <>
346
+ <ConfigToggle
347
+ label="Open browser on start"
348
+ value={!(config.noBrowser ?? false)}
349
+ onChange={(v) => onChange('noBrowser', !v ? true : undefined)}
350
+ />
351
+
352
+ <ConfigField
353
+ label="Browser"
354
+ help="Browser for automatic launch; macOS app names are supported"
355
+ value={config.browser ?? ''}
356
+ effectiveValue={effectiveConfig?.browser}
357
+ placeholder="System default"
358
+ onChange={(v) => onChange('browser', v || undefined)}
359
+ />
360
+ </>
300
361
  )}
301
362
 
302
363
  <div className="border-t border-theme-border pt-4 mt-4">
@@ -12,6 +12,12 @@ const defaultCapabilities: Capabilities = {
12
12
  secretsUpdate: true,
13
13
  helmWrite: true,
14
14
  nodeWrite: true,
15
+ workloadWrites: {
16
+ deployments: true,
17
+ daemonSets: true,
18
+ statefulSets: true,
19
+ rollouts: true,
20
+ },
15
21
  mcpEnabled: true,
16
22
  // Default to 'local' for the loading window so the UI renders the
17
23
  // OSS standalone shape until /api/capabilities resolves. Both
@@ -30,6 +36,12 @@ const restrictedCapabilities: Capabilities = {
30
36
  secretsUpdate: false,
31
37
  helmWrite: false,
32
38
  nodeWrite: false,
39
+ workloadWrites: {
40
+ deployments: false,
41
+ daemonSets: false,
42
+ statefulSets: false,
43
+ rollouts: false,
44
+ },
33
45
  mcpEnabled: false,
34
46
  deployment: { mode: 'local' },
35
47
  }
@@ -121,10 +133,8 @@ export function useHasLimitedAccess(): boolean {
121
133
  return Object.entries(resources).some(([kind, allowed]) => !allowed && !isOptionalKind(kind))
122
134
  }
123
135
 
124
- // Namespace-scoped capability hooks: lazily re-check exec/logs/portForward
125
- // scoped to a specific namespace when global RBAC checks denied them.
126
- // Falls back to global capability values while the namespace check is loading
127
- // or when all capabilities are already granted.
136
+ // Namespace-scoped capability hooks. A concrete namespace gets its own
137
+ // capability check; callers use global capability values until it resolves.
128
138
  export function useNamespacedCapabilities(namespace: string | undefined) {
129
139
  const globalCaps = useContext(CapabilitiesContext)
130
140
  const { data: nsCaps, error } = useNamespaceCapabilities(namespace, globalCaps)
@@ -137,5 +147,6 @@ export function useNamespacedCapabilities(namespace: string | undefined) {
137
147
  canExec: nsCaps?.exec ?? globalCaps.exec,
138
148
  canViewLogs: nsCaps?.logs ?? globalCaps.logs,
139
149
  canPortForward: nsCaps?.portForward ?? globalCaps.portForward,
140
- }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, nsCaps])
150
+ workloadWrites: nsCaps?.workloadWrites ?? globalCaps.workloadWrites,
151
+ }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.workloadWrites, nsCaps])
141
152
  }