@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 +7 -8
- package/src/api/client.ts +120 -4
- package/src/components/audit/AuditSettingsDialog.tsx +30 -11
- package/src/components/audit/AuditView.tsx +8 -3
- package/src/components/resources/ResourcesView.tsx +54 -3
- package/src/components/settings/SettingsDialog.tsx +90 -29
- package/src/contexts/CapabilitiesContext.tsx +16 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "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
|
|
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.
|
|
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.
|
|
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.
|
|
71
|
-
"react-dom": "^19.2.
|
|
72
|
-
"react-router-dom": "^7.
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
? '
|
|
189
|
-
:
|
|
190
|
-
? '
|
|
191
|
-
:
|
|
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<
|
|
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-
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
{
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
125
|
-
//
|
|
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
|
-
|
|
150
|
+
workloadWrites: nsCaps?.workloadWrites ?? globalCaps.workloadWrites,
|
|
151
|
+
}), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.workloadWrites, nsCaps])
|
|
141
152
|
}
|