@skyhook-io/radar-app 1.8.3 → 1.8.5
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 +5 -5
- package/src/App.tsx +248 -92
- package/src/RadarApp.tsx +4 -1
- package/src/api/client.ts +32 -6
- package/src/components/ConnectionErrorView.tsx +1 -1
- package/src/components/ContextSwitcher.tsx +5 -1
- package/src/components/NamespaceSwitcher.tsx +21 -300
- package/src/components/applications/ApplicationsView.tsx +13 -1
- package/src/components/audit/AuditView.tsx +11 -2
- package/src/components/cost/CostView.tsx +12 -2
- package/src/components/gitops/GitOpsView.tsx +22 -7
- package/src/components/helm/HelmCompareRoute.tsx +1342 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +189 -352
- package/src/components/helm/HelmView.tsx +79 -62
- package/src/components/helm/ManifestDiffViewer.tsx +4 -4
- package/src/components/home/ClusterHealthCard.tsx +6 -1
- package/src/components/home/HomeView.tsx +12 -1
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/issues/IssuesPane.tsx +29 -18
- package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
- package/src/components/resources/ResourcesView.tsx +3 -0
- package/src/components/timeline/TimelineView.tsx +26 -2
- package/src/components/traffic/TrafficView.tsx +17 -10
- package/src/components/ui/Markdown.tsx +2 -2
- package/src/components/ui/Omnibar.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +5 -1
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/index.ts +15 -0
package/src/RadarApp.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import { ThemeProvider } from './context/ThemeContext';
|
|
|
25
25
|
import { ToastProvider, showApiError, showApiSuccess } from './components/ui/Toast';
|
|
26
26
|
import { setApiBase, setBasename } from './api/config';
|
|
27
27
|
import { NavCustomizationProvider } from './context/NavCustomization';
|
|
28
|
+
import { FilterLocationBridge } from './filter/FilterLocationBridge';
|
|
28
29
|
import type { NavCustomization } from './context/NavCustomization';
|
|
29
30
|
|
|
30
31
|
// Declare the shape of mutation meta here — inlined rather than in a
|
|
@@ -153,7 +154,9 @@ export function RadarApp({
|
|
|
153
154
|
<QueryClientProvider client={client}>
|
|
154
155
|
<ToastProvider>
|
|
155
156
|
<NavCustomizationProvider value={navSlots}>
|
|
156
|
-
<
|
|
157
|
+
<FilterLocationBridge>
|
|
158
|
+
<App manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
|
|
159
|
+
</FilterLocationBridge>
|
|
157
160
|
</NavCustomizationProvider>
|
|
158
161
|
</ToastProvider>
|
|
159
162
|
</QueryClientProvider>
|
package/src/api/client.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
HelmValues,
|
|
18
18
|
ManifestDiff,
|
|
19
19
|
NotesDiff,
|
|
20
|
+
HooksDiff,
|
|
20
21
|
ResourceDiff,
|
|
21
22
|
UpgradeInfo,
|
|
22
23
|
BatchUpgradeInfo,
|
|
@@ -35,6 +36,15 @@ import type { GitOpsOperationResponse } from '../types/gitops'
|
|
|
35
36
|
import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
|
|
36
37
|
import { pluralToKind } from '../utils/navigation'
|
|
37
38
|
|
|
39
|
+
// Auto-refresh cadences (ms) — named constants for each polled hook's
|
|
40
|
+
// refetchInterval below, so the poll rate reads clearly at each call site.
|
|
41
|
+
const DASHBOARD_REFRESH_INTERVAL_MS = 30_000
|
|
42
|
+
const AUDIT_REFRESH_INTERVAL_MS = 60_000
|
|
43
|
+
const ISSUES_REFRESH_INTERVAL_MS = 30_000
|
|
44
|
+
const COST_REFRESH_INTERVAL_MS = 60_000
|
|
45
|
+
const CHANGES_REFRESH_INTERVAL_MS = 60_000
|
|
46
|
+
const APPLICATIONS_REFRESH_INTERVAL_MS = 60_000
|
|
47
|
+
|
|
38
48
|
// Wrapper around fetch that always includes credentials (for session cookies)
|
|
39
49
|
// and handles 401 responses globally. Merges caller-provided headers with
|
|
40
50
|
// auth headers from the config module so library consumers (Radar Hub) can
|
|
@@ -326,7 +336,7 @@ export function useDashboard(namespaces: string[] = []) {
|
|
|
326
336
|
queryKey: ['dashboard', namespaces],
|
|
327
337
|
queryFn: () => fetchJSON(`/dashboard${params}`),
|
|
328
338
|
staleTime: 15000, // 15 seconds
|
|
329
|
-
refetchInterval:
|
|
339
|
+
refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
|
|
330
340
|
})
|
|
331
341
|
}
|
|
332
342
|
|
|
@@ -337,7 +347,7 @@ export function useAudit(namespaces: string[] = []) {
|
|
|
337
347
|
queryKey: ['audit', namespaces],
|
|
338
348
|
queryFn: () => fetchJSON(`/audit${params}`),
|
|
339
349
|
staleTime: 30000,
|
|
340
|
-
refetchInterval:
|
|
350
|
+
refetchInterval: AUDIT_REFRESH_INTERVAL_MS,
|
|
341
351
|
placeholderData: (prev) => prev,
|
|
342
352
|
})
|
|
343
353
|
}
|
|
@@ -368,7 +378,7 @@ export function useIssues(namespaces: string[] = []) {
|
|
|
368
378
|
queryKey: ['issues', namespaces],
|
|
369
379
|
queryFn: () => fetchJSON(`/issues${params}`),
|
|
370
380
|
staleTime: 30000,
|
|
371
|
-
refetchInterval:
|
|
381
|
+
refetchInterval: ISSUES_REFRESH_INTERVAL_MS,
|
|
372
382
|
})
|
|
373
383
|
}
|
|
374
384
|
|
|
@@ -516,7 +526,7 @@ export function useOpenCostSummary() {
|
|
|
516
526
|
return useQuery<OpenCostSummary>({
|
|
517
527
|
queryKey: ['opencost-summary'],
|
|
518
528
|
queryFn: () => fetchJSON('/opencost/summary'),
|
|
519
|
-
refetchInterval:
|
|
529
|
+
refetchInterval: COST_REFRESH_INTERVAL_MS,
|
|
520
530
|
staleTime: 30000,
|
|
521
531
|
placeholderData: (prev) => prev, // Keep previous data visible during refetch
|
|
522
532
|
})
|
|
@@ -954,7 +964,7 @@ export function useApplications(namespaces: string[]) {
|
|
|
954
964
|
queryKey: ['applications', namespaces],
|
|
955
965
|
queryFn: () => fetchJSON(`/applications${queryString ? `?${queryString}` : ''}`),
|
|
956
966
|
staleTime: 30_000,
|
|
957
|
-
refetchInterval:
|
|
967
|
+
refetchInterval: APPLICATIONS_REFRESH_INTERVAL_MS,
|
|
958
968
|
})
|
|
959
969
|
}
|
|
960
970
|
|
|
@@ -1113,7 +1123,7 @@ export function useChanges(options: UseChangesOptions = {}) {
|
|
|
1113
1123
|
queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
|
|
1114
1124
|
queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
|
|
1115
1125
|
staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
|
|
1116
|
-
refetchInterval:
|
|
1126
|
+
refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE handles real-time updates; this is a fallback
|
|
1117
1127
|
enabled,
|
|
1118
1128
|
})
|
|
1119
1129
|
}
|
|
@@ -2452,6 +2462,22 @@ export function useHelmNotesDiff(
|
|
|
2452
2462
|
})
|
|
2453
2463
|
}
|
|
2454
2464
|
|
|
2465
|
+
export function useHelmHooksDiff(
|
|
2466
|
+
namespace: string,
|
|
2467
|
+
name: string,
|
|
2468
|
+
revision1: number,
|
|
2469
|
+
revision2: number,
|
|
2470
|
+
enabled = true,
|
|
2471
|
+
) {
|
|
2472
|
+
return useQuery<HooksDiff>({
|
|
2473
|
+
queryKey: ['helm-hooks-diff', namespace, name, revision1, revision2],
|
|
2474
|
+
queryFn: () =>
|
|
2475
|
+
fetchJSON(`/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`),
|
|
2476
|
+
enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
|
|
2477
|
+
staleTime: 60000,
|
|
2478
|
+
})
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2455
2481
|
export function useHelmResourceDiff(
|
|
2456
2482
|
namespace: string,
|
|
2457
2483
|
name: string,
|
|
@@ -254,7 +254,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
254
254
|
|
|
255
255
|
{connection.error && (
|
|
256
256
|
<div className="w-full bg-theme-elevated border border-theme-border rounded-lg p-3 mb-6 overflow-auto max-h-32">
|
|
257
|
-
<code className="text-xs text-red-400 font-mono whitespace-pre-wrap break-
|
|
257
|
+
<code className="text-xs text-red-400 font-mono whitespace-pre-wrap break-words">
|
|
258
258
|
{connection.error}
|
|
259
259
|
</code>
|
|
260
260
|
</div>
|
|
@@ -14,6 +14,8 @@ import { parseContextName, type ParsedContextName } from '../utils/context-name'
|
|
|
14
14
|
|
|
15
15
|
interface ContextSwitcherProps {
|
|
16
16
|
className?: string
|
|
17
|
+
variant?: 'chip' | 'segment'
|
|
18
|
+
label?: string
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface ContextSwitcherHandle {
|
|
@@ -24,7 +26,7 @@ interface ParsedContext extends ParsedContextName {
|
|
|
24
26
|
context: ContextInfo
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '' }, ref) => {
|
|
29
|
+
export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '', variant, label }, ref) => {
|
|
28
30
|
const [showConfirm, setShowConfirm] = useState(false)
|
|
29
31
|
const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
|
|
30
32
|
const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
|
|
@@ -191,6 +193,8 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
|
|
|
191
193
|
<ClusterSwitcher
|
|
192
194
|
ref={ref}
|
|
193
195
|
className={className}
|
|
196
|
+
variant={variant}
|
|
197
|
+
label={label}
|
|
194
198
|
currentId={currentId}
|
|
195
199
|
currentName={currentRaw}
|
|
196
200
|
currentSourceLabel={currentSourceLabel}
|
|
@@ -1,320 +1,41 @@
|
|
|
1
|
-
import { forwardRef
|
|
2
|
-
import {
|
|
3
|
-
import { ChevronDown, Globe, Search, AlertTriangle, X } from 'lucide-react'
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
import { NamespacePicker, type NamespacePickerHandle } from '@skyhook-io/k8s-ui'
|
|
4
3
|
import { useNamespaceScope, useSetActiveNamespace } from '../api/client'
|
|
5
|
-
import { Tooltip } from './ui/Tooltip'
|
|
6
4
|
|
|
7
|
-
export
|
|
8
|
-
open: () => void
|
|
9
|
-
}
|
|
5
|
+
export type NamespaceSwitcherHandle = NamespacePickerHandle
|
|
10
6
|
|
|
11
7
|
interface NamespaceSwitcherProps {
|
|
12
8
|
className?: string
|
|
13
9
|
disabled?: boolean
|
|
14
10
|
disabledTooltip?: string
|
|
11
|
+
variant?: 'chip' | 'segment'
|
|
12
|
+
label?: string
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* Three states reflect what the backend reports:
|
|
23
|
-
* - cluster-wide: empty trigger label "All namespaces", picker lets the
|
|
24
|
-
* user narrow the view; otherwise informational.
|
|
25
|
-
* - namespace: label shows the namespace count (or single name); picker
|
|
26
|
-
* offers other accessible namespaces and a clear-all reset.
|
|
27
|
-
* - restricted: user can't list namespaces and isn't pinned; picker
|
|
28
|
-
* surfaces only the kubeconfig context's namespace + any saved picks.
|
|
29
|
-
*
|
|
30
|
-
* Selection model: the dropdown keeps a draft Set<string>; toggling rows
|
|
31
|
-
* mutates the draft locally; closing the dropdown applies the draft in a
|
|
32
|
-
* single mutation. "Clear all" applies immediately and closes; "Select all
|
|
33
|
-
* visible" / "Clear visible" mutate the draft only and wait for close.
|
|
16
|
+
* OSS Radar's namespace scope control — a thin data container over the shared
|
|
17
|
+
* presentational NamespacePicker (@skyhook-io/k8s-ui). Wires Radar's own API
|
|
18
|
+
* hooks; Radar Hub supplies its own container over the per-cluster apiBase.
|
|
34
19
|
*/
|
|
35
20
|
export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSwitcherProps>(function NamespaceSwitcher(
|
|
36
|
-
{ className
|
|
21
|
+
{ className, disabled, disabledTooltip, variant, label },
|
|
37
22
|
ref,
|
|
38
23
|
) {
|
|
39
24
|
const { data: scope, isLoading } = useNamespaceScope()
|
|
40
25
|
const setActive = useSetActiveNamespace()
|
|
41
26
|
|
|
42
|
-
const [isOpen, setIsOpen] = useState(false)
|
|
43
|
-
const [search, setSearch] = useState('')
|
|
44
|
-
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
|
45
|
-
const [draft, setDraft] = useState<Set<string>>(() => new Set())
|
|
46
|
-
|
|
47
|
-
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
48
|
-
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
49
|
-
|
|
50
|
-
const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
|
|
51
|
-
const activesKey = useMemo(() => [...scopeActives].sort().join(','), [scopeActives])
|
|
52
|
-
|
|
53
|
-
// Sync the draft with the server's view whenever it changes (initial load,
|
|
54
|
-
// post-mutation refetch, eviction after RBAC drift).
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
setDraft(new Set(scopeActives))
|
|
57
|
-
}, [activesKey, scopeActives])
|
|
58
|
-
|
|
59
|
-
const items = useMemo(() => {
|
|
60
|
-
if (!scope) return [] as string[]
|
|
61
|
-
return [...(scope.accessibleNamespaces ?? [])].sort((a, b) => a.localeCompare(b))
|
|
62
|
-
}, [scope])
|
|
63
|
-
|
|
64
|
-
const filteredItems = useMemo(() => {
|
|
65
|
-
const q = search.trim().toLowerCase()
|
|
66
|
-
if (!q) return items
|
|
67
|
-
return items.filter(n => n.toLowerCase().includes(q))
|
|
68
|
-
}, [items, search])
|
|
69
|
-
|
|
70
|
-
const applySelection = useCallback((next: Set<string>) => {
|
|
71
|
-
if (!scope) return
|
|
72
|
-
const nextArr = Array.from(next).sort()
|
|
73
|
-
if (scope.cacheScoped && nextArr.length !== 1) return
|
|
74
|
-
if (nextArr.join(',') === activesKey) return
|
|
75
|
-
setActive.mutate({ namespaces: nextArr })
|
|
76
|
-
}, [activesKey, scope, setActive])
|
|
77
|
-
|
|
78
|
-
const closeAndApply = useCallback(() => {
|
|
79
|
-
setIsOpen(false)
|
|
80
|
-
setSearch('')
|
|
81
|
-
applySelection(draft)
|
|
82
|
-
}, [applySelection, draft])
|
|
83
|
-
|
|
84
|
-
useImperativeHandle(ref, () => ({
|
|
85
|
-
open: () => {
|
|
86
|
-
if (disabled || isLoading || setActive.isPending) return
|
|
87
|
-
setIsOpen(true)
|
|
88
|
-
},
|
|
89
|
-
}), [disabled, isLoading, setActive.isPending])
|
|
90
|
-
|
|
91
|
-
useEffect(() => {
|
|
92
|
-
if (!isOpen) return
|
|
93
|
-
const trigger = triggerRef.current
|
|
94
|
-
if (!trigger) return
|
|
95
|
-
const r = trigger.getBoundingClientRect()
|
|
96
|
-
setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 240) })
|
|
97
|
-
}, [isOpen])
|
|
98
|
-
|
|
99
|
-
useEffect(() => {
|
|
100
|
-
if (!isOpen) return
|
|
101
|
-
function onClick(e: MouseEvent) {
|
|
102
|
-
if (
|
|
103
|
-
!dropdownRef.current?.contains(e.target as Node) &&
|
|
104
|
-
!triggerRef.current?.contains(e.target as Node)
|
|
105
|
-
) {
|
|
106
|
-
closeAndApply()
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
function onKey(e: KeyboardEvent) {
|
|
110
|
-
if (e.key === 'Escape') closeAndApply()
|
|
111
|
-
}
|
|
112
|
-
document.addEventListener('mousedown', onClick)
|
|
113
|
-
document.addEventListener('keydown', onKey)
|
|
114
|
-
return () => {
|
|
115
|
-
document.removeEventListener('mousedown', onClick)
|
|
116
|
-
document.removeEventListener('keydown', onKey)
|
|
117
|
-
}
|
|
118
|
-
}, [isOpen, closeAndApply])
|
|
119
|
-
|
|
120
|
-
if (!scope) return null
|
|
121
|
-
|
|
122
|
-
const toggle = (ns: string) => {
|
|
123
|
-
if (scope.cacheScoped) {
|
|
124
|
-
setDraft(new Set([ns]))
|
|
125
|
-
return
|
|
126
|
-
}
|
|
127
|
-
const next = new Set(draft)
|
|
128
|
-
if (next.has(ns)) next.delete(ns)
|
|
129
|
-
else next.add(ns)
|
|
130
|
-
setDraft(next)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const clearAll = () => {
|
|
134
|
-
if (scope.cacheScoped) return
|
|
135
|
-
setDraft(new Set())
|
|
136
|
-
setIsOpen(false)
|
|
137
|
-
setSearch('')
|
|
138
|
-
applySelection(new Set())
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const selectAllVisible = () => {
|
|
142
|
-
const next = new Set(draft)
|
|
143
|
-
for (const ns of filteredItems) next.add(ns)
|
|
144
|
-
setDraft(next)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const clearVisible = () => {
|
|
148
|
-
const next = new Set(draft)
|
|
149
|
-
for (const ns of filteredItems) next.delete(ns)
|
|
150
|
-
setDraft(next)
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const activeCount = scopeActives.length
|
|
154
|
-
const triggerLabel =
|
|
155
|
-
activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
|
|
156
|
-
const isClusterWide = activeCount === 0
|
|
157
|
-
const restrictedHint = scope.mode === 'restricted'
|
|
158
|
-
const cacheScopeLocked = scope.cacheScoped && !scope.namespaceRescope
|
|
159
|
-
const isDisabled = disabled || isLoading || setActive.isPending || cacheScopeLocked
|
|
160
|
-
const canClearAll = scope.canClearNamespace || activeCount === 0
|
|
161
|
-
const tooltipContent = disabled && disabledTooltip
|
|
162
|
-
? disabledTooltip
|
|
163
|
-
: scope.cacheScoped
|
|
164
|
-
? scope.namespaceRescope
|
|
165
|
-
? `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} to stay fast on large clusters. Pick another namespace to re-point it (takes a moment; closes open terminals).`
|
|
166
|
-
: `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} on this cluster.`
|
|
167
|
-
: restrictedHint
|
|
168
|
-
? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
|
|
169
|
-
: isClusterWide
|
|
170
|
-
? 'Currently viewing all namespaces. Click to narrow the view.'
|
|
171
|
-
: activeCount === 1
|
|
172
|
-
? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
|
|
173
|
-
: `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
|
|
174
|
-
|
|
175
|
-
// Counts used to label the bulk-action buttons; computed against the visible
|
|
176
|
-
// (filtered) set so the labels match what the action will affect.
|
|
177
|
-
const visibleSelectedCount = filteredItems.reduce((n, ns) => n + (draft.has(ns) ? 1 : 0), 0)
|
|
178
|
-
const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
|
|
179
|
-
|
|
180
27
|
return (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
>
|
|
194
|
-
{isClusterWide ? (
|
|
195
|
-
<Globe className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
196
|
-
) : restrictedHint ? (
|
|
197
|
-
<AlertTriangle className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
198
|
-
) : null}
|
|
199
|
-
<span className="font-medium max-w-[180px] truncate">
|
|
200
|
-
{setActive.isPending ? 'Switching…' : triggerLabel}
|
|
201
|
-
</span>
|
|
202
|
-
<ChevronDown className="w-3 h-3 opacity-60" />
|
|
203
|
-
</button>
|
|
204
|
-
</Tooltip>
|
|
205
|
-
|
|
206
|
-
{isOpen &&
|
|
207
|
-
createPortal(
|
|
208
|
-
<div
|
|
209
|
-
ref={dropdownRef}
|
|
210
|
-
style={{ position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width, zIndex: 100 }}
|
|
211
|
-
className="bg-theme-surface border border-theme-border rounded-md shadow-theme-lg overflow-hidden"
|
|
212
|
-
>
|
|
213
|
-
{items.length > 6 && (
|
|
214
|
-
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
|
|
215
|
-
<Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
216
|
-
<input
|
|
217
|
-
autoFocus
|
|
218
|
-
value={search}
|
|
219
|
-
onChange={e => setSearch(e.target.value)}
|
|
220
|
-
placeholder="Filter namespaces"
|
|
221
|
-
className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
|
|
222
|
-
/>
|
|
223
|
-
</div>
|
|
224
|
-
)}
|
|
225
|
-
|
|
226
|
-
{scope.cacheScoped ? (
|
|
227
|
-
<div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
|
|
228
|
-
Radar is watching one namespace to stay fast on large clusters.
|
|
229
|
-
{scope.namespaceRescope
|
|
230
|
-
? ' Pick another to re-point it — takes a moment and closes open terminals.'
|
|
231
|
-
: ' This instance is locked to its startup namespace.'}
|
|
232
|
-
</div>
|
|
233
|
-
) : (
|
|
234
|
-
<div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
|
|
235
|
-
<button
|
|
236
|
-
onClick={canClearAll ? clearAll : undefined}
|
|
237
|
-
disabled={!canClearAll || activeCount === 0}
|
|
238
|
-
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
239
|
-
aria-label="Clear namespace selection"
|
|
240
|
-
>
|
|
241
|
-
<X className="w-3 h-3" />
|
|
242
|
-
Clear all
|
|
243
|
-
</button>
|
|
244
|
-
<button
|
|
245
|
-
onClick={allVisibleSelected ? clearVisible : selectAllVisible}
|
|
246
|
-
disabled={filteredItems.length === 0}
|
|
247
|
-
className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
248
|
-
>
|
|
249
|
-
{allVisibleSelected
|
|
250
|
-
? `Clear ${filteredItems.length} visible`
|
|
251
|
-
: search.trim()
|
|
252
|
-
? `Select ${filteredItems.length} visible`
|
|
253
|
-
: 'Select all'}
|
|
254
|
-
</button>
|
|
255
|
-
</div>
|
|
256
|
-
)}
|
|
257
|
-
|
|
258
|
-
<ul className="max-h-80 overflow-y-auto py-1">
|
|
259
|
-
{filteredItems.length === 0 && (
|
|
260
|
-
<li className="px-3 py-2 text-xs text-theme-text-tertiary">
|
|
261
|
-
{search ? 'No matches.' : 'No namespaces available.'}
|
|
262
|
-
</li>
|
|
263
|
-
)}
|
|
264
|
-
|
|
265
|
-
{filteredItems.map(ns => {
|
|
266
|
-
const isChecked = draft.has(ns)
|
|
267
|
-
const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
|
|
268
|
-
return (
|
|
269
|
-
<li key={ns}>
|
|
270
|
-
<label
|
|
271
|
-
className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer"
|
|
272
|
-
>
|
|
273
|
-
<span className="flex items-center gap-2 min-w-0">
|
|
274
|
-
<input
|
|
275
|
-
type={scope.cacheScoped ? 'radio' : 'checkbox'}
|
|
276
|
-
name={scope.cacheScoped ? 'namespace-cache-scope' : undefined}
|
|
277
|
-
checked={isChecked}
|
|
278
|
-
onChange={() => toggle(ns)}
|
|
279
|
-
className="shrink-0 accent-current"
|
|
280
|
-
/>
|
|
281
|
-
<span className="truncate">{ns}</span>
|
|
282
|
-
{isContextDefault && (
|
|
283
|
-
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
|
|
284
|
-
kubeconfig
|
|
285
|
-
</span>
|
|
286
|
-
)}
|
|
287
|
-
</span>
|
|
288
|
-
</label>
|
|
289
|
-
</li>
|
|
290
|
-
)
|
|
291
|
-
})}
|
|
292
|
-
</ul>
|
|
293
|
-
|
|
294
|
-
<div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
|
|
295
|
-
<span>
|
|
296
|
-
{scope.cacheScoped
|
|
297
|
-
? (draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace')
|
|
298
|
-
: draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
|
|
299
|
-
</span>
|
|
300
|
-
<button
|
|
301
|
-
onClick={closeAndApply}
|
|
302
|
-
className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
|
|
303
|
-
>
|
|
304
|
-
Done
|
|
305
|
-
</button>
|
|
306
|
-
</div>
|
|
307
|
-
|
|
308
|
-
{!scope.authoritative && (
|
|
309
|
-
<div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
|
|
310
|
-
Limited list — your RBAC doesn’t allow listing all
|
|
311
|
-
namespaces. Other namespaces may be accessible but won’t
|
|
312
|
-
appear here until you switch context.
|
|
313
|
-
</div>
|
|
314
|
-
)}
|
|
315
|
-
</div>,
|
|
316
|
-
document.body,
|
|
317
|
-
)}
|
|
318
|
-
</>
|
|
28
|
+
<NamespacePicker
|
|
29
|
+
ref={ref}
|
|
30
|
+
scope={scope}
|
|
31
|
+
loading={isLoading}
|
|
32
|
+
pending={setActive.isPending}
|
|
33
|
+
onApply={namespaces => setActive.mutate({ namespaces })}
|
|
34
|
+
disabled={disabled}
|
|
35
|
+
disabledTooltip={disabledTooltip}
|
|
36
|
+
className={className}
|
|
37
|
+
variant={variant}
|
|
38
|
+
label={label}
|
|
39
|
+
/>
|
|
319
40
|
)
|
|
320
41
|
})
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ApplicationDetail,
|
|
6
6
|
CenteredEmpty,
|
|
7
7
|
PageHeader,
|
|
8
|
+
FreshnessControl,
|
|
8
9
|
useToast,
|
|
9
10
|
orderEnvs,
|
|
10
11
|
matchWorkloadAcrossInstances,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
} from '@skyhook-io/k8s-ui'
|
|
19
20
|
import { Boxes } from 'lucide-react'
|
|
20
21
|
import { useApplications, useTopology } from '../../api/client'
|
|
22
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
21
23
|
import { kindToPlural } from '../../utils/navigation'
|
|
22
24
|
import { WorkloadView } from '../workload/WorkloadView'
|
|
23
25
|
|
|
@@ -28,8 +30,18 @@ interface ApplicationsViewProps {
|
|
|
28
30
|
|
|
29
31
|
export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsViewProps) {
|
|
30
32
|
const query = useApplications(namespaces)
|
|
33
|
+
const { connection } = useConnection()
|
|
31
34
|
const apps = useMemo(() => query.data?.applications ?? [], [query.data])
|
|
32
35
|
|
|
36
|
+
const freshness = (
|
|
37
|
+
<FreshnessControl
|
|
38
|
+
mode="auto"
|
|
39
|
+
dataUpdatedAt={query.dataUpdatedAt}
|
|
40
|
+
onRefresh={() => query.refetch()}
|
|
41
|
+
connectionState={connection.state}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
|
|
33
45
|
// Which app is open lives in the URL (?app=<key>) so the detail view is
|
|
34
46
|
// deep-linkable and the browser back button returns to the list. Opening or
|
|
35
47
|
// closing an app also clears the per-app params (workload, tab).
|
|
@@ -92,7 +104,7 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
92
104
|
|
|
93
105
|
return (
|
|
94
106
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
95
|
-
<ApplicationsList apps={apps} onSelect={selectApp} />
|
|
107
|
+
<ApplicationsList apps={apps} onSelect={selectApp} headerActions={freshness} />
|
|
96
108
|
</div>
|
|
97
109
|
)
|
|
98
110
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useState, useCallback } from 'react'
|
|
2
2
|
import { useAudit, useAuditSettings, useUpdateAuditSettings, useCloudRole } from '../../api/client'
|
|
3
3
|
import type { SelectedResource } from '../../types'
|
|
4
|
-
import { ChecksView, PaneLoader, PageHeader, type CheckResourceRef } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { ChecksView, PaneLoader, PageHeader, FreshnessControl, type CheckResourceRef } from '@skyhook-io/k8s-ui'
|
|
5
5
|
import { ShieldCheck, Settings } from 'lucide-react'
|
|
6
6
|
import { AuditSettingsDialog } from './AuditSettingsDialog'
|
|
7
7
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
8
9
|
|
|
9
10
|
interface AuditViewProps {
|
|
10
11
|
namespaces: string[]
|
|
@@ -18,7 +19,7 @@ interface AuditViewProps {
|
|
|
18
19
|
// ~/.radar settings are this cluster's "policy" and the row hide-menu writes to
|
|
19
20
|
// them.
|
|
20
21
|
export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps) {
|
|
21
|
-
const { data, isLoading, error } = useAudit(namespaces)
|
|
22
|
+
const { data, isLoading, error, dataUpdatedAt, refetch } = useAudit(namespaces)
|
|
22
23
|
const { data: auditSettings } = useAuditSettings()
|
|
23
24
|
const updateSettings = useUpdateAuditSettings()
|
|
24
25
|
// Audit policy is owner-gated (enforced server-side). Withhold the inline
|
|
@@ -30,6 +31,8 @@ export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps)
|
|
|
30
31
|
|
|
31
32
|
const ignoredCount = auditSettings?.ignoredNamespaces?.length ?? 0
|
|
32
33
|
|
|
34
|
+
const { connection } = useConnection()
|
|
35
|
+
|
|
33
36
|
// Inline hide actions — persist to local settings immediately.
|
|
34
37
|
const hideCheck = useCallback((checkID: string) => {
|
|
35
38
|
if (!auditSettings) return
|
|
@@ -80,6 +83,12 @@ export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps)
|
|
|
80
83
|
description="Security, reliability, and efficiency best practices (NSA/CISA, CIS, Polaris, Kubescape), grouped into a remediation queue."
|
|
81
84
|
actions={
|
|
82
85
|
<>
|
|
86
|
+
<FreshnessControl
|
|
87
|
+
mode="auto"
|
|
88
|
+
dataUpdatedAt={dataUpdatedAt}
|
|
89
|
+
onRefresh={() => refetch()}
|
|
90
|
+
connectionState={connection.state}
|
|
91
|
+
/>
|
|
83
92
|
{ignoredCount > 0 && (
|
|
84
93
|
<button onClick={() => setShowSettings(true)} className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors">{ignoredCount} {ignoredCount === 1 ? 'namespace' : 'namespaces'} hidden</button>
|
|
85
94
|
)}
|
|
@@ -2,17 +2,19 @@ import { useState, useEffect } from 'react'
|
|
|
2
2
|
import { useOpenCostSummary, useOpenCostWorkloads, useOpenCostNodes } from '../../api/client'
|
|
3
3
|
import type { OpenCostNamespaceCost, OpenCostWorkloadCost, OpenCostNodeCost } from '../../api/client'
|
|
4
4
|
import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, HelpCircle, Loader2, Server, X } from 'lucide-react'
|
|
5
|
-
import { PaneLoader } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui'
|
|
6
6
|
import { CostTrendChart } from './CostTrendChart'
|
|
7
7
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
8
9
|
|
|
9
10
|
interface CostViewProps {
|
|
10
11
|
onBack: () => void
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function CostView({ onBack }: CostViewProps) {
|
|
14
|
-
const { data, isLoading } = useOpenCostSummary()
|
|
15
|
+
const { data, isLoading, dataUpdatedAt, refetch } = useOpenCostSummary()
|
|
15
16
|
const { data: nodeData } = useOpenCostNodes()
|
|
17
|
+
const { connection } = useConnection()
|
|
16
18
|
const [showHelp, setShowHelp] = useState(false)
|
|
17
19
|
|
|
18
20
|
if (isLoading) {
|
|
@@ -90,6 +92,14 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
90
92
|
</button>
|
|
91
93
|
</div>
|
|
92
94
|
<div className="flex items-center gap-4">
|
|
95
|
+
{/* Tracks the headline $/hr summary (the primary query); its load
|
|
96
|
+
time is the representative freshness signal for the view. */}
|
|
97
|
+
<FreshnessControl
|
|
98
|
+
mode="auto"
|
|
99
|
+
dataUpdatedAt={dataUpdatedAt}
|
|
100
|
+
onRefresh={() => refetch()}
|
|
101
|
+
connectionState={connection.state}
|
|
102
|
+
/>
|
|
93
103
|
{hasEfficiency && (
|
|
94
104
|
<div className="flex flex-col items-end gap-0.5">
|
|
95
105
|
<div className="flex items-center gap-2 text-sm">
|