@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.4.2",
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,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}
@@ -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
- {!isDesktop && (
336
- <ConfigToggle
337
- label="Open browser on start"
338
- value={!(config.noBrowser ?? false)}
339
- onChange={(v) => onChange('noBrowser', !v ? true : undefined)}
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: 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
  }