@skyhook-io/radar-app 1.4.2 → 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.
|
|
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,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}
|
|
@@ -6,6 +6,8 @@ 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
8
|
import { useCloudRole } from '../../api/client'
|
|
9
|
+
import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
|
|
10
|
+
import type { DeploymentMode } from '../../types'
|
|
9
11
|
|
|
10
12
|
interface Config {
|
|
11
13
|
kubeconfig?: string
|
|
@@ -13,6 +15,7 @@ interface Config {
|
|
|
13
15
|
namespace?: string
|
|
14
16
|
port?: number
|
|
15
17
|
noBrowser?: boolean
|
|
18
|
+
browser?: string
|
|
16
19
|
timelineStorage?: 'memory' | 'sqlite'
|
|
17
20
|
timelineDbPath?: string
|
|
18
21
|
historyLimit?: number
|
|
@@ -41,6 +44,7 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
41
44
|
// (OSS, OIDC, kubectl plugin) have no role and pass — single-user laptops
|
|
42
45
|
// are never locked out of their own config. Backend enforces this too.
|
|
43
46
|
const { canAtLeast } = useCloudRole()
|
|
47
|
+
const capabilities = useCapabilitiesContext()
|
|
44
48
|
const canEditConfig = canAtLeast('owner')
|
|
45
49
|
const [configData, setConfigData] = useState<ConfigResponse | null>(null)
|
|
46
50
|
const [editedConfig, setEditedConfig] = useState<Config>({})
|
|
@@ -130,6 +134,7 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
130
134
|
if (!shouldRender) return null
|
|
131
135
|
|
|
132
136
|
const isDesktop = configData?.isDesktop ?? false
|
|
137
|
+
const deploymentMode = capabilities.deployment?.mode ?? 'local'
|
|
133
138
|
|
|
134
139
|
return createPortal(
|
|
135
140
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
@@ -205,6 +210,7 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
205
210
|
config={editedConfig}
|
|
206
211
|
effectiveConfig={configData?.effective}
|
|
207
212
|
isDesktop={isDesktop}
|
|
213
|
+
deploymentMode={deploymentMode}
|
|
208
214
|
onChange={updateConfigField}
|
|
209
215
|
/>
|
|
210
216
|
) : (
|
|
@@ -278,13 +284,16 @@ function StartupConfigTab({
|
|
|
278
284
|
config,
|
|
279
285
|
effectiveConfig,
|
|
280
286
|
isDesktop,
|
|
287
|
+
deploymentMode,
|
|
281
288
|
onChange,
|
|
282
289
|
}: {
|
|
283
290
|
config: Config
|
|
284
291
|
effectiveConfig?: Config
|
|
285
292
|
isDesktop: boolean
|
|
293
|
+
deploymentMode: DeploymentMode
|
|
286
294
|
onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
|
|
287
295
|
}) {
|
|
296
|
+
const showBrowserLaunchControls = !isDesktop && deploymentMode === 'local'
|
|
288
297
|
return (
|
|
289
298
|
<div className="space-y-4">
|
|
290
299
|
<p className="text-xs text-theme-text-tertiary">
|
|
@@ -332,12 +341,23 @@ function StartupConfigTab({
|
|
|
332
341
|
onChange={(v) => onChange('port', v)}
|
|
333
342
|
/>
|
|
334
343
|
|
|
335
|
-
{
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
+
</>
|
|
341
361
|
)}
|
|
342
362
|
|
|
343
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
|
}
|