@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,13 +1,13 @@
|
|
|
1
1
|
import { useState, useMemo, useRef, useEffect, useCallback, forwardRef } from 'react'
|
|
2
|
-
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
3
2
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
4
|
-
import { Package, Search,
|
|
5
|
-
import { PaneLoader, PageHeader } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { Package, Search, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight, RotateCcw, Clock } from 'lucide-react'
|
|
4
|
+
import { PaneLoader, PageHeader, SortableTh, FreshnessControl, type SortDir } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
6
6
|
import { clsx } from 'clsx'
|
|
7
7
|
import { useHelmReleases, useHelmBatchUpgradeInfo, isForbiddenError } from '../../api/client'
|
|
8
8
|
import type { HelmOperation, HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
|
|
9
9
|
import { getStatusColor, formatAge, isHelmReleaseActionable } from './helm-utils'
|
|
10
|
-
import { SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
10
|
+
import { SEVERITY_BADGE, SEVERITY_DOT, SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
11
11
|
import { Tooltip } from '../ui/Tooltip'
|
|
12
12
|
import { ChartBrowser } from './ChartBrowser'
|
|
13
13
|
import { InstallWizard } from './InstallWizard'
|
|
@@ -25,7 +25,7 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
25
25
|
const [searchTerm, setSearchTerm] = useState('')
|
|
26
26
|
const [selectedChart, setSelectedChart] = useState<{ repo: string; chart: string; version: string; source: ChartSource } | null>(null)
|
|
27
27
|
|
|
28
|
-
const { data: releases, isLoading, error: releasesError, refetch: refetchReleases } = useHelmReleases(namespaces)
|
|
28
|
+
const { data: releases, isLoading, error: releasesError, dataUpdatedAt: releasesUpdatedAt, isFetching: releasesFetching, refetch: refetchReleases } = useHelmReleases(namespaces)
|
|
29
29
|
const isForbidden = isForbiddenError(releasesError)
|
|
30
30
|
const releasesErrorMessage = releasesError instanceof Error ? releasesError.message : 'Failed to load Helm releases'
|
|
31
31
|
|
|
@@ -36,24 +36,38 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
36
36
|
)
|
|
37
37
|
const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade checks failed'
|
|
38
38
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
const isFullyLoaded = !isLoading && !upgradeLoading
|
|
39
|
+
const { connection } = useConnection()
|
|
40
|
+
const refetchAll = () => Promise.all([refetchReleases(), refetchUpgradeInfo()])
|
|
44
41
|
|
|
45
42
|
// Filter releases by search term
|
|
43
|
+
// Resources-table cycle: asc → desc → off (null restores the server's order).
|
|
44
|
+
const [sort, setSort] = useState<{ key: HelmSortKey; dir: SortDir } | null>(null)
|
|
45
|
+
const onSort = useCallback(
|
|
46
|
+
(key: HelmSortKey) =>
|
|
47
|
+
setSort((prev) => {
|
|
48
|
+
if (!prev || prev.key !== key) return { key, dir: 'asc' }
|
|
49
|
+
if (prev.dir === 'asc') return { key, dir: 'desc' }
|
|
50
|
+
return null
|
|
51
|
+
}),
|
|
52
|
+
[],
|
|
53
|
+
)
|
|
54
|
+
|
|
46
55
|
const filteredReleases = useMemo(() => {
|
|
47
56
|
if (!releases) return []
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
const term = searchTerm.trim().toLowerCase()
|
|
58
|
+
const filtered = term
|
|
59
|
+
? releases.filter(
|
|
60
|
+
(r) =>
|
|
61
|
+
r.name.toLowerCase().includes(term) ||
|
|
62
|
+
r.namespace.toLowerCase().includes(term) ||
|
|
63
|
+
r.chart.toLowerCase().includes(term)
|
|
64
|
+
)
|
|
65
|
+
: releases
|
|
66
|
+
|
|
67
|
+
if (!sort) return filtered
|
|
68
|
+
const factor = sort.dir === 'asc' ? 1 : -1
|
|
69
|
+
return [...filtered].sort((a, b) => compareReleases(a, b, sort.key) * factor)
|
|
70
|
+
}, [releases, searchTerm, sort])
|
|
57
71
|
|
|
58
72
|
// Keyboard navigation state
|
|
59
73
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
@@ -62,8 +76,9 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
62
76
|
const filteredReleasesCountRef = useRef(0)
|
|
63
77
|
filteredReleasesCountRef.current = filteredReleases.length
|
|
64
78
|
|
|
65
|
-
// Reset highlight when search
|
|
66
|
-
|
|
79
|
+
// Reset highlight when the visible order changes (search or sort) — otherwise
|
|
80
|
+
// the index would point at whatever release shifted into that row.
|
|
81
|
+
useEffect(() => { setHighlightedIndex(-1) }, [searchTerm, sort])
|
|
67
82
|
|
|
68
83
|
// Scroll highlighted row into view
|
|
69
84
|
useEffect(() => {
|
|
@@ -173,6 +188,17 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
173
188
|
icon={Package}
|
|
174
189
|
title="Helm"
|
|
175
190
|
description="Installed Helm releases and the chart catalog for this cluster."
|
|
191
|
+
actions={
|
|
192
|
+
activeTab === 'releases' ? (
|
|
193
|
+
<FreshnessControl
|
|
194
|
+
mode="snapshot"
|
|
195
|
+
dataUpdatedAt={releasesUpdatedAt}
|
|
196
|
+
isFetching={releasesFetching || upgradeLoading}
|
|
197
|
+
onRefresh={refetchAll}
|
|
198
|
+
connectionState={connection.state}
|
|
199
|
+
/>
|
|
200
|
+
) : undefined
|
|
201
|
+
}
|
|
176
202
|
/>
|
|
177
203
|
</div>
|
|
178
204
|
{/* Tab bar */}
|
|
@@ -212,9 +238,6 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
212
238
|
<>
|
|
213
239
|
{/* Releases Toolbar */}
|
|
214
240
|
<div className="flex items-center gap-4 px-4 py-3 border-b border-theme-border bg-theme-surface/50 shrink-0">
|
|
215
|
-
{!isFullyLoaded && (
|
|
216
|
-
<RefreshCw className="w-3.5 h-3.5 animate-spin text-theme-text-tertiary shrink-0" />
|
|
217
|
-
)}
|
|
218
241
|
<div className="flex-1 relative">
|
|
219
242
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
220
243
|
<input
|
|
@@ -226,15 +249,6 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
226
249
|
className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
227
250
|
/>
|
|
228
251
|
</div>
|
|
229
|
-
<Tooltip content="Refresh">
|
|
230
|
-
<button
|
|
231
|
-
onClick={handleRefresh}
|
|
232
|
-
disabled={isRefreshAnimating}
|
|
233
|
-
className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg disabled:opacity-50 disabled:pointer-events-none"
|
|
234
|
-
>
|
|
235
|
-
<RefreshCw className={clsx('w-4 h-4', isRefreshAnimating && 'animate-spin')} />
|
|
236
|
-
</button>
|
|
237
|
-
</Tooltip>
|
|
238
252
|
</div>
|
|
239
253
|
|
|
240
254
|
{/* Releases Table */}
|
|
@@ -289,29 +303,15 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
289
303
|
</div>
|
|
290
304
|
) : (
|
|
291
305
|
<table className="w-full table-fixed">
|
|
292
|
-
<thead className="bg-theme-
|
|
306
|
+
<thead className="bg-theme-base sticky top-0 z-10">
|
|
293
307
|
<tr>
|
|
294
|
-
<
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
<
|
|
301
|
-
Chart
|
|
302
|
-
</th>
|
|
303
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-24 hidden xl:table-cell">
|
|
304
|
-
App Version
|
|
305
|
-
</th>
|
|
306
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-40">
|
|
307
|
-
Status
|
|
308
|
-
</th>
|
|
309
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-16">
|
|
310
|
-
Rev
|
|
311
|
-
</th>
|
|
312
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-24">
|
|
313
|
-
Updated
|
|
314
|
-
</th>
|
|
308
|
+
<SortableTh label="Name" sortKey="name" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[28%]" />
|
|
309
|
+
<SortableTh label="Namespace" sortKey="namespace" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[18%]" />
|
|
310
|
+
<SortableTh label="Chart" sortKey="chart" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[22%]" />
|
|
311
|
+
<SortableTh label="App Version" sortKey="appVersion" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-24 hidden xl:table-cell" />
|
|
312
|
+
<SortableTh label="Status" sortKey="status" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-40" />
|
|
313
|
+
<SortableTh label="Rev" sortKey="revision" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-16" />
|
|
314
|
+
<SortableTh label="Updated" sortKey="updated" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-24" />
|
|
315
315
|
</tr>
|
|
316
316
|
</thead>
|
|
317
317
|
<tbody className="table-divide-subtle">
|
|
@@ -360,6 +360,23 @@ function releaseIdentityKey(release: Pick<HelmRelease, 'namespace' | 'name' | 's
|
|
|
360
360
|
return `${release.storageNamespace || release.namespace}/${release.name}`
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
type HelmSortKey = 'name' | 'namespace' | 'chart' | 'appVersion' | 'status' | 'revision' | 'updated'
|
|
364
|
+
|
|
365
|
+
function compareReleases(a: HelmRelease, b: HelmRelease, key: HelmSortKey): number {
|
|
366
|
+
let cmp: number
|
|
367
|
+
switch (key) {
|
|
368
|
+
case 'revision':
|
|
369
|
+
cmp = a.revision - b.revision
|
|
370
|
+
break
|
|
371
|
+
case 'updated':
|
|
372
|
+
cmp = (Date.parse(a.updated) || 0) - (Date.parse(b.updated) || 0)
|
|
373
|
+
break
|
|
374
|
+
default:
|
|
375
|
+
cmp = String(a[key] ?? '').localeCompare(String(b[key] ?? ''))
|
|
376
|
+
}
|
|
377
|
+
return cmp || a.name.localeCompare(b.name)
|
|
378
|
+
}
|
|
379
|
+
|
|
363
380
|
interface ReleaseRowProps {
|
|
364
381
|
release: HelmRelease
|
|
365
382
|
upgradeInfo?: UpgradeInfo
|
|
@@ -392,7 +409,7 @@ function getActionableTooltip(issue: string | undefined, summary: string | undef
|
|
|
392
409
|
<div className="max-w-xs">
|
|
393
410
|
<div className={clsx(
|
|
394
411
|
'font-medium',
|
|
395
|
-
health === 'unhealthy' ?
|
|
412
|
+
health === 'unhealthy' ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning
|
|
396
413
|
)}>
|
|
397
414
|
{summary || issue || health}
|
|
398
415
|
</div>
|
|
@@ -483,10 +500,10 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
|
|
|
483
500
|
const getHealthBadge = () => {
|
|
484
501
|
if (!release.resourceHealth || release.resourceHealth === 'unknown') return null
|
|
485
502
|
|
|
486
|
-
const healthStyles: Record<string, {
|
|
487
|
-
healthy: {
|
|
488
|
-
degraded: {
|
|
489
|
-
unhealthy: {
|
|
503
|
+
const healthStyles: Record<string, { badge: string; dot: string }> = {
|
|
504
|
+
healthy: { badge: SEVERITY_BADGE.success, dot: SEVERITY_DOT.success },
|
|
505
|
+
degraded: { badge: SEVERITY_BADGE.warning, dot: SEVERITY_DOT.warning },
|
|
506
|
+
unhealthy: { badge: SEVERITY_BADGE.error, dot: SEVERITY_DOT.error },
|
|
490
507
|
}
|
|
491
508
|
|
|
492
509
|
const style = healthStyles[release.resourceHealth] || healthStyles.healthy
|
|
@@ -495,8 +512,8 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
|
|
|
495
512
|
return (
|
|
496
513
|
<Tooltip content={tooltipContent}>
|
|
497
514
|
<span className={clsx(
|
|
498
|
-
'
|
|
499
|
-
style.
|
|
515
|
+
'badge-sm shrink-0',
|
|
516
|
+
style.badge
|
|
500
517
|
)}>
|
|
501
518
|
<span className={clsx('w-1.5 h-1.5 rounded-full', style.dot)} />
|
|
502
519
|
{release.healthIssue || (release.resourceHealth !== 'healthy' ? release.healthSummary : null)}
|
|
@@ -69,7 +69,7 @@ export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onCl
|
|
|
69
69
|
)
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function hasDiffBodyChange(diff: string): boolean {
|
|
72
|
+
export function hasDiffBodyChange(diff: string): boolean {
|
|
73
73
|
return diff.split('\n').some((line) => {
|
|
74
74
|
if (!line || line.startsWith('---') || line.startsWith('+++') || line.startsWith('@@')) {
|
|
75
75
|
return false
|
|
@@ -78,7 +78,7 @@ function hasDiffBodyChange(diff: string): boolean {
|
|
|
78
78
|
})
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function DiffLine({ line }: { line: string }) {
|
|
81
|
+
export function DiffLine({ line }: { line: string }) {
|
|
82
82
|
const isAddition = line.startsWith('+') && !line.startsWith('+++')
|
|
83
83
|
const isRemoval = line.startsWith('-') && !line.startsWith('---')
|
|
84
84
|
const isHeader = line.startsWith('---') || line.startsWith('+++') || line.startsWith('@@')
|
|
@@ -87,8 +87,8 @@ function DiffLine({ line }: { line: string }) {
|
|
|
87
87
|
<div
|
|
88
88
|
className={clsx(
|
|
89
89
|
'whitespace-pre',
|
|
90
|
-
isAddition && 'bg-green-500/10 text-green-400',
|
|
91
|
-
isRemoval && 'bg-red-500/10 text-red-400',
|
|
90
|
+
isAddition && 'bg-green-500/10 text-green-700 dark:text-green-400',
|
|
91
|
+
isRemoval && 'bg-red-500/10 text-red-700 dark:text-red-400',
|
|
92
92
|
isHeader && 'text-theme-text-tertiary font-bold',
|
|
93
93
|
!isAddition && !isRemoval && !isHeader && 'text-theme-text-secondary'
|
|
94
94
|
)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
2
|
import type { DashboardResponse, DashboardMetrics, DashboardCRDCount } from '../../api/client'
|
|
3
3
|
import { HealthRing } from './HealthRing'
|
|
4
4
|
import {
|
|
@@ -30,6 +30,9 @@ interface ClusterHealthCardProps {
|
|
|
30
30
|
onNavigateToView: () => void
|
|
31
31
|
onWarningEventsClick?: () => void
|
|
32
32
|
onIssuesClick?: () => void
|
|
33
|
+
// Freshness/refresh control for the dashboard poll — rendered under the
|
|
34
|
+
// cluster metadata so the overview carries a freshness signal without a band.
|
|
35
|
+
freshness?: ReactNode
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
function getMetricsInstallHint(platform: string): string {
|
|
@@ -121,6 +124,7 @@ export function ClusterHealthCard({
|
|
|
121
124
|
onNavigateToView,
|
|
122
125
|
onWarningEventsClick,
|
|
123
126
|
onIssuesClick,
|
|
127
|
+
freshness,
|
|
124
128
|
}: ClusterHealthCardProps) {
|
|
125
129
|
void _topCRDs // Reserved for future CRD display
|
|
126
130
|
|
|
@@ -254,6 +258,7 @@ export function ClusterHealthCard({
|
|
|
254
258
|
</Tooltip>
|
|
255
259
|
)}
|
|
256
260
|
</div>
|
|
261
|
+
{freshness && <div className="mt-2">{freshness}</div>}
|
|
257
262
|
{nodeVersionSkew && (
|
|
258
263
|
<Tooltip
|
|
259
264
|
content={
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo, type ReactNode } from 'react'
|
|
2
2
|
import { useDashboard, useDashboardCRDs, useDashboardHelm, useIssues, type IssuesResponse } from '../../api/client'
|
|
3
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
3
4
|
import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
|
|
4
5
|
import { TopologyPreview } from './TopologyPreview'
|
|
5
6
|
import { HelmSummary } from './HelmSummary'
|
|
@@ -11,6 +12,7 @@ import { CostCard } from './CostCard'
|
|
|
11
12
|
import { GitOpsControllersCard } from './GitOpsControllersCard'
|
|
12
13
|
import {
|
|
13
14
|
AuditCard,
|
|
15
|
+
FreshnessControl,
|
|
14
16
|
PaneLoader,
|
|
15
17
|
StatusDot,
|
|
16
18
|
categoryLabel,
|
|
@@ -39,7 +41,8 @@ interface HomeViewProps {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts }: HomeViewProps) {
|
|
42
|
-
const { data, isLoading, error } = useDashboard(namespaces)
|
|
44
|
+
const { data, isLoading, error, dataUpdatedAt, refetch } = useDashboard(namespaces)
|
|
45
|
+
const { connection } = useConnection()
|
|
43
46
|
const { data: issuesData, isLoading: issuesLoading, isFetching: issuesFetching, error: issuesError } = useIssues(namespaces)
|
|
44
47
|
const issues = issuesData?.issues ?? []
|
|
45
48
|
const issueCount = issuesData?.total_matched ?? issuesData?.total ?? issues.length
|
|
@@ -108,6 +111,14 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
108
111
|
)}
|
|
109
112
|
{/* Row 1: Cluster Health Card (combined health + resource counts) */}
|
|
110
113
|
<ClusterHealthCard
|
|
114
|
+
freshness={
|
|
115
|
+
<FreshnessControl
|
|
116
|
+
mode="auto"
|
|
117
|
+
dataUpdatedAt={dataUpdatedAt}
|
|
118
|
+
onRefresh={() => refetch()}
|
|
119
|
+
connectionState={connection.state}
|
|
120
|
+
/>
|
|
121
|
+
}
|
|
111
122
|
health={data.health}
|
|
112
123
|
counts={data.resourceCounts}
|
|
113
124
|
cluster={data.cluster}
|
|
@@ -348,11 +359,8 @@ function ProblemsPanel({
|
|
|
348
359
|
<div className="divide-y divide-theme-border">
|
|
349
360
|
{issues.map((issue) => {
|
|
350
361
|
const ref = subjectRef(issue)
|
|
351
|
-
const age = issue.first_seen
|
|
352
|
-
|
|
353
|
-
? 'since deploy'
|
|
354
|
-
: formatCompactAge(issue.first_seen)
|
|
355
|
-
: ''
|
|
362
|
+
const age = issue.first_seen ? formatCompactAge(issue.first_seen) : ''
|
|
363
|
+
const startedAtDeploy = issue.issue_timing === 'started_at_resource_creation'
|
|
356
364
|
|
|
357
365
|
return (
|
|
358
366
|
<button
|
|
@@ -370,7 +378,12 @@ function ProblemsPanel({
|
|
|
370
378
|
<div className="flex items-center gap-1.5">
|
|
371
379
|
<span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
|
|
372
380
|
<span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
|
|
373
|
-
{age
|
|
381
|
+
{(age || startedAtDeploy) && (
|
|
382
|
+
<span className="ml-auto flex shrink-0 items-center gap-1">
|
|
383
|
+
{age && <span className="text-[10px] text-theme-text-tertiary tabular-nums">{age}</span>}
|
|
384
|
+
{startedAtDeploy && <span className="badge-sm text-[10px] text-theme-text-secondary">since deploy</span>}
|
|
385
|
+
</span>
|
|
386
|
+
)}
|
|
374
387
|
</div>
|
|
375
388
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|
376
389
|
<span className="text-[11px] text-theme-text-secondary truncate">{categoryLabel(issue.category)}</span>
|
|
@@ -152,7 +152,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
152
152
|
},
|
|
153
153
|
{
|
|
154
154
|
name: 'get_helm_release',
|
|
155
|
-
desc: 'Detailed Helm release info with owned resources, health, Flux ownership, current lastOperation, hooks, and failed/running hook diagnostics with live Job/Pod/Event/redacted-log evidence when available.',
|
|
155
|
+
desc: 'Detailed Helm release info with owned resources, health, Flux ownership, current lastOperation, operationInsight, hooks, and failed/running hook diagnostics with live Job/Pod/Event/redacted-log evidence when available.',
|
|
156
156
|
params: [
|
|
157
157
|
{ arg: 'namespace', required: true, desc: 'Helm storage namespace; use storageNamespace from list_helm_releases when present' },
|
|
158
158
|
{ arg: 'name', required: true, desc: 'release name' },
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react'
|
|
2
2
|
import { useIssues } from '../../api/client'
|
|
3
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
3
4
|
import type { SelectedResource } from '../../types'
|
|
4
5
|
import {
|
|
5
6
|
IssuesView,
|
|
6
7
|
PaneLoader,
|
|
7
8
|
PageHeader,
|
|
8
9
|
SummaryTile,
|
|
10
|
+
FreshnessControl,
|
|
9
11
|
ISSUE_SEVERITIES,
|
|
10
12
|
ISSUE_SEVERITY_LABEL,
|
|
11
13
|
type IssueResourceRef,
|
|
@@ -30,7 +32,8 @@ interface IssuesPaneProps {
|
|
|
30
32
|
// the header status tiles (clickable → filter), matching the Applications /
|
|
31
33
|
// GitOps header-tile pattern rather than Hub's fleet facet sidebar.
|
|
32
34
|
export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps) {
|
|
33
|
-
const { data, isLoading, error } = useIssues(namespaces)
|
|
35
|
+
const { data, isLoading, error, dataUpdatedAt, refetch } = useIssues(namespaces)
|
|
36
|
+
const { connection } = useConnection()
|
|
34
37
|
const [severityFilter, setSeverityFilter] = useState<Set<IssueSeverity>>(new Set())
|
|
35
38
|
|
|
36
39
|
const allIssues = useMemo(() => data?.issues ?? [], [data])
|
|
@@ -70,23 +73,31 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
|
|
|
70
73
|
title="Issues"
|
|
71
74
|
description="Live cluster problems — crashes, scheduling failures, bad references — grouped by the resource they affect."
|
|
72
75
|
actions={
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
76
|
+
<>
|
|
77
|
+
<FreshnessControl
|
|
78
|
+
mode="auto"
|
|
79
|
+
dataUpdatedAt={dataUpdatedAt}
|
|
80
|
+
onRefresh={() => refetch()}
|
|
81
|
+
connectionState={connection.state}
|
|
82
|
+
/>
|
|
83
|
+
{allIssues.length > 0 && (
|
|
84
|
+
<>
|
|
85
|
+
<SummaryTile label={allIssues.length === 1 ? 'issue' : 'issues'} value={allIssues.length} />
|
|
86
|
+
{ISSUE_SEVERITIES.map((s) =>
|
|
87
|
+
totals[s] > 0 || severityFilter.has(s) ? (
|
|
88
|
+
<SummaryTile
|
|
89
|
+
key={s}
|
|
90
|
+
label={ISSUE_SEVERITY_LABEL[s]}
|
|
91
|
+
value={totals[s]}
|
|
92
|
+
tone={SEVERITY_TONE[s]}
|
|
93
|
+
active={severityFilter.has(s)}
|
|
94
|
+
onClick={() => toggleSeverity(s)}
|
|
95
|
+
/>
|
|
96
|
+
) : null,
|
|
97
|
+
)}
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
</>
|
|
90
101
|
}
|
|
91
102
|
/>
|
|
92
103
|
|
|
@@ -14,8 +14,10 @@ interface ResourceDetailDrawerProps {
|
|
|
14
14
|
expanded?: boolean
|
|
15
15
|
/** Called when user clicks collapse in expanded mode */
|
|
16
16
|
onCollapse?: () => void
|
|
17
|
-
/** Called when user clicks expand button */
|
|
18
|
-
onExpand?: (resource: SelectedResource) => void
|
|
17
|
+
/** Called when user clicks expand button (opts.yaml = expanding from YAML view) */
|
|
18
|
+
onExpand?: (resource: SelectedResource, opts?: { yaml?: boolean }) => void
|
|
19
|
+
/** Hide the collapse-to-drawer control (mobile: no drawer to collapse to). Default true. */
|
|
20
|
+
canCollapseToDrawer?: boolean
|
|
19
21
|
/** Navigate to another resource within expanded WorkloadView */
|
|
20
22
|
onNavigateToResource?: (resource: SelectedResource) => void
|
|
21
23
|
/** Top offset for the drawer (px). Defaults to Radar's 49px header height;
|
|
@@ -26,16 +28,19 @@ interface ResourceDetailDrawerProps {
|
|
|
26
28
|
export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
|
|
27
29
|
return (
|
|
28
30
|
<BaseResourceDetailDrawer {...props}>
|
|
29
|
-
{({ resource, expanded, initialTab, onClose, onExpand, onBack, onNavigateToResource, onCollapseToDrawer }) => (
|
|
31
|
+
{({ resource, expanded, active, initialTab, onClose, onExpand, onExpandIntent, onCancelExpandIntent, onBack, onNavigateToResource, onCollapseToDrawer }) => (
|
|
30
32
|
<WorkloadView
|
|
31
33
|
kind={resource.kind}
|
|
32
34
|
namespace={resource.namespace}
|
|
33
35
|
name={resource.name}
|
|
34
36
|
group={resource.group}
|
|
35
37
|
expanded={expanded}
|
|
38
|
+
active={active}
|
|
36
39
|
initialTab={initialTab}
|
|
37
40
|
onClose={onClose}
|
|
38
41
|
onExpand={onExpand}
|
|
42
|
+
onExpandIntent={onExpandIntent}
|
|
43
|
+
onCancelExpandIntent={onCancelExpandIntent}
|
|
39
44
|
onBack={onBack ?? (() => {})}
|
|
40
45
|
onNavigateToResource={onNavigateToResource}
|
|
41
46
|
onCollapseToDrawer={onCollapseToDrawer}
|
|
@@ -6,6 +6,7 @@ import { isBadgeWorthy } from '../../utils/auditBadges'
|
|
|
6
6
|
import type { AuditBadgeMessage } from '@skyhook-io/k8s-ui'
|
|
7
7
|
import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
|
|
8
8
|
import { useAPIResources } from '../../api/apiResources'
|
|
9
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
9
10
|
import { initNavigationMap } from '@skyhook-io/k8s-ui'
|
|
10
11
|
import { usePinnedKinds } from '../../hooks/useFavorites'
|
|
11
12
|
import { useOpenLogs, useOpenWorkloadLogs } from '../dock'
|
|
@@ -62,6 +63,7 @@ function resourceCountKey(kind: NonNullable<SelectedKindInfo>): string {
|
|
|
62
63
|
export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) {
|
|
63
64
|
const location = useLocation()
|
|
64
65
|
const navigate = useNavigate()
|
|
66
|
+
const { connection } = useConnection()
|
|
65
67
|
|
|
66
68
|
const { data: capabilities } = useCapabilities()
|
|
67
69
|
const namespaceForCapabilities = namespaces.length === 1 ? namespaces[0] : undefined
|
|
@@ -328,6 +330,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
328
330
|
resourceReasons={countsData?.reasons}
|
|
329
331
|
resourceUnavailable={countsData?.unavailable}
|
|
330
332
|
selectedKindQuery={selectedKindQueryResult}
|
|
333
|
+
connectionState={connection.state}
|
|
331
334
|
largeListGuard={largeListGuard}
|
|
332
335
|
onSelectedKindChange={setSelectedKind}
|
|
333
336
|
topPodMetrics={topPodMetrics}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NodeRenderer as BaseNodeRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/NodeRenderer'
|
|
2
2
|
import { useNavigate } from 'react-router-dom'
|
|
3
|
-
import { useNodeMetrics, useNodeMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
|
|
3
|
+
import { getVisibleLiveMetrics, isLiveMetricsUnavailable, shouldFetchLiveMetrics, useNodeMetrics, useNodeMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
|
|
4
4
|
import { serializeColumnFilters } from '../resource-utils'
|
|
5
5
|
|
|
6
6
|
interface NodeRendererProps {
|
|
@@ -13,8 +13,13 @@ export function NodeRenderer({ data, relationships }: NodeRendererProps) {
|
|
|
13
13
|
const nodeName = data.metadata?.name
|
|
14
14
|
|
|
15
15
|
// Fetch node metrics
|
|
16
|
-
const
|
|
17
|
-
const { data: metricsHistory } =
|
|
16
|
+
const metricsHistoryQuery = useNodeMetricsHistory(nodeName)
|
|
17
|
+
const { data: metricsHistory } = metricsHistoryQuery
|
|
18
|
+
const historyMetricsUnavailable = metricsHistory?.metricsUnavailable === true
|
|
19
|
+
const liveMetricsEnabled = shouldFetchLiveMetrics(metricsHistoryQuery.isFetched || metricsHistoryQuery.isError, historyMetricsUnavailable)
|
|
20
|
+
const { data: metrics } = useNodeMetrics(nodeName, { enabled: liveMetricsEnabled })
|
|
21
|
+
const metricsUnavailable = historyMetricsUnavailable || isLiveMetricsUnavailable(liveMetricsEnabled, metrics)
|
|
22
|
+
const visibleMetrics = getVisibleLiveMetrics(liveMetricsEnabled, metricsUnavailable, metrics)
|
|
18
23
|
|
|
19
24
|
// Determine whether to hide metrics-server section (Prometheus has data)
|
|
20
25
|
const { data: prometheusStatus } = usePrometheusStatus()
|
|
@@ -36,8 +41,9 @@ export function NodeRenderer({ data, relationships }: NodeRendererProps) {
|
|
|
36
41
|
params.set('filters', serializeColumnFilters({ node: [nodeName] }))
|
|
37
42
|
navigate(`/resources/pods?${params.toString()}`)
|
|
38
43
|
} : undefined}
|
|
39
|
-
metrics={
|
|
44
|
+
metrics={visibleMetrics}
|
|
40
45
|
metricsHistory={metricsHistory}
|
|
46
|
+
metricsUnavailable={metricsUnavailable}
|
|
41
47
|
hideMetricsServer={hideMetricsServer}
|
|
42
48
|
/>
|
|
43
49
|
)
|
|
@@ -3,7 +3,7 @@ import type { CopyHandler } from '@skyhook-io/k8s-ui/components/ui/drawer-compon
|
|
|
3
3
|
import type { ResolvedEnvFrom } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import { useOpenTerminal, useOpenLogs } from '../../dock'
|
|
5
5
|
import { useNamespacedCapabilities, useIsLocalDeployment } from '../../../contexts/CapabilitiesContext'
|
|
6
|
-
import { usePodMetrics, usePodMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
|
|
6
|
+
import { getVisibleLiveMetrics, isLiveMetricsUnavailable, shouldFetchLiveMetrics, usePodMetrics, usePodMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
|
|
7
7
|
import { useRBACSubject } from '../../../api/rbac'
|
|
8
8
|
import { PortForwardInlineButton } from '../../portforward/PortForwardButton'
|
|
9
9
|
import { ImageFilesystemModal } from '../ImageFilesystemModal'
|
|
@@ -34,8 +34,13 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
|
|
|
34
34
|
const showPortForward = canPortForward || !isLocal
|
|
35
35
|
|
|
36
36
|
// Metrics
|
|
37
|
-
const
|
|
38
|
-
const { data: metricsHistory } =
|
|
37
|
+
const metricsHistoryQuery = usePodMetricsHistory(namespace, podName)
|
|
38
|
+
const { data: metricsHistory } = metricsHistoryQuery
|
|
39
|
+
const historyMetricsUnavailable = metricsHistory?.metricsUnavailable === true
|
|
40
|
+
const liveMetricsEnabled = shouldFetchLiveMetrics(metricsHistoryQuery.isFetched || metricsHistoryQuery.isError, historyMetricsUnavailable)
|
|
41
|
+
const { data: metrics } = usePodMetrics(namespace, podName, { enabled: liveMetricsEnabled })
|
|
42
|
+
const metricsUnavailable = historyMetricsUnavailable || isLiveMetricsUnavailable(liveMetricsEnabled, metrics)
|
|
43
|
+
const visibleMetrics = getVisibleLiveMetrics(liveMetricsEnabled, metricsUnavailable, metrics)
|
|
39
44
|
|
|
40
45
|
// Hide metrics-server section when Prometheus has CPU data
|
|
41
46
|
const { data: prometheusStatus } = usePrometheusStatus()
|
|
@@ -82,8 +87,9 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
|
|
|
82
87
|
disabled={disabled}
|
|
83
88
|
/>
|
|
84
89
|
)}
|
|
85
|
-
metrics={
|
|
90
|
+
metrics={visibleMetrics}
|
|
86
91
|
metricsHistory={metricsHistory}
|
|
92
|
+
metricsUnavailable={metricsUnavailable}
|
|
87
93
|
hideMetricsServer={hideMetricsServer}
|
|
88
94
|
renderImageBrowser={({ image, namespace: ns, podName: pod, pullSecrets, onClose, onSwitchToPodFiles }) => (
|
|
89
95
|
<ImageFilesystemModal
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useMemo, useRef } from 'react'
|
|
2
|
-
import { Network } from 'lucide-react'
|
|
2
|
+
import { Network, AlertTriangle, RefreshCw } from 'lucide-react'
|
|
3
3
|
import { TimelineList } from './TimelineList'
|
|
4
4
|
import { TimelineSwimlanes } from './TimelineSwimlanes'
|
|
5
5
|
import { useChanges, useTopology } from '../../api/client'
|
|
@@ -52,7 +52,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
52
52
|
|
|
53
53
|
// Fetch all activity - zoom controls what's visible in the UI
|
|
54
54
|
// Only fetch heavy 10k dataset for swimlanes; list view fetches its own 500
|
|
55
|
-
const { data: activity, isLoading } = useChanges({
|
|
55
|
+
const { data: activity, isLoading, isError, refetch } = useChanges({
|
|
56
56
|
namespaces,
|
|
57
57
|
timeRange: 'all',
|
|
58
58
|
includeK8sEvents: true,
|
|
@@ -115,6 +115,30 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
115
115
|
)
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// A failed fetch must not render as the swimlane "No events yet" empty state —
|
|
119
|
+
// that reads as a quiet cluster rather than a load failure.
|
|
120
|
+
if (isError) {
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex-1 flex flex-col">
|
|
123
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-theme-border">
|
|
124
|
+
<div />
|
|
125
|
+
<ViewModeToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
|
126
|
+
</div>
|
|
127
|
+
<div className="flex-1 flex flex-col items-center justify-center text-theme-text-tertiary gap-3">
|
|
128
|
+
<AlertTriangle className="w-10 h-10 text-amber-400/70" />
|
|
129
|
+
<p className="text-base">Failed to load timeline data</p>
|
|
130
|
+
<button
|
|
131
|
+
onClick={() => refetch()}
|
|
132
|
+
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg hover:bg-theme-hover transition-colors"
|
|
133
|
+
>
|
|
134
|
+
<RefreshCw className="w-3.5 h-3.5" />
|
|
135
|
+
Try again
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
118
142
|
return (
|
|
119
143
|
<TimelineSwimlanes
|
|
120
144
|
events={events}
|