@skyhook-io/radar-app 1.8.3 → 1.8.6
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 +256 -94
- package/src/RadarApp.tsx +4 -1
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.ts +147 -20
- 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 +22 -7
- 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 +20 -7
- 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/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PodRenderer.tsx +10 -4
- 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
|
@@ -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).
|
|
@@ -71,7 +83,14 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
71
83
|
// the page header from vanishing while loading / on error, the wrapper shows
|
|
72
84
|
// the same header bar above those states. (Keep title + description in sync
|
|
73
85
|
// with ApplicationsList's PageHeader.)
|
|
74
|
-
if (query.isLoading
|
|
86
|
+
if (query.isLoading) {
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
89
|
+
<ApplicationsList apps={[]} onSelect={selectApp} headerActions={freshness} loading />
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
if (query.error) {
|
|
75
94
|
return (
|
|
76
95
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
77
96
|
<div className="shrink-0 border-b border-theme-border px-4 py-4">
|
|
@@ -81,18 +100,14 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
81
100
|
description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
|
|
82
101
|
/>
|
|
83
102
|
</div>
|
|
84
|
-
{query.
|
|
85
|
-
<CenteredEmpty icon={Boxes} headline="Loading applications…" />
|
|
86
|
-
) : (
|
|
87
|
-
<CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
|
|
88
|
-
)}
|
|
103
|
+
<CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
|
|
89
104
|
</div>
|
|
90
105
|
)
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
return (
|
|
94
109
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
95
|
-
<ApplicationsList apps={apps} onSelect={selectApp} />
|
|
110
|
+
<ApplicationsList apps={apps} onSelect={selectApp} headerActions={freshness} />
|
|
96
111
|
</div>
|
|
97
112
|
)
|
|
98
113
|
}
|
|
@@ -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">
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
GitOpsDetailLayout,
|
|
9
9
|
GitOpsGraphFilterRail,
|
|
10
10
|
GitOpsTableView as SharedGitOpsTableView,
|
|
11
|
+
FreshnessControl,
|
|
11
12
|
GitOpsTreeGraph,
|
|
12
13
|
RollbackDialog,
|
|
13
14
|
SyncOptionsDialog,
|
|
@@ -61,6 +62,7 @@ import {
|
|
|
61
62
|
useResource,
|
|
62
63
|
} from '../../api/client'
|
|
63
64
|
import { useAPIResources } from '../../api/apiResources'
|
|
65
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
64
66
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
65
67
|
import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
|
|
66
68
|
import { CodeViewer } from '../ui/CodeViewer'
|
|
@@ -80,6 +82,11 @@ const GITOPS_KINDS: APIResource[] = [
|
|
|
80
82
|
|
|
81
83
|
const KIND_BY_NAME = new Map(GITOPS_KINDS.map((k) => [k.name, k]))
|
|
82
84
|
|
|
85
|
+
// Rows are the table's primary content; their poll cadence is what the toolbar
|
|
86
|
+
// freshness signal advertises ("Auto-refreshes every 2m"). Single source of
|
|
87
|
+
// truth so the signal can't drift from the actual refetchInterval below.
|
|
88
|
+
const GITOPS_ROWS_REFRESH_INTERVAL_MS = 120_000
|
|
89
|
+
|
|
83
90
|
interface ResourceCountsResponse {
|
|
84
91
|
counts: Record<string, number>
|
|
85
92
|
forbidden?: string[]
|
|
@@ -102,6 +109,7 @@ export function GitOpsView({ namespaces, onOpenResource, onClearNamespaces }: Gi
|
|
|
102
109
|
|
|
103
110
|
function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string[]; onClearNamespaces?: () => void }) {
|
|
104
111
|
const navigate = useNavigate()
|
|
112
|
+
const { connection } = useConnection()
|
|
105
113
|
const namespacesParam = namespaces.join(',')
|
|
106
114
|
const { data: apiResources, isLoading: apiResourcesLoading } = useAPIResources()
|
|
107
115
|
|
|
@@ -191,7 +199,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
191
199
|
},
|
|
192
200
|
enabled: !apiResourcesLoading,
|
|
193
201
|
staleTime: 30_000,
|
|
194
|
-
refetchInterval:
|
|
202
|
+
refetchInterval: GITOPS_ROWS_REFRESH_INTERVAL_MS,
|
|
195
203
|
})
|
|
196
204
|
|
|
197
205
|
// Row mutations invalidate granular keys (['resource', …], ['gitops-tree', …])
|
|
@@ -201,11 +209,11 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
201
209
|
// inviting a duplicate request. Radar serves reads from an informer cache that
|
|
202
210
|
// lags the write by the watch-propagation delay, so refetch once now (covers
|
|
203
211
|
// an already-current cache) and once shortly after to catch the propagated
|
|
204
|
-
// update; refetch() forces a fetch regardless of staleTime.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
212
|
+
// update; refetch() forces a fetch regardless of staleTime. The toolbar's
|
|
213
|
+
// manual refresh reuses refetchTable so rows + counts stay in sync.
|
|
214
|
+
// Return the combined promise so the toolbar's refresh animation waits for the
|
|
215
|
+
// real fetches to settle before showing its success checkmark.
|
|
216
|
+
const refetchTable = () => Promise.all([rowsQuery.refetch(), countsQuery.refetch()])
|
|
209
217
|
const refetchTableAfterMutation = () => {
|
|
210
218
|
refetchTable()
|
|
211
219
|
window.setTimeout(refetchTable, 1200)
|
|
@@ -280,7 +288,14 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
280
288
|
error={(rowsQuery.error as Error | null) ?? null}
|
|
281
289
|
counts={countsQuery.data?.counts ?? {}}
|
|
282
290
|
countsUnavailable={countsQuery.data?.unavailable}
|
|
283
|
-
|
|
291
|
+
freshnessSlot={
|
|
292
|
+
<FreshnessControl
|
|
293
|
+
mode="auto"
|
|
294
|
+
dataUpdatedAt={rowsQuery.dataUpdatedAt}
|
|
295
|
+
onRefresh={refetchTable}
|
|
296
|
+
connectionState={connection.state}
|
|
297
|
+
/>
|
|
298
|
+
}
|
|
284
299
|
onRowClick={(row) => {
|
|
285
300
|
const ns = row.namespace || '_'
|
|
286
301
|
const params = new URLSearchParams()
|