@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.
Files changed (32) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +256 -94
  3. package/src/RadarApp.tsx +4 -1
  4. package/src/api/client.metrics.test.ts +106 -0
  5. package/src/api/client.ts +147 -20
  6. package/src/components/ConnectionErrorView.tsx +1 -1
  7. package/src/components/ContextSwitcher.tsx +5 -1
  8. package/src/components/NamespaceSwitcher.tsx +21 -300
  9. package/src/components/applications/ApplicationsView.tsx +22 -7
  10. package/src/components/audit/AuditView.tsx +11 -2
  11. package/src/components/cost/CostView.tsx +12 -2
  12. package/src/components/gitops/GitOpsView.tsx +22 -7
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +189 -352
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +4 -4
  17. package/src/components/home/ClusterHealthCard.tsx +6 -1
  18. package/src/components/home/HomeView.tsx +20 -7
  19. package/src/components/home/mcpToolCatalog.ts +1 -1
  20. package/src/components/issues/IssuesPane.tsx +29 -18
  21. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  22. package/src/components/resources/ResourcesView.tsx +3 -0
  23. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  24. package/src/components/resources/renderers/PodRenderer.tsx +10 -4
  25. package/src/components/timeline/TimelineView.tsx +26 -2
  26. package/src/components/traffic/TrafficView.tsx +17 -10
  27. package/src/components/ui/Markdown.tsx +2 -2
  28. package/src/components/ui/Omnibar.tsx +1 -1
  29. package/src/components/workload/WorkloadView.tsx +5 -1
  30. package/src/filter/FilterLocationBridge.tsx +30 -0
  31. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  32. 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, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight, RotateCcw, Clock } from 'lucide-react'
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 [handleRefresh, isRefreshAnimating] = useRefreshAnimation(async () => {
40
- await Promise.all([refetchReleases(), refetchUpgradeInfo()])
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
- if (!searchTerm) return releases
49
- const term = searchTerm.toLowerCase()
50
- return releases.filter(
51
- (r) =>
52
- r.name.toLowerCase().includes(term) ||
53
- r.namespace.toLowerCase().includes(term) ||
54
- r.chart.toLowerCase().includes(term)
55
- )
56
- }, [releases, searchTerm])
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 changes
66
- useEffect(() => { setHighlightedIndex(-1) }, [searchTerm])
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-surface sticky top-0 z-10">
306
+ <thead className="bg-theme-base sticky top-0 z-10">
293
307
  <tr>
294
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[28%]">
295
- Name
296
- </th>
297
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[18%]">
298
- Namespace
299
- </th>
300
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[22%]">
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' ? 'text-red-400' : 'text-yellow-400'
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, { bg: string; text: string; dot: string }> = {
487
- healthy: { bg: 'bg-green-500/10', text: 'text-green-400', dot: 'bg-green-500' },
488
- degraded: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', dot: 'bg-yellow-500' },
489
- unhealthy: { bg: 'bg-red-500/10', text: 'text-red-400', dot: 'bg-red-500' },
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
- 'flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded shrink-0',
499
- style.bg, style.text
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
- ? issue.issue_timing === 'started_at_resource_creation'
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 && <span className="text-[10px] text-theme-text-tertiary ml-auto shrink-0">{age}</span>}
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
- allIssues.length > 0 ? (
74
- <>
75
- <SummaryTile label={allIssues.length === 1 ? 'issue' : 'issues'} value={allIssues.length} />
76
- {ISSUE_SEVERITIES.map((s) =>
77
- totals[s] > 0 || severityFilter.has(s) ? (
78
- <SummaryTile
79
- key={s}
80
- label={ISSUE_SEVERITY_LABEL[s]}
81
- value={totals[s]}
82
- tone={SEVERITY_TONE[s]}
83
- active={severityFilter.has(s)}
84
- onClick={() => toggleSeverity(s)}
85
- />
86
- ) : null,
87
- )}
88
- </>
89
- ) : undefined
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 { data: metrics } = useNodeMetrics(nodeName)
17
- const { data: metricsHistory } = useNodeMetricsHistory(nodeName)
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={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 { data: metrics } = usePodMetrics(namespace, podName)
38
- const { data: metricsHistory } = usePodMetricsHistory(namespace, podName)
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={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}